From ec9edc294fd5e7e29977fc7b0e6fb953422bc0e2 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Wed, 19 Aug 2026 05:54:00 +0800 Subject: chore: reorganize dev tools into dev/ directory and consolidate configs Move CI, dev tools, and configs from root-level scattered locations into a unified `dev/` directory structure. Update all references across build scripts, documentation, and editor configurations. Also consolidate editor config generation into build.rs for automated synchronization of rust-analyzer settings across VS Code and Zed. --- .cargo/config.toml | 4 +- .config/ci-ignored-dirs.txt | 5 - .config/docs-lang.txt | 2 - .config/verified-docs.toml | 8 - .config/version-files.toml | 39 -- .github/workflows/ci.yml | 8 +- .gitignore | 9 +- .idea/.gitignore | 10 - .idea/mingling.iml | 18 - .idea/modules.xml | 8 - .idea/runConfigurations/Check__All.xml | 26 - .idea/runConfigurations/Check__Codes.xml | 26 - .idea/runConfigurations/Check__Documents.xml | 26 - .idea/runConfigurations/Refresh_All.xml | 27 - .idea/runConfigurations/Run__Cargo_Format.xml | 23 - .idea/runConfigurations/Run__Deploy_API_Docs.xml | 28 - .../Run__Fix_Documents_Codebox.xml | 28 - .../Run__Generate_Docsify_Sidebar.xml | 28 - .../runConfigurations/Run__Package_All_Crates.xml | 28 - .../Run__Sync_Examples_To_Crates.xml | 28 - .../Run__Sync_Examples_To_Helpdoc.xml | 28 - .idea/runConfigurations/Run__Sync_Feature_List.xml | 28 - .idea/vcs.xml | 6 - .run/.gitignore | 5 - .run/Cargo.lock | 572 --------------- .run/Cargo.toml | 26 - .run/src/bin/ci.py | 75 -- .run/src/bin/clippy-fix.ps1 | 8 - .run/src/bin/clippy-fix.sh | 6 - .run/src/bin/cov-test.rs | 571 --------------- .run/src/bin/deploy-api-docs.rs | 118 ---- .run/src/bin/display-dependency-order.rs | 12 - .run/src/bin/http-page-preview.ps1 | 3 - .run/src/bin/http-page-preview.sh | 2 - .run/src/bin/install-mling.ps1 | 10 - .run/src/bin/install-mling.sh | 17 - .run/src/bin/package-all.rs | 736 ------------------- .run/src/bin/update-version.rs | 104 --- .run/src/bin/windows-folder-hide.ps1 | 115 --- .run/src/dependency_order.rs | 196 ------ .run/src/lib.rs | 459 ------------ .run/src/verify.rs | 506 -------------- .vscode/settings.json | 16 - .vscode/tasks.json | 122 ---- .zed/settings.json | 30 - .zed/tasks.json | 71 -- CONTRIBUTING.md | 28 +- Cargo.lock | 4 + Cargo.toml | 4 + README.md | 2 +- build.rs | 153 +++- dev/ci/Cargo.lock | 777 +++++++++++++++++++++ dev/ci/Cargo.toml | 47 ++ dev/ci/build.rs | 6 + dev/ci/help.txt | 34 + dev/ci/src/bin/ci.rs | 30 + dev/ci/src/cmd.rs | 6 + dev/ci/src/cmd/cmd_git_lock.rs | 77 ++ dev/ci/src/cmd/cmd_git_unlock.rs | 116 +++ dev/ci/src/cmd/cmd_report_clean.rs | 54 ++ dev/ci/src/cmd/cmd_report_collect.rs | 150 ++++ dev/ci/src/cmd/cmd_show_features.rs | 26 + dev/ci/src/cmd/cmd_show_manifests.rs | 71 ++ dev/ci/src/examples.rs | 186 +++++ dev/ci/src/git.rs | 69 ++ dev/ci/src/lib.rs | 28 + dev/ci/src/markdown.rs | 3 + dev/ci/src/markdown/compare.rs | 203 ++++++ dev/ci/src/markdown/project.rs | 347 +++++++++ dev/ci/src/markdown/test.rs | 152 ++++ dev/ci/src/progress.rs | 24 + dev/ci/src/reporter.rs | 208 ++++++ dev/ci/src/res.rs | 14 + dev/ci/src/res/collect_logs.rs | 203 ++++++ dev/ci/src/res/crate_config.rs | 79 +++ dev/ci/src/res/features.rs | 47 ++ dev/ci/src/res/manifests.rs | 103 +++ dev/ci/src/res/print.rs | 174 +++++ dev/ci/src/task.rs | 9 + dev/ci/src/task/cmd_build_check.rs | 47 ++ dev/ci/src/task/cmd_clippy_check.rs | 50 ++ dev/ci/src/task/cmd_docs_check.rs | 44 ++ dev/ci/src/task/cmd_example_check.rs | 69 ++ dev/ci/src/task/cmd_markdown_check.rs | 192 +++++ dev/ci/src/task/cmd_markdown_compare.rs | 221 ++++++ dev/ci/src/task/cmd_test.rs | 54 ++ dev/ci/src/task/run.rs | 114 +++ dev/ci/src/tools.rs | 3 + dev/ci/src/tools/docsify_refresh.rs | 373 ++++++++++ dev/ci/src/tools/example_refresh.rs | 279 ++++++++ dev/ci/src/tools/features_refresh.rs | 96 +++ dev/ci/tmpls/report.md | 9 + dev/ci/tmpls/task_section.md | 18 + dev/configs/ci-ignored-dirs.txt | 5 + dev/configs/docs-lang.txt | 2 + dev/configs/rust-analyzer.json | 16 + dev/configs/verified-docs.toml | 8 + dev/configs/version-files.toml | 39 ++ dev/run/.gitignore | 5 + dev/run/Cargo.lock | 572 +++++++++++++++ dev/run/Cargo.toml | 26 + dev/run/src/bin/ci.py | 75 ++ dev/run/src/bin/clippy-fix.ps1 | 8 + dev/run/src/bin/clippy-fix.sh | 6 + dev/run/src/bin/cov-test.rs | 571 +++++++++++++++ dev/run/src/bin/deploy-api-docs.rs | 118 ++++ dev/run/src/bin/display-dependency-order.rs | 12 + dev/run/src/bin/http-page-preview.ps1 | 3 + dev/run/src/bin/http-page-preview.sh | 2 + dev/run/src/bin/install-mling.ps1 | 10 + dev/run/src/bin/install-mling.sh | 17 + dev/run/src/bin/package-all.rs | 736 +++++++++++++++++++ dev/run/src/bin/update-version.rs | 104 +++ dev/run/src/bin/windows-folder-hide.ps1 | 115 +++ dev/run/src/dependency_order.rs | 196 ++++++ dev/run/src/lib.rs | 459 ++++++++++++ dev/run/src/verify.rs | 506 ++++++++++++++ dist/index.html | 2 +- docs/LICENSE | 21 - docs/_zh_CN/index.html | 2 +- docs/dev/README.md | 2 +- docs/dev/index.html | 2 +- docs/dev/pages/abouts/ci.md | 72 +- docs/dev/pages/abouts/code-verify-system.md | 20 +- docs/doc.html | 111 --- docs/example-pages/examples.json | 393 ----------- docs/example-viewer.html | 4 +- docs/examples.html | 4 +- docs/examples.json | 393 +++++++++++ docs/index.html | 111 +++ docs/licenses/docsify.md | 21 + index.html | 6 +- mingling/src/docs/lib.md | 2 +- mingling_ci/Cargo.lock | 777 --------------------- mingling_ci/Cargo.toml | 47 -- mingling_ci/build.rs | 6 - mingling_ci/help.txt | 34 - mingling_ci/src/bin/ci.rs | 30 - mingling_ci/src/cmd.rs | 6 - mingling_ci/src/cmd/cmd_git_lock.rs | 77 -- mingling_ci/src/cmd/cmd_git_unlock.rs | 116 --- mingling_ci/src/cmd/cmd_report_clean.rs | 54 -- mingling_ci/src/cmd/cmd_report_collect.rs | 150 ---- mingling_ci/src/cmd/cmd_show_features.rs | 26 - mingling_ci/src/cmd/cmd_show_manifests.rs | 71 -- mingling_ci/src/examples.rs | 186 ----- mingling_ci/src/git.rs | 69 -- mingling_ci/src/lib.rs | 28 - mingling_ci/src/markdown.rs | 3 - mingling_ci/src/markdown/compare.rs | 203 ------ mingling_ci/src/markdown/project.rs | 347 --------- mingling_ci/src/markdown/test.rs | 152 ---- mingling_ci/src/progress.rs | 24 - mingling_ci/src/reporter.rs | 208 ------ mingling_ci/src/res.rs | 14 - mingling_ci/src/res/collect_logs.rs | 203 ------ mingling_ci/src/res/crate_config.rs | 79 --- mingling_ci/src/res/features.rs | 47 -- mingling_ci/src/res/manifests.rs | 103 --- mingling_ci/src/res/print.rs | 174 ----- mingling_ci/src/task.rs | 9 - mingling_ci/src/task/cmd_build_check.rs | 47 -- mingling_ci/src/task/cmd_clippy_check.rs | 50 -- mingling_ci/src/task/cmd_docs_check.rs | 44 -- mingling_ci/src/task/cmd_example_check.rs | 69 -- mingling_ci/src/task/cmd_markdown_check.rs | 192 ----- mingling_ci/src/task/cmd_markdown_compare.rs | 221 ------ mingling_ci/src/task/cmd_test.rs | 54 -- mingling_ci/src/task/run.rs | 114 --- mingling_ci/src/tools.rs | 3 - mingling_ci/src/tools/docsify_refresh.rs | 373 ---------- mingling_ci/src/tools/example_refresh.rs | 279 -------- mingling_ci/src/tools/features_refresh.rs | 96 --- mingling_ci/tmpls/report.md | 9 - mingling_ci/tmpls/task_section.md | 18 - run.ps1 | 52 +- run.sh | 52 +- 177 files changed, 9244 insertions(+), 9673 deletions(-) delete mode 100644 .config/ci-ignored-dirs.txt delete mode 100644 .config/docs-lang.txt delete mode 100644 .config/verified-docs.toml delete mode 100644 .config/version-files.toml delete mode 100644 .idea/.gitignore delete mode 100644 .idea/mingling.iml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations/Check__All.xml delete mode 100644 .idea/runConfigurations/Check__Codes.xml delete mode 100644 .idea/runConfigurations/Check__Documents.xml delete mode 100644 .idea/runConfigurations/Refresh_All.xml delete mode 100644 .idea/runConfigurations/Run__Cargo_Format.xml delete mode 100644 .idea/runConfigurations/Run__Deploy_API_Docs.xml delete mode 100644 .idea/runConfigurations/Run__Fix_Documents_Codebox.xml delete mode 100644 .idea/runConfigurations/Run__Generate_Docsify_Sidebar.xml delete mode 100644 .idea/runConfigurations/Run__Package_All_Crates.xml delete mode 100644 .idea/runConfigurations/Run__Sync_Examples_To_Crates.xml delete mode 100644 .idea/runConfigurations/Run__Sync_Examples_To_Helpdoc.xml delete mode 100644 .idea/runConfigurations/Run__Sync_Feature_List.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .run/.gitignore delete mode 100644 .run/Cargo.lock delete mode 100644 .run/Cargo.toml delete mode 100644 .run/src/bin/ci.py delete mode 100644 .run/src/bin/clippy-fix.ps1 delete mode 100755 .run/src/bin/clippy-fix.sh delete mode 100644 .run/src/bin/cov-test.rs delete mode 100644 .run/src/bin/deploy-api-docs.rs delete mode 100644 .run/src/bin/display-dependency-order.rs delete mode 100644 .run/src/bin/http-page-preview.ps1 delete mode 100755 .run/src/bin/http-page-preview.sh delete mode 100644 .run/src/bin/install-mling.ps1 delete mode 100755 .run/src/bin/install-mling.sh delete mode 100644 .run/src/bin/package-all.rs delete mode 100644 .run/src/bin/update-version.rs delete mode 100644 .run/src/bin/windows-folder-hide.ps1 delete mode 100644 .run/src/dependency_order.rs delete mode 100644 .run/src/lib.rs delete mode 100644 .run/src/verify.rs delete mode 100644 .vscode/settings.json delete mode 100644 .vscode/tasks.json delete mode 100644 .zed/settings.json delete mode 100644 .zed/tasks.json create mode 100644 dev/ci/Cargo.lock create mode 100644 dev/ci/Cargo.toml create mode 100644 dev/ci/build.rs create mode 100644 dev/ci/help.txt create mode 100644 dev/ci/src/bin/ci.rs create mode 100644 dev/ci/src/cmd.rs create mode 100644 dev/ci/src/cmd/cmd_git_lock.rs create mode 100644 dev/ci/src/cmd/cmd_git_unlock.rs create mode 100644 dev/ci/src/cmd/cmd_report_clean.rs create mode 100644 dev/ci/src/cmd/cmd_report_collect.rs create mode 100644 dev/ci/src/cmd/cmd_show_features.rs create mode 100644 dev/ci/src/cmd/cmd_show_manifests.rs create mode 100644 dev/ci/src/examples.rs create mode 100644 dev/ci/src/git.rs create mode 100644 dev/ci/src/lib.rs create mode 100644 dev/ci/src/markdown.rs create mode 100644 dev/ci/src/markdown/compare.rs create mode 100644 dev/ci/src/markdown/project.rs create mode 100644 dev/ci/src/markdown/test.rs create mode 100644 dev/ci/src/progress.rs create mode 100644 dev/ci/src/reporter.rs create mode 100644 dev/ci/src/res.rs create mode 100644 dev/ci/src/res/collect_logs.rs create mode 100644 dev/ci/src/res/crate_config.rs create mode 100644 dev/ci/src/res/features.rs create mode 100644 dev/ci/src/res/manifests.rs create mode 100644 dev/ci/src/res/print.rs create mode 100644 dev/ci/src/task.rs create mode 100644 dev/ci/src/task/cmd_build_check.rs create mode 100644 dev/ci/src/task/cmd_clippy_check.rs create mode 100644 dev/ci/src/task/cmd_docs_check.rs create mode 100644 dev/ci/src/task/cmd_example_check.rs create mode 100644 dev/ci/src/task/cmd_markdown_check.rs create mode 100644 dev/ci/src/task/cmd_markdown_compare.rs create mode 100644 dev/ci/src/task/cmd_test.rs create mode 100644 dev/ci/src/task/run.rs create mode 100644 dev/ci/src/tools.rs create mode 100644 dev/ci/src/tools/docsify_refresh.rs create mode 100644 dev/ci/src/tools/example_refresh.rs create mode 100644 dev/ci/src/tools/features_refresh.rs create mode 100644 dev/ci/tmpls/report.md create mode 100644 dev/ci/tmpls/task_section.md create mode 100644 dev/configs/ci-ignored-dirs.txt create mode 100644 dev/configs/docs-lang.txt create mode 100644 dev/configs/rust-analyzer.json create mode 100644 dev/configs/verified-docs.toml create mode 100644 dev/configs/version-files.toml create mode 100644 dev/run/.gitignore create mode 100644 dev/run/Cargo.lock create mode 100644 dev/run/Cargo.toml create mode 100644 dev/run/src/bin/ci.py create mode 100644 dev/run/src/bin/clippy-fix.ps1 create mode 100755 dev/run/src/bin/clippy-fix.sh create mode 100644 dev/run/src/bin/cov-test.rs create mode 100644 dev/run/src/bin/deploy-api-docs.rs create mode 100644 dev/run/src/bin/display-dependency-order.rs create mode 100644 dev/run/src/bin/http-page-preview.ps1 create mode 100755 dev/run/src/bin/http-page-preview.sh create mode 100644 dev/run/src/bin/install-mling.ps1 create mode 100755 dev/run/src/bin/install-mling.sh create mode 100644 dev/run/src/bin/package-all.rs create mode 100644 dev/run/src/bin/update-version.rs create mode 100644 dev/run/src/bin/windows-folder-hide.ps1 create mode 100644 dev/run/src/dependency_order.rs create mode 100644 dev/run/src/lib.rs create mode 100644 dev/run/src/verify.rs delete mode 100644 docs/LICENSE delete mode 100644 docs/doc.html delete mode 100644 docs/example-pages/examples.json create mode 100644 docs/examples.json create mode 100644 docs/index.html create mode 100644 docs/licenses/docsify.md delete mode 100644 mingling_ci/Cargo.lock delete mode 100644 mingling_ci/Cargo.toml delete mode 100644 mingling_ci/build.rs delete mode 100644 mingling_ci/help.txt delete mode 100644 mingling_ci/src/bin/ci.rs delete mode 100644 mingling_ci/src/cmd.rs delete mode 100644 mingling_ci/src/cmd/cmd_git_lock.rs delete mode 100644 mingling_ci/src/cmd/cmd_git_unlock.rs delete mode 100644 mingling_ci/src/cmd/cmd_report_clean.rs delete mode 100644 mingling_ci/src/cmd/cmd_report_collect.rs delete mode 100644 mingling_ci/src/cmd/cmd_show_features.rs delete mode 100644 mingling_ci/src/cmd/cmd_show_manifests.rs delete mode 100644 mingling_ci/src/examples.rs delete mode 100644 mingling_ci/src/git.rs delete mode 100644 mingling_ci/src/lib.rs delete mode 100644 mingling_ci/src/markdown.rs delete mode 100644 mingling_ci/src/markdown/compare.rs delete mode 100644 mingling_ci/src/markdown/project.rs delete mode 100644 mingling_ci/src/markdown/test.rs delete mode 100644 mingling_ci/src/progress.rs delete mode 100644 mingling_ci/src/reporter.rs delete mode 100644 mingling_ci/src/res.rs delete mode 100644 mingling_ci/src/res/collect_logs.rs delete mode 100644 mingling_ci/src/res/crate_config.rs delete mode 100644 mingling_ci/src/res/features.rs delete mode 100644 mingling_ci/src/res/manifests.rs delete mode 100644 mingling_ci/src/res/print.rs delete mode 100644 mingling_ci/src/task.rs delete mode 100644 mingling_ci/src/task/cmd_build_check.rs delete mode 100644 mingling_ci/src/task/cmd_clippy_check.rs delete mode 100644 mingling_ci/src/task/cmd_docs_check.rs delete mode 100644 mingling_ci/src/task/cmd_example_check.rs delete mode 100644 mingling_ci/src/task/cmd_markdown_check.rs delete mode 100644 mingling_ci/src/task/cmd_markdown_compare.rs delete mode 100644 mingling_ci/src/task/cmd_test.rs delete mode 100644 mingling_ci/src/task/run.rs delete mode 100644 mingling_ci/src/tools.rs delete mode 100644 mingling_ci/src/tools/docsify_refresh.rs delete mode 100644 mingling_ci/src/tools/example_refresh.rs delete mode 100644 mingling_ci/src/tools/features_refresh.rs delete mode 100644 mingling_ci/tmpls/report.md delete mode 100644 mingling_ci/tmpls/task_section.md diff --git a/.cargo/config.toml b/.cargo/config.toml index f0b35b3..8a25d59 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,5 +4,5 @@ target-dir = "./.temp/target" [env] [alias] -ci = "run --manifest-path mingling_ci/Cargo.toml --bin ci --quiet --" -dev_tool = "run --manifest-path .run/Cargo.toml --quiet --bin " +ci = "run --manifest-path dev/ci/Cargo.toml --bin ci --quiet --" +dev_tool = "run --manifest-path dev/run/Cargo.toml --quiet --bin " diff --git a/.config/ci-ignored-dirs.txt b/.config/ci-ignored-dirs.txt deleted file mode 100644 index 08037c9..0000000 --- a/.config/ci-ignored-dirs.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Temp -./.temp/ - -# Self -./mingling_ci/ diff --git a/.config/docs-lang.txt b/.config/docs-lang.txt deleted file mode 100644 index 96d4f3c..0000000 --- a/.config/docs-lang.txt +++ /dev/null @@ -1,2 +0,0 @@ -./pages/ -./_zh_CN/pages/ diff --git a/.config/verified-docs.toml b/.config/verified-docs.toml deleted file mode 100644 index df2469b..0000000 --- a/.config/verified-docs.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Files marked in the following document, -# all rust code blocks inside will be verified in CI to ensure they can compile - -[verified] -readme = "./README.md" -getting_started = "./GETTING-STARTED.md" -documents_en_us = "./docs/pages/**" -documents_zh_cn = "./docs/_zh_CN/pages/**" diff --git a/.config/version-files.toml b/.config/version-files.toml deleted file mode 100644 index 30fda5e..0000000 --- a/.config/version-files.toml +++ /dev/null @@ -1,39 +0,0 @@ -[[file]] -file = "./Cargo.toml" -pattern = "version = \"{VER}\"" - -[[file]] -file = "./mingling_cli/Cargo.toml" -pattern = "version = \"{VER}\"" - -[[file]] -file = "./README.md" -pattern = "version = \"{VER}\"" - -[[file]] -file = "./docs/_zh_CN/pages/1-getting-started.md" -pattern = "version = \"{VER}\"" - -[[file]] -file = "./docs/pages/1-getting-started.md" -pattern = "version = \"{VER}\"" - -[[file]] -file = "./docs/res/guide.txt" -pattern = "mingling = \"{VER}\"" - -[[file]] -file = "./index.html" -pattern = "cargo add mingling@{VER}" - -[[file]] -file = "./index.html" -pattern = "version = \"{VER}\"" - -[[file]] -file = "./index.html" -pattern = "mling proj-init {VER}@basic" - -[[file]] -file = "./dist/index.html" -pattern = "mling proj-init {VER}@basic" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56667d4..1814307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,9 +102,9 @@ jobs: shell: bash run: | if [ "$RUNNER_OS" = "Windows" ]; then - powershell -ExecutionPolicy Bypass -File .run/src/bin/install-mling.ps1 + powershell -ExecutionPolicy Bypass -File dev/run/src/bin/install-mling.ps1 else - bash .run/src/bin/install-mling.sh + bash dev/run/src/bin/install-mling.sh fi - name: Package mling @@ -181,13 +181,13 @@ jobs: run: rustup toolchain install nightly - name: Build API docs - run: cargo +nightly run --manifest-path .run/Cargo.toml --bin deploy-api-docs -- --docsrs + run: cargo +nightly run --manifest-path dev/run/Cargo.toml --bin deploy-api-docs -- --docsrs - name: Install cargo-llvm-cov run: cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov - name: Run cov-test - run: cargo run --manifest-path .run/Cargo.toml --bin cov-test + run: cargo run --manifest-path dev/run/Cargo.toml --bin cov-test - name: Delete .temp directory before deployment run: rm -rf .temp diff --git a/.gitignore b/.gitignore index aeaee81..ea4e635 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,13 @@ docs/api-docs/ docs/cov-test/ # Drafts -__*.md +__*.* __*/ -__*.py -__*.rs # Fuck nul + +# Editors config +/.zed/ +/.vscode/ +/.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 30cf57e..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/mingling.iml b/.idea/mingling.iml deleted file mode 100644 index ca57e6c..0000000 --- a/.idea/mingling.iml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 764f612..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Check__All.xml b/.idea/runConfigurations/Check__All.xml deleted file mode 100644 index 95af540..0000000 --- a/.idea/runConfigurations/Check__All.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Check__Codes.xml b/.idea/runConfigurations/Check__Codes.xml deleted file mode 100644 index 75f5e09..0000000 --- a/.idea/runConfigurations/Check__Codes.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Check__Documents.xml b/.idea/runConfigurations/Check__Documents.xml deleted file mode 100644 index 3b40bbb..0000000 --- a/.idea/runConfigurations/Check__Documents.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Refresh_All.xml b/.idea/runConfigurations/Refresh_All.xml deleted file mode 100644 index 24be845..0000000 --- a/.idea/runConfigurations/Refresh_All.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - diff --git a/.idea/runConfigurations/Run__Cargo_Format.xml b/.idea/runConfigurations/Run__Cargo_Format.xml deleted file mode 100644 index a99a91d..0000000 --- a/.idea/runConfigurations/Run__Cargo_Format.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Deploy_API_Docs.xml b/.idea/runConfigurations/Run__Deploy_API_Docs.xml deleted file mode 100644 index 1a32a64..0000000 --- a/.idea/runConfigurations/Run__Deploy_API_Docs.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Fix_Documents_Codebox.xml b/.idea/runConfigurations/Run__Fix_Documents_Codebox.xml deleted file mode 100644 index 0863ef1..0000000 --- a/.idea/runConfigurations/Run__Fix_Documents_Codebox.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Generate_Docsify_Sidebar.xml b/.idea/runConfigurations/Run__Generate_Docsify_Sidebar.xml deleted file mode 100644 index a50a2dd..0000000 --- a/.idea/runConfigurations/Run__Generate_Docsify_Sidebar.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Package_All_Crates.xml b/.idea/runConfigurations/Run__Package_All_Crates.xml deleted file mode 100644 index 336c9b3..0000000 --- a/.idea/runConfigurations/Run__Package_All_Crates.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Sync_Examples_To_Crates.xml b/.idea/runConfigurations/Run__Sync_Examples_To_Crates.xml deleted file mode 100644 index 43db9bb..0000000 --- a/.idea/runConfigurations/Run__Sync_Examples_To_Crates.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Sync_Examples_To_Helpdoc.xml b/.idea/runConfigurations/Run__Sync_Examples_To_Helpdoc.xml deleted file mode 100644 index 8e7faff..0000000 --- a/.idea/runConfigurations/Run__Sync_Examples_To_Helpdoc.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/runConfigurations/Run__Sync_Feature_List.xml b/.idea/runConfigurations/Run__Sync_Feature_List.xml deleted file mode 100644 index 1e6ed8f..0000000 --- a/.idea/runConfigurations/Run__Sync_Feature_List.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.run/.gitignore b/.run/.gitignore deleted file mode 100644 index 40f407e..0000000 --- a/.run/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# All temp build artifacts will be stored in this dir -/target - -# If you want to add some Rust crates? Remove this line -# /Cargo.* diff --git a/.run/Cargo.lock b/.run/Cargo.lock deleted file mode 100644 index 3117d0b..0000000 --- a/.run/Cargo.lock +++ /dev/null @@ -1,572 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "arg-picker" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd62c395708a956e98e06b6b18200b2a65a5ae63448ed7d59cca32833aaf7265" -dependencies = [ - "arg-picker-macros", - "just_fmt 0.2.0", -] - -[[package]] -name = "arg-picker-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04fddbb5c1f26450cfc6aded0559b759b292d400dba1ff4e45128f3e0e1ffe6b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "just_fmt" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5454cda0d57db59778608d7a47bff5b16c6705598265869fb052b657f66cf05e" - -[[package]] -name = "just_fmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6170dccbc3ea15dfb7f2da964097f814aba1dd8f746d4ffc56f33245c38e6d96" - -[[package]] -name = "just_template" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3edb658c34b10b69c4b3b58f7ba989cd09c82c0621dee1eef51843c2327225" -dependencies = [ - "just_fmt 0.1.2", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "pin-project-lite", - "tokio-macros", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tools" -version = "0.1.0" -dependencies = [ - "arg-picker", - "colored", - "flate2", - "indicatif", - "just_fmt 0.1.2", - "just_template", - "serde", - "serde_json", - "tar", - "tokio", - "toml", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] -name = "wasm-bindgen" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/.run/Cargo.toml b/.run/Cargo.toml deleted file mode 100644 index 4935f2d..0000000 --- a/.run/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "tools" -version = "0.1.0" -edition = "2024" -authors = ["Weicao-CatilGrass"] -description = "Development tools for mingling" -license = "MIT OR Apache-2.0" -repository = "https://github.com/catilgrass/mingling" -readme = "../README.md" -keywords = ["cli", "development", "tools"] -categories = ["command-line-interface", "development-tools"] - -[dependencies] -just_template = "0.1.3" -just_fmt = "0.1.2" -colored = "3.1.1" -toml = "0.8" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -indicatif = "0.18.4" -flate2 = "1" -tar = "0.4" -arg-picker = "0.1.0" - -[workspace] diff --git a/.run/src/bin/ci.py b/.run/src/bin/ci.py deleted file mode 100644 index 6234a19..0000000 --- a/.run/src/bin/ci.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Full CI orchestration for the mingling project. - -Runs every `cargo ci` step in order: lock the workspace, run all checks, -refresh the generated artifacts, then unlock. The final `git-unlock` doubles -as the idempotency check: it fails with a non-zero exit code when the run -left the working tree dirty. - -The script locates the git repository root and runs with it as the working -directory, so it can be invoked from anywhere inside the repo. -""" - -import os -import subprocess -import sys -from pathlib import Path - -# The pipeline steps, in execution order, as (command, args) pairs. -STEPS: list[tuple[str, list[str]]] = [ - ("git-lock", []), - ("report-clean", []), - ("build-check", []), - ("clippy-check", []), - ("test-all", []), - ("example-check", []), - ("docs-check", []), - ("example-refresh", []), - ("docsify-refresh", []), - ("features-refresh", []), - # Idempotency check: exits non-zero if CI contaminated the workspace, and - # prints the diff of the contamination before restoring. - ("git-unlock", ["--show-diff"]), -] - - -def find_repo_root() -> Path: - """Return the nearest ancestor directory containing `.git`.""" - current = Path.cwd() - for directory in (current, *current.parents): - if (directory / ".git").is_dir(): - return directory - raise SystemExit("error: not inside a git repository") - - -def main() -> int: - root = find_repo_root() - os.chdir(root) - - # Signature banner: docs/res/ci_banner.txt, relative to this script - # (.run/src/bin -> four levels up is the repo root). - banner = ( - Path(__file__).resolve().parent.parent.parent.parent - / "docs" - / "res" - / "ci_banner.txt" - ) - try: - print(banner.read_text(encoding="utf-8"), end="") - except OSError: - pass - - for command, args in STEPS: - print(f"==> cargo ci {' '.join([command, *args])}") - result = subprocess.run(["cargo", "ci", command, *args], check=False) - if result.returncode != 0: - print( - f"error: step `{command}` failed with exit code {result.returncode}", - file=sys.stderr, - ) - return result.returncode - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.run/src/bin/clippy-fix.ps1 b/.run/src/bin/clippy-fix.ps1 deleted file mode 100644 index 1d24f92..0000000 --- a/.run/src/bin/clippy-fix.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$starting_dir = Get-Location -Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object { - $project_dir = $_.DirectoryName - Push-Location $project_dir - cargo clippy --fix --allow-dirty --allow-no-vcs --quiet - Pop-Location -} -Set-Location $starting_dir diff --git a/.run/src/bin/clippy-fix.sh b/.run/src/bin/clippy-fix.sh deleted file mode 100755 index 9771ad4..0000000 --- a/.run/src/bin/clippy-fix.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -find . -name "Cargo.toml" -type f | while read -r cargo_file; do - project_dir=$(dirname "$cargo_file") - (cd "$project_dir" && cargo clippy --fix --allow-dirty --allow-no-vcs --quiet) -done diff --git a/.run/src/bin/cov-test.rs b/.run/src/bin/cov-test.rs deleted file mode 100644 index f62ff01..0000000 --- a/.run/src/bin/cov-test.rs +++ /dev/null @@ -1,571 +0,0 @@ -//! Coverage test generator for mingling. -//! -//! This script requires the **fork** of cargo-llvm-cov: -//! -//! -//! The upstream `report` command cannot include binaries of non-workspace -//! crates (examples and test crates) and unconditionally filters -//! `tests`/`examples` source files. The fork adds two flags to fix this: -//! -//! - `--object `: include arbitrary binaries in the report -//! (upstream issue taiki-e/cargo-llvm-cov#367) -//! - `--include-examples`: stop filtering source files under the -//! `examples` directory (upstream issue taiki-e/cargo-llvm-cov#503) -//! -//! The script itself does not use `--include-examples`; it passes -//! `--no-default-ignore-filename-regex` and supplies its own filter so that -//! `tests`/`benches` directories stay in the report too. -//! -//! Install it with: -//! -//! ```bash -//! cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov -//! ``` - -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; -use tools::{eprintln_cargo_style, println_cargo_style, run_cmd}; - -const OUTPUT_DIR: &str = "docs/cov-test"; - -/// Shared target directory for all `cargo llvm-cov` runs. -/// -/// Pointing every run at the same target dir makes all of them share the -/// instrumented build cache and, more importantly, accumulate profraw files -/// in one place so the final `report` can merge everything. -const COV_TARGET_DIR: &str = ".temp/cov-llvm"; - -/// An example's `test.toml` (`[[runs]]` entries). -#[derive(Deserialize)] -struct TestConfig { - runs: Vec, -} - -/// One `[[runs]]` entry of an example's `test.toml`. -#[derive(Deserialize)] -struct TestCase { - input: Vec, -} - -fn main() { - let repo_root = find_git_repo().expect("Failed to find git repository root"); - let output_path = repo_root.join(OUTPUT_DIR); - let cov_target = repo_root.join(COV_TARGET_DIR); - - // Read features from [package.metadata.docs.rs] - let features = tools::read_features().unwrap_or_else(|e| { - eprintln!("Error: {}", e); - std::process::exit(1); - }); - let features_arg = features.join(","); - - // Ensure output directory exists - std::fs::create_dir_all(&output_path).expect("Failed to create output directory"); - std::fs::create_dir_all(&cov_target).expect("Failed to create cov target directory"); - - // All `cargo llvm-cov` invocations below share one target dir, so profraw - // files accumulate and are merged by the final `report` command. - // SAFETY: set before any thread is spawned; this process only shells out - // to subcommands via std::process. - unsafe { - std::env::set_var("CARGO_LLVM_COV_TARGET_DIR", &cov_target); - } - - // Drop stale profraw from previous runs (keep the instrumented build cache). - clean_old_profraw(&cov_target); - - println_cargo_style!("Features: {}", features_arg); - println_cargo_style!("Target: {}", cov_target.display()); - - // 1. Workspace tests - println_cargo_style!("Running: cargo llvm-cov test --workspace"); - run_cmd!(format!( - "cargo llvm-cov test --no-report --workspace --features \"{}\" --color always", - features_arg - )) - .unwrap_or_else(|code| { - eprintln_cargo_style!("workspace tests failed with exit code {}", code); - std::process::exit(code); - }); - - // 2. Integration test crates under mingling_core/tests (excluded from the - // workspace, so they need their own `--manifest-path` runs) - for manifest in find_test_crate_manifests(&repo_root) { - println_cargo_style!( - "Running: cargo llvm-cov test {}", - manifest.file_name().unwrap_or_default().to_string_lossy() - ); - run_cmd!(format!( - "cargo llvm-cov test --no-report --manifest-path \"{}\" --color always", - manifest.display() - )) - .unwrap_or_else(|code| { - eprintln_cargo_style!( - "test crate {} failed with exit code {}", - manifest.display(), - code - ); - std::process::exit(code); - }); - } - - // 3. Examples: build each example with explicit RUSTFLAGS, then execute - // every command declared in the example's test.toml directly. - // - // NOTE: `cargo llvm-cov run` cannot be used here. Its rustc wrapper - // only instruments the crates of the *current* cargo project (with - // `--manifest-path` that is the example itself), so the mingling - // libraries — being dependencies — would not be instrumented and their - // coverage would silently be lost (once_exec.rs showed 0%). Building - // with plain RUSTFLAGS instruments the whole dependency graph. - // - // RUSTFLAGS/CARGO_TARGET_DIR are set process-wide here because only the - // `report` step (which does not compile) follows. Non-zero exit codes - // are expected for some examples (e.g. `--help` exits with 2); profraw - // is still written. - unsafe { - std::env::set_var("RUSTFLAGS", "-Cinstrument-coverage"); - std::env::set_var("CARGO_TARGET_DIR", &cov_target); - } - let examples = load_example_commands(&repo_root); - let mut built = std::collections::HashSet::new(); - for (example, input) in &examples { - if built.insert(example.clone()) { - println_cargo_style!("Building: {}", example); - run_cmd!(format!( - "cargo build --manifest-path examples/{}/Cargo.toml --color always", - example - )) - .unwrap_or_else(|code| { - eprintln_cargo_style!( - "build of example {} failed with exit code {}", - example, - code - ); - std::process::exit(code); - }); - } - let binary = cov_target.join("debug").join(get_binary_name(example)); - let profraw = format!( - "{}/example-{}.%p.profraw", - cov_target.to_string_lossy(), - example - ); - match std::process::Command::new(&binary) - .args(input) - .env("LLVM_PROFILE_FILE", &profraw) - .status() - { - Ok(status) if status.success() => {} - Ok(status) => println_cargo_style!( - "Warning: example {} exited with {:?}, profraw still recorded", - example, - status.code() - ), - Err(e) => eprintln_cargo_style!("Failed to run example {}: {}", example, e), - } - } - - // 4. Collect the binaries of non-workspace crates (examples + test crates). - // The automatic object-file detection only knows workspace members, so - // these must be passed explicitly via --object. - let member_names = workspace_member_names(&repo_root); - let object_args = collect_object_args(&cov_target, &member_names); - - // 5. Generate the merged HTML report. - // - // --no-default-ignore-filename-regex: the default regex unconditionally - // excludes `examples`/`tests` directories, which is exactly what we want - // to include here, so we take over the filter ourselves. - let ignore_re = build_ignore_regex(&cov_target); - println_cargo_style!("Running: cargo llvm-cov report --html"); - run_cmd!(format!( - "cargo llvm-cov report --html --output-dir \"{}\" --no-default-ignore-filename-regex --ignore-filename-regex \"{}\" {} --color always", - output_path.to_string_lossy(), - ignore_re, - object_args - )) - .unwrap_or_else(|code| { - eprintln_cargo_style!("cargo llvm-cov report failed with exit code {}", code); - std::process::exit(code); - }); - - // Move files from /html/ to - let html_dir = output_path.join("html"); - if html_dir.exists() && html_dir.is_dir() { - println_cargo_style!("Moving files from {}/html/ to {}/", OUTPUT_DIR, OUTPUT_DIR); - - for entry in fs::read_dir(&html_dir).expect("Failed to read html directory") { - let entry = entry.expect("Failed to read entry"); - let entry_path = entry.path(); - let file_name = entry - .file_name() - .to_str() - .expect("Invalid filename") - .to_owned(); - - let dest_path = output_path.join(&file_name); - if dest_path.exists() { - if dest_path.is_dir() { - fs::remove_dir_all(&dest_path).unwrap_or_else(|e| { - eprintln!( - "Warning: could not remove directory {}: {}", - dest_path.display(), - e - ); - }); - } else { - fs::remove_file(&dest_path).unwrap_or_else(|e| { - eprintln!( - "Warning: could not remove file {}: {}", - dest_path.display(), - e - ); - }); - } - } - fs::rename(&entry_path, &dest_path).unwrap_or_else(|e| { - eprintln!("Warning: could not move {}: {}", entry_path.display(), e); - }); - } - - fs::remove_dir(&html_dir).unwrap_or_else(|e| { - eprintln!("Warning: could not remove html directory: {}", e); - }); - - println_cargo_style!("Files moved successfully."); - } - - // 6. Recolor the per-file coverage summary with project-specific - // thresholds: 0-50% red, 51-80% yellow, 81-100% green. llvm-cov's - // built-in thresholds differ, and the color is assigned when the HTML - // is generated, so the summary table is rewritten here. - let index_path = output_path.join("index.html"); - if let Err(e) = recolor_report_index(&index_path) { - eprintln_cargo_style!("Warning: failed to recolor {}: {}", index_path.display(), e); - } - - println_cargo_style!( - "Done: coverage report generated at {}/index.html", - OUTPUT_DIR - ); -} - -/// Remove `*.profraw` from the shared target dir so stale data from previous -/// runs does not pollute the merged report. The instrumented build cache -/// (everything else) is kept. -fn clean_old_profraw(cov_target: &Path) { - if let Ok(entries) = fs::read_dir(cov_target) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|e| e == "profraw") { - let _ = fs::remove_file(&path); - } - } - } -} - -/// All `mingling_core/tests//Cargo.toml` manifests. -fn find_test_crate_manifests(repo_root: &Path) -> Vec { - let tests_dir = repo_root.join("mingling_core/tests"); - let mut manifests = Vec::new(); - if let Ok(entries) = fs::read_dir(&tests_dir) { - for entry in entries.flatten() { - let manifest = entry.path().join("Cargo.toml"); - if manifest.is_file() { - manifests.push(manifest); - } - } - } - manifests.sort(); - manifests -} - -/// Parse every `examples//test.toml` into `(example_name, input)` pairs. -fn load_example_commands(repo_root: &Path) -> Vec<(String, Vec)> { - let examples_dir = repo_root.join("examples"); - let mut entries: Vec<_> = std::fs::read_dir(&examples_dir) - .unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read {}: {}", examples_dir.display(), e); - std::process::exit(1); - }) - .flatten() - .collect(); - entries.sort_by_key(|e| e.file_name()); - - let mut pairs = Vec::new(); - for entry in entries { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let test_toml = path.join("test.toml"); - if !test_toml.is_file() { - continue; - } - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default() - .to_string(); - let content = fs::read_to_string(&test_toml).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to read {}: {}", test_toml.display(), e); - std::process::exit(1); - }); - let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { - eprintln_cargo_style!("Failed to parse {}: {}", test_toml.display(), e); - std::process::exit(1); - }); - for case in config.runs { - pairs.push((name.clone(), case.input)); - } - } - pairs -} - -/// Names of all workspace members, from `cargo metadata --no-deps`. -fn workspace_member_names(repo_root: &Path) -> Vec { - let Ok(output) = tools::run_cmd_capture_with_dir( - "cargo metadata --no-deps --format-version 1".to_string(), - repo_root, - ) else { - return Vec::new(); - }; - let Ok(json) = serde_json::from_str::(&output) else { - return Vec::new(); - }; - json["packages"] - .as_array() - .into_iter() - .flatten() - .filter_map(|p| p["name"].as_str().map(str::to_owned)) - .collect() -} - -/// Collect the binaries of non-workspace crates (examples and test crates) -/// from the shared target dir, as `--object ` arguments. -/// -/// - `debug/` root: example binaries (built via `cargo llvm-cov run`). -/// - `debug/deps/`: test crate binaries (e.g. `integration-`); their -/// names do not follow a single pattern, so anything that is not a -/// workspace-member binary and not a proc-macro `.so` is collected. -/// -/// Workspace member binaries are detected automatically by `report` and must -/// NOT be passed again (duplicate `-object` entries produce duplicated -/// output). Hard links to the same file are deduplicated by inode. -fn collect_object_args(cov_target: &Path, member_names: &[String]) -> String { - let debug_dir = cov_target.join("debug"); - let mut objects = Vec::new(); - let mut seen = std::collections::HashSet::new(); - - for dir in [debug_dir.clone(), debug_dir.join("deps")] { - let Ok(entries) = fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() || !is_executable(&path) { - continue; - } - if !seen.insert(file_id(&path)) { - continue; - } - let Some(name) = path.file_name().and_then(|s| s.to_str()) else { - continue; - }; - // Proc-macro shared objects are either workspace members (picked - // up automatically) or external deps (excluded from the report - // by the ignore regex), so never pass them explicitly. - if name.starts_with("lib") && name.ends_with(".so") { - continue; - } - if is_workspace_member_binary(name, member_names) { - continue; - } - objects.push(path); - } - } - - objects.sort(); - objects - .iter() - .map(|p| format!("--object \"{}\"", p.to_string_lossy())) - .collect::>() - .join(" ") -} - -/// True if the binary name (e.g. `mingling_core-fea14a01b88afcaa`) belongs to -/// a workspace member. -fn is_workspace_member_binary(name: &str, member_names: &[String]) -> bool { - let stem = strip_cargo_hash(name); - member_names.iter().any(|m| stem == m) -} - -/// Strip the cargo-generated hash suffix: `mingling_core-fea14a01b88afcaa` -> -/// `mingling_core`. Returns the input unchanged if there is no such suffix. -fn strip_cargo_hash(name: &str) -> &str { - let Some(idx) = name.rfind('-') else { - return name; - }; - let (head, tail) = name.split_at(idx); - let hash = &tail[1..]; - if hash.len() == 16 && hash.chars().all(|c| c.is_ascii_hexdigit()) { - head - } else { - name - } -} - -/// A stable identity for deduplicating hard links: device+inode on Unix, -/// canonicalized path elsewhere. -fn file_id(path: &Path) -> String { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt as _; - if let Ok(metadata) = fs::metadata(path) { - return format!("{}:{}", metadata.dev(), metadata.ino()); - } - } - fs::canonicalize(path) - .unwrap_or_else(|_| path.to_path_buf()) - .to_string_lossy() - .into_owned() -} - -/// Resolve binary filename for the given example. -/// -/// The binary name matches the package name. On Windows, the `.exe` suffix is -/// required. -fn get_binary_name(example_name: &str) -> String { - let base = example_name; - if cfg!(target_os = "windows") { - format!("{base}.exe") - } else { - base.to_string() - } -} - -/// Rewrite the per-file coverage colors in `index.html` with project-specific -/// thresholds: 0-50% red, 51-80% yellow, 81-100% green. -fn recolor_report_index(index_path: &Path) -> std::io::Result<()> { - let content = fs::read_to_string(index_path)?; - fs::write(index_path, recolor_coverage_table(&content)) -} - -/// Recolor every `
XX% ...
` cell -/// in the coverage summary table according to the new thresholds. Cells with -/// no data (e.g. branch coverage `- (0/0)`, class `gray`) are left as-is. -fn recolor_coverage_table(input: &str) -> String { - const TD: &str = "
") else {
-            out.push_str(rest);
-            return out;
-        };
-        let color = &rest[..pre_end];
-        let tail = &rest[pre_end + "'>
".len()..];
-        let pct: String = tail
-            .trim_start()
-            .chars()
-            .take_while(|c| c.is_ascii_digit() || *c == '.')
-            .collect();
-        let new_color = match pct.parse::() {
-            Ok(v) if v <= 50.0 => "red",
-            Ok(v) if v <= 80.0 => "yellow",
-            Ok(_) => "green",
-            Err(_) => color, // no data (e.g. gray branch column)
-        };
-        out.push_str(new_color);
-        out.push_str("'>
");
-        rest = tail;
-    }
-    out.push_str(rest);
-    out
-}
-
-/// True if the file is executable: mode bits on Unix, `.exe` on Windows.
-fn is_executable(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        use std::os::unix::fs::PermissionsExt as _;
-        let Ok(metadata) = std::fs::metadata(path) else {
-            return false;
-        };
-        metadata.permissions().mode() & 0o111 != 0
-    }
-    #[cfg(not(unix))]
-    {
-        path.extension()
-            .is_some_and(|e| e.eq_ignore_ascii_case("exe"))
-    }
-}
-
-/// Regex that keeps only the project's own sources in the report:
-/// excludes the shared llvm-cov target dir, the standard library, and
-/// external dependencies.
-fn build_ignore_regex(cov_target: &Path) -> String {
-    let target = regex_escape_path(cov_target);
-    format!(
-        "^{target}($|/)|/rustc/([0-9a-f]+|[0-9]+\\.[0-9]+\\.[0-9]+)/|/\\.cargo/(registry|git)/|/\\.rustup/toolchains($|/)"
-    )
-}
-
-/// Escape a path for use inside a regular expression (as a literal prefix).
-fn regex_escape_path(path: &Path) -> String {
-    let s = path.to_string_lossy().replace('\\', "/");
-    let mut escaped = String::with_capacity(s.len());
-    for ch in s.chars() {
-        if ch == '.' || ch == '-' {
-            escaped.push('\\');
-        }
-        escaped.push(ch);
-    }
-    escaped
-}
-
-fn find_git_repo() -> Option {
-    let mut current_dir = std::env::current_dir().ok()?;
-
-    loop {
-        let git_dir = current_dir.join(".git");
-        if git_dir.exists() && git_dir.is_dir() {
-            return Some(current_dir);
-        }
-
-        if !current_dir.pop() {
-            break;
-        }
-    }
-
-    None
-}
-
-#[cfg(test)]
-mod tests {
-    use super::recolor_coverage_table;
-
-    #[test]
-    fn recolor_thresholds() {
-        let input = concat!(
-            "
  50.00% (2/4)
", - "
  51.23% (32/52)
", - "
  80.00% (48/89)
", - "
  81.00% (1/1)
", - "
  90.00% (6/7)
", - "
- (0/0)
", - ); - let out = recolor_coverage_table(input); - assert!(out.contains("class='column-entry-red'>
  50.00%"));
-        assert!(out.contains("class='column-entry-yellow'>
  51.23%"));
-        assert!(out.contains("class='column-entry-yellow'>
  80.00%"));
-        assert!(out.contains("class='column-entry-green'>
  81.00%"));
-        assert!(out.contains("class='column-entry-green'>
  90.00%"));
-        assert!(out.contains("class='column-entry-gray'>
- (0/0)"));
-    }
-}
diff --git a/.run/src/bin/deploy-api-docs.rs b/.run/src/bin/deploy-api-docs.rs
deleted file mode 100644
index 961eb04..0000000
--- a/.run/src/bin/deploy-api-docs.rs
+++ /dev/null
@@ -1,118 +0,0 @@
-use std::path::Path;
-
-use arg_picker::{Picker, macros::arg};
-use tools::{println_cargo_style, run_cmd};
-
-const OUTPUT_DIR: &str = "docs/api-docs";
-
-fn main() {
-    let using_docsrs = Picker::from_args()
-        .pick_or_default(&arg![docsrs: bool])
-        .unwrap();
-
-    let repo_root = find_git_repo().expect("Failed to find git repository root");
-
-    // Read features from [package.metadata.docs.rs]
-    let features = tools::read_features().unwrap_or_else(|e| {
-        eprintln!("Error: {}", e);
-        std::process::exit(1);
-    });
-
-    let features_arg = features.join(",");
-
-    // Ensure output directory exists
-    let output_path = repo_root.join(OUTPUT_DIR);
-    std::fs::create_dir_all(&output_path).expect("Failed to create output directory");
-
-    // Build cargo doc command
-    let cmd = if using_docsrs {
-        format!(
-            "cargo +nightly rustdoc --features \"{}\" -p mingling --target-dir \"{}\" --color always -- --cfg docsrs",
-            features_arg,
-            output_path.join("target").to_string_lossy()
-        )
-    } else {
-        format!(
-            "cargo doc --no-deps --features \"{}\" -p mingling --target-dir \"{}\" --color always",
-            features_arg,
-            output_path.join("target").to_string_lossy()
-        )
-    };
-
-    println_cargo_style!("Features: {}", features_arg);
-    println_cargo_style!("Output: {}", output_path.display());
-
-    // Run cargo doc, then copy generated docs to output directory
-    println_cargo_style!("Building: docs (cargo doc --no-deps)");
-    run_cmd!(&cmd).unwrap_or_else(|code| {
-        eprintln!("Error: cargo doc failed with exit code {}", code);
-        std::process::exit(code);
-    });
-
-    // Copy generated docs from target/doc to OUTPUT_DIR (top level)
-    let doc_source = output_path.join("target").join("doc");
-    let doc_dest = &output_path;
-
-    if doc_source.exists() {
-        println_cargo_style!("Copying: docs to output directory");
-        // Remove old docs in destination (except target/)
-        if let Ok(entries) = std::fs::read_dir(doc_dest) {
-            for entry in entries.flatten() {
-                let path = entry.path();
-                if path.file_name().and_then(|n| n.to_str()) == Some("target") {
-                    continue;
-                }
-                if path.is_dir() {
-                    std::fs::remove_dir_all(&path).ok();
-                } else {
-                    std::fs::remove_file(&path).ok();
-                }
-            }
-        }
-        copy_dir_recursively(&doc_source, doc_dest).expect("Failed to copy documentation");
-    }
-
-    // Clean up the intermediate target directory to save space
-    std::fs::remove_dir_all(output_path.join("target")).ok();
-
-    println_cargo_style!("Done: API docs deployed to {}", output_path.display());
-}
-
-fn copy_dir_recursively(src: &Path, dst: &Path) -> std::io::Result<()> {
-    if !dst.exists() {
-        std::fs::create_dir_all(dst)?;
-    }
-
-    for entry in std::fs::read_dir(src)? {
-        let entry = entry?;
-        let file_type = entry.file_type()?;
-        let src_path = entry.path();
-        let file_name = src_path.file_name().expect("Failed to get file name");
-        let dst_path = dst.join(file_name);
-
-        if file_type.is_dir() {
-            copy_dir_recursively(&src_path, &dst_path)?;
-        } else {
-            std::fs::copy(&src_path, &dst_path)?;
-        }
-    }
-
-    Ok(())
-}
-
-fn find_git_repo() -> Option {
-    let mut current_dir = std::env::current_dir().ok()?;
-
-    loop {
-        let git_dir = current_dir.join(".git");
-        if git_dir.exists() && git_dir.is_dir() {
-            return Some(current_dir);
-        }
-
-        if !current_dir.pop() {
-            break;
-        }
-    }
-
-    None
-}
diff --git a/.run/src/bin/display-dependency-order.rs b/.run/src/bin/display-dependency-order.rs
deleted file mode 100644
index a31c67a..0000000
--- a/.run/src/bin/display-dependency-order.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-use tools::{dependency_order::display_dependency_order, eprintln_cargo_style};
-
-fn main() {
-    let order = display_dependency_order();
-    if order.is_empty() {
-        eprintln_cargo_style!("could not find workspace root or mingling crates");
-        std::process::exit(1);
-    }
-    for path in order {
-        println!("{}", path.display());
-    }
-}
diff --git a/.run/src/bin/http-page-preview.ps1 b/.run/src/bin/http-page-preview.ps1
deleted file mode 100644
index 8cc3579..0000000
--- a/.run/src/bin/http-page-preview.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-$starting_dir = Get-Location
-python -m http.server 3000
-Set-Location $starting_dir
diff --git a/.run/src/bin/http-page-preview.sh b/.run/src/bin/http-page-preview.sh
deleted file mode 100755
index bed4b1c..0000000
--- a/.run/src/bin/http-page-preview.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/bash
-python3 -m http.server 3000
diff --git a/.run/src/bin/install-mling.ps1 b/.run/src/bin/install-mling.ps1
deleted file mode 100644
index 2b55a09..0000000
--- a/.run/src/bin/install-mling.ps1
+++ /dev/null
@@ -1,10 +0,0 @@
-$ErrorActionPreference = "Stop"
-
-cargo build --release --manifest-path mingling_cli/Cargo.toml
-
-New-Item -ItemType Directory -Force -Path .temp/mling/bin, .temp/mling/scripts | Out-Null
-
-Copy-Item .temp/target/release/mling.exe .temp/mling/bin/
-Copy-Item .temp/target/release/mingling-cli.exe .temp/mling/bin/
-Copy-Item .temp/target/mingling/mling_comp.ps1 .temp/mling/scripts/mling_comp.ps1
-Copy-Item mingling_cli/scripts/load_mling.ps1 .temp/mling/
diff --git a/.run/src/bin/install-mling.sh b/.run/src/bin/install-mling.sh
deleted file mode 100755
index e8cfa18..0000000
--- a/.run/src/bin/install-mling.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-
-set -e
-
-cargo build --release --manifest-path mingling_cli/Cargo.toml
-
-mkdir -p .temp/mling/bin .temp/mling/scripts
-
-cp .temp/target/release/mling .temp/mling/bin/
-cp .temp/target/release/mingling-cli .temp/mling/bin/
-
-for comp in zsh sh fish; do
-    cp ".temp/target/mingling/mling_comp.$comp" ".temp/mling/scripts/mling_comp.$comp"
-done
-cp mingling_cli/scripts/load_mling.zsh .temp/mling/
-cp mingling_cli/scripts/load_mling.sh .temp/mling/
-cp mingling_cli/scripts/load_mling.fish .temp/mling/
diff --git a/.run/src/bin/package-all.rs b/.run/src/bin/package-all.rs
deleted file mode 100644
index ecdd133..0000000
--- a/.run/src/bin/package-all.rs
+++ /dev/null
@@ -1,736 +0,0 @@
-use std::collections::HashMap;
-use std::path::{Path, PathBuf};
-
-use flate2::read::GzDecoder;
-use serde::Deserialize;
-use tar::Archive;
-use toml::Table as TomlTable;
-use tools::{
-    dependency_order::find_workspace_root, eprintln_cargo_style, println_cargo_style,
-    run_cmd_capture_with_dir, wprintln_cargo_style,
-};
-
-/// A single member from `cargo metadata` output.
-#[derive(Deserialize, Debug)]
-struct MetadataPackage {
-    name: String,
-    version: String,
-    manifest_path: String,
-}
-
-/// The top-level metadata structure.
-#[derive(Deserialize, Debug)]
-struct Metadata {
-    #[allow(dead_code)]
-    workspace_root: String,
-    packages: Vec,
-}
-
-fn main() {
-    // 1. Determine project root
-    let cwd = std::env::current_dir().expect("failed to get current working directory");
-    let workspace_root = find_workspace_root(&cwd).expect("not inside a Cargo workspace");
-    println_cargo_style!("Workspace: {}", workspace_root.display());
-
-    let pre_release_dir = workspace_root.join(".temp/pre-release");
-
-    // 2. Clean `.temp/pre-release/`
-    println_cargo_style!("Clean: .temp/pre-release/");
-    let _ = std::fs::remove_dir_all(&pre_release_dir);
-    std::fs::create_dir_all(&pre_release_dir).expect("failed to create .temp/pre-release/");
-
-    // 3. Run `cargo metadata` to get workspace members info
-    println_cargo_style!("Metadata: querying workspace members");
-    let metadata_json = run_cmd_capture_with_dir(
-        "cargo metadata --format-version 1 --no-deps",
-        &workspace_root,
-    )
-    .unwrap_or_else(|(code, _msg)| {
-        eprintln_cargo_style!(format!("cargo metadata failed (exit {code}):\n{{msg}}"));
-        std::process::exit(1);
-    });
-
-    let metadata: Metadata = serde_json::from_str(&metadata_json).unwrap_or_else(|e| {
-        eprintln_cargo_style!("failed to parse cargo metadata: {}", e);
-        std::process::exit(1);
-    });
-
-    // Filter workspace members: skip the root virtual manifest
-    let workspace_root_str = workspace_root.to_string_lossy().replace('\\', "/");
-    let members: Vec<&MetadataPackage> = metadata
-        .packages
-        .iter()
-        .filter(|p| {
-            let mp = p.manifest_path.replace('\\', "/");
-            mp.starts_with(&workspace_root_str)
-                && mp != format!("{}/Cargo.toml", workspace_root_str)
-        })
-        .collect();
-
-    if members.is_empty() {
-        eprintln_cargo_style!("No workspace members found!");
-        std::process::exit(1);
-    }
-
-    // Print member info
-    for m in &members {
-        println_cargo_style!("Member: {}@{}", m.name, m.version);
-    }
-
-    // Build version map: crate_name -> version
-    let mut version_map: HashMap = HashMap::new();
-    for m in &members {
-        version_map.insert(m.name.clone(), m.version.clone());
-    }
-
-    // Collect unique member directories that need to be copied
-    let mut member_dirs: Vec = Vec::new();
-    for m in &members {
-        let dir = Path::new(&m.manifest_path)
-            .parent()
-            .expect("manifest_path has no parent");
-        let canonical_dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
-        let canonical_root =
-            std::fs::canonicalize(&workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
-        let relative = canonical_dir
-            .strip_prefix(&canonical_root)
-            .map(|p| p.to_path_buf())
-            .unwrap_or_else(|_| PathBuf::from(dir.file_name().unwrap_or_default()));
-        if !member_dirs.contains(&relative) {
-            member_dirs.push(relative);
-        }
-    }
-
-    // 4. Copy files to the temp directory, preserving the workspace directory structure
-    println_cargo_style!("Copy: project structure to .temp/pre-release/");
-
-    copy_dir(
-        &workspace_root.join(".cargo"),
-        &pre_release_dir.join(".cargo"),
-    );
-
-    for dir in &member_dirs {
-        let src = workspace_root.join(dir);
-        let dst = pre_release_dir.join(dir);
-        copy_dir(&src, &dst);
-    }
-
-    copy_file(
-        &workspace_root.join("Cargo.toml"),
-        &pre_release_dir.join("Cargo.toml"),
-    );
-    copy_file(
-        &workspace_root.join("Cargo.lock"),
-        &pre_release_dir.join("Cargo.lock"),
-    );
-
-    // 5. Fully resolve ALL workspace inheritance in every member's Cargo.toml,
-    //    so each crate becomes monomorphic (no `workspace = true` references).
-    //    Then strip `[workspace.dependencies]` and `[workspace.package]` from the
-    //    root Cargo.toml, since they are no longer needed.
-    println_cargo_style!("Resolve: inline all workspace inheritance");
-
-    // Parse workspace config from the COPIED root Cargo.toml
-    let root_cargo_path = pre_release_dir.join("Cargo.toml");
-    let root_content = std::fs::read_to_string(&root_cargo_path)
-        .unwrap_or_else(|e| panic!("failed to read {}: {e}", root_cargo_path.display()));
-
-    let (ws_package, ws_deps) = parse_workspace_config(&root_content);
-
-    // Resolve each member's Cargo.toml
-    for dir in &member_dirs {
-        let member_cargo = pre_release_dir.join(dir).join("Cargo.toml");
-        if !member_cargo.exists() {
-            continue;
-        }
-        let member_content = std::fs::read_to_string(&member_cargo)
-            .unwrap_or_else(|e| panic!("failed to read {}: {e}", member_cargo.display()));
-        let resolved =
-            resolve_member_manifest(&member_content, dir, &ws_package, &ws_deps, &version_map);
-        std::fs::write(&member_cargo, &resolved)
-            .unwrap_or_else(|e| panic!("failed to write {}: {e}", member_cargo.display()));
-    }
-
-    // Strip [workspace.dependencies], [workspace.package], and root [package]
-    // from root Cargo.toml, making it a pure virtual manifest.
-    let stripped = strip_workspace_config(&root_content);
-    std::fs::write(&root_cargo_path, &stripped)
-        .unwrap_or_else(|e| panic!("failed to write {}: {e}", root_cargo_path.display()));
-
-    println_cargo_style!("Package: running cargo package --workspace --no-verify");
-
-    // 6. Run cargo package in the temp directory
-    let package_ok = run_cmd_capture_with_dir(
-        "cargo package --workspace --no-verify --color always",
-        &pre_release_dir,
-    );
-
-    match &package_ok {
-        Ok(out) => {
-            println!("{out}");
-        }
-        Err((code, msg)) => {
-            // Print output but don't fail yet
-            eprintln_cargo_style!(format!("cargo package exited with code {code}:"));
-            println!("{msg}");
-        }
-    }
-
-    // 7. Copy built packages back to .temp/target/package
-    let temp_package_dir = workspace_root.join(".temp/target/package");
-    std::fs::create_dir_all(&temp_package_dir)
-        .unwrap_or_else(|e| panic!("failed to create {}: {e}", temp_package_dir.display()));
-
-    // cargo package puts .crate files in target/package
-    let pre_release_target_package = pre_release_dir.join(".temp/target/package");
-    if pre_release_target_package.exists() {
-        println_cargo_style!("Copy: packages to .temp/target/package");
-        copy_dir_contents(&pre_release_target_package, &temp_package_dir);
-    } else {
-        wprintln_cargo_style!("No packages found in .temp/pre-release/.temp/target/package");
-    }
-
-    // 8. Export each crate as a standalone project from the .crate packages.
-    //    The .crate files contain the final publish-ready Cargo.toml with all
-    //    workspace/path deps already resolved by `cargo package`.
-    let release_dir = workspace_root.join(".temp/release");
-    println_cargo_style!("Export: standalone crates to .temp/release/");
-    let _ = std::fs::remove_dir_all(&release_dir);
-    std::fs::create_dir_all(&release_dir)
-        .unwrap_or_else(|e| panic!("failed to create {}: {e}", release_dir.display()));
-
-    for entry in std::fs::read_dir(&temp_package_dir).expect("failed to read target/package") {
-        let entry = entry.expect("failed to read entry");
-        let path = entry.path();
-        if path.extension().is_none_or(|e| e != "crate") {
-            continue;
-        }
-
-        // .crate files are gzipped tarballs. Extract to .temp/release//
-        // fname is like "mingling-0.3.0"
-        let fname = path.file_stem().unwrap().to_string_lossy().to_string();
-
-        // Derive crate directory name by stripping the version suffix
-        // mingling-0.3.0 -> mingling, arg-picker-0.1.0 -> arg-picker
-        let crate_dir_name = fname
-            .rfind('-')
-            .and_then(|dash| {
-                // Check if what follows looks like a semver
-                let ver_part = &fname[dash + 1..];
-                if ver_part.chars().next().is_some_and(|c| c.is_ascii_digit()) {
-                    Some(&fname[..dash])
-                } else {
-                    None
-                }
-            })
-            .unwrap_or(&fname)
-            .to_string();
-
-        let target_dir = release_dir.join(&crate_dir_name);
-        std::fs::create_dir_all(&target_dir)
-            .unwrap_or_else(|e| panic!("failed to create {}: {e}", target_dir.display()));
-
-        // Extract using flate2 + tar (cross-platform)
-        let file = match std::fs::File::open(&path) {
-            Ok(f) => f,
-            Err(e) => {
-                eprintln_cargo_style!("Failed to open {}: {e}", path.display());
-                continue;
-            }
-        };
-        let decoder = GzDecoder::new(file);
-        let mut archive = Archive::new(decoder);
-        if let Err(e) = archive.unpack(&target_dir) {
-            eprintln_cargo_style!("Failed to extract {}: {e}", fname);
-            continue;
-        }
-
-        // Move the contents from the inner dir up one level
-        // .crate contains a single top-level dir named after the package
-        let inner = target_dir.join(&fname);
-        if inner.exists() {
-            for inner_entry in std::fs::read_dir(&inner).expect("failed to read inner dir") {
-                let inner_entry = inner_entry.expect("failed to read entry");
-                let inner_path = inner_entry.path();
-                let dest = target_dir.join(inner_path.file_name().unwrap());
-                if dest.exists() {
-                    let _ = std::fs::remove_dir_all(&dest);
-                }
-                std::fs::rename(&inner_path, &dest).unwrap_or_else(|e| {
-                    panic!(
-                        "failed to rename {} -> {}: {e}",
-                        inner_path.display(),
-                        dest.display()
-                    )
-                });
-            }
-            let _ = std::fs::remove_dir_all(&inner);
-        }
-
-        // Clean up: remove .orig file (only the normalized Cargo.toml is needed)
-        let _ = std::fs::remove_file(target_dir.join("Cargo.toml.orig"));
-        // Also remove Cargo.lock — standalone crate doesn't need it for publish
-        let _ = std::fs::remove_file(target_dir.join("Cargo.lock"));
-
-        // Append an empty [workspace] section so each crate is a valid workspace root
-        let cargo_toml_path = target_dir.join("Cargo.toml");
-        if cargo_toml_path.exists() {
-            let mut cargo_content = std::fs::read_to_string(&cargo_toml_path)
-                .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display()));
-            // Only append if there isn't already a [workspace] section
-            if !cargo_content.contains("\n[workspace]\n")
-                && !cargo_content.ends_with("\n[workspace]\n")
-            {
-                cargo_content.push_str("\n[workspace]\n");
-                std::fs::write(&cargo_toml_path, &cargo_content).unwrap_or_else(|e| {
-                    panic!("failed to write {}: {e}", cargo_toml_path.display())
-                });
-            }
-        }
-
-        println_cargo_style!("Export: {}", crate_dir_name);
-    }
-
-    println_cargo_style!("Done: .temp/release/ is ready");
-
-    // If package failed, report it
-    if package_ok.is_err() {
-        eprintln_cargo_style!("cargo package reported errors above");
-        std::process::exit(1);
-    }
-}
-
-/// Parse `[workspace.package]` and `[workspace.dependencies]` from the root Cargo.toml.
-/// Returns (package_fields, dep_values).
-fn parse_workspace_config(
-    content: &str,
-) -> (HashMap, HashMap) {
-    let table: TomlTable = content.parse().expect("failed to parse root Cargo.toml");
-
-    let mut package_fields = HashMap::new();
-    if let Some(workspace) = table.get("workspace").and_then(|w| w.as_table())
-        && let Some(pkg_table) = workspace.get("package").and_then(|p| p.as_table())
-    {
-        for (k, v) in pkg_table {
-            if let Some(s) = v.as_str() {
-                package_fields.insert(k.clone(), s.to_string());
-            }
-        }
-    }
-
-    let mut dep_values = HashMap::new();
-    if let Some(workspace) = table.get("workspace").and_then(|w| w.as_table())
-        && let Some(deps_table) = workspace.get("dependencies").and_then(|d| d.as_table())
-    {
-        for (k, v) in deps_table {
-            dep_values.insert(k.clone(), v.clone());
-        }
-    }
-
-    (package_fields, dep_values)
-}
-
-/// Serialize a `toml::Value` into Cargo-toml-compatible inline representation.
-fn toml_value_str(v: &toml::Value) -> String {
-    match v {
-        toml::Value::String(s) => format!("\"{}\"", s),
-        toml::Value::Table(t) => {
-            let items: Vec = t
-                .iter()
-                .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
-                .collect();
-            format!("{{ {} }}", items.join(", "))
-        }
-        toml::Value::Array(a) => {
-            let items: Vec = a.iter().map(toml_value_str).collect();
-            format!("[{}]", items.join(", "))
-        }
-        toml::Value::Boolean(b) => b.to_string(),
-        toml::Value::Integer(i) => i.to_string(),
-        toml::Value::Float(f) => f.to_string(),
-        toml::Value::Datetime(dt) => format!("\"{}\"", dt),
-    }
-}
-
-/// Compute the relative path from `member_rel_dir` to `target_path`.
-/// Both are relative to workspace root.
-/// e.g. member_rel_dir="mingling", target_path="mingling_core" → "../mingling_core"
-fn make_path_relative_to_member(target_path: &str, member_rel_dir: &Path) -> String {
-    if member_rel_dir.as_os_str().is_empty() || member_rel_dir == Path::new(".") {
-        return target_path.to_string();
-    }
-    let depth = member_rel_dir.components().count();
-    let mut result = PathBuf::new();
-    for _ in 0..depth {
-        result.push("..");
-    }
-    result.push(target_path);
-    result.to_string_lossy().to_string()
-}
-
-/// Resolve a dependency definition from `[workspace.dependencies]` to an inline string.
-/// If the definition contains a path to a workspace member, add `version = "..."`
-/// and adjust the path to be relative to the member's directory.
-fn resolve_dep_def(
-    dep_name: &str,
-    dep_def: &toml::Value,
-    member_rel_dir: &Path,
-    version_map: &HashMap,
-) -> String {
-    match dep_def {
-        toml::Value::String(ver) => {
-            format!("\"{}\"", ver)
-        }
-        toml::Value::Table(t) => {
-            let mut resolved = t.clone();
-            let has_path = t.contains_key("path");
-            let is_ws_member = version_map.contains_key(dep_name);
-
-            // Fix path to be relative to member's directory
-            if has_path && let Some(path_val) = t.get("path").and_then(|v| v.as_str()) {
-                let rel = make_path_relative_to_member(path_val, member_rel_dir);
-                resolved.insert("path".to_string(), toml::Value::String(rel));
-            }
-
-            // Add version for workspace member path deps
-            if has_path
-                && is_ws_member
-                && let Some(version) = version_map.get(dep_name)
-            {
-                resolved.insert("version".to_string(), toml::Value::String(version.clone()));
-            }
-
-            let items: Vec = resolved
-                .iter()
-                .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
-                .collect();
-            format!("{{ {} }}", items.join(", "))
-        }
-        _ => toml_value_str(dep_def),
-    }
-}
-
-/// Merge an inline `{ workspace = true, optional = true, ... }` with the workspace definition.
-/// Returns the full resolved dependency value string (without the leading `dep_name = `).
-fn merge_inline_dep(
-    inline_rest: &str,
-    dep_name: &str,
-    dep_def: &toml::Value,
-    member_rel_dir: &Path,
-    version_map: &HashMap,
-) -> String {
-    // inline_rest is the part after `=`: `{ workspace = true, optional = true }`
-    let inner = inline_rest
-        .trim()
-        .strip_prefix('{')
-        .and_then(|s| s.strip_suffix('}'))
-        .unwrap_or("");
-
-    match dep_def {
-        toml::Value::String(ver) => {
-            // Workspace def is just a version string
-            // Collect extras: everything except `workspace = true`
-            let extras: Vec<&str> = inner
-                .split(',')
-                .map(|s| s.trim())
-                .filter(|s| !s.is_empty() && *s != "workspace = true")
-                .collect();
-
-            if extras.is_empty() {
-                format!("\"{}\"", ver)
-            } else {
-                // Serialize as inline table: version + extras
-                let mut parts = vec![format!("version = \"{}\"", ver)];
-                parts.extend(extras.iter().map(|s| s.to_string()));
-                format!("{{ {} }}", parts.join(", "))
-            }
-        }
-        toml::Value::Table(t) => {
-            // Start from workspace def
-            let mut merged = t.clone();
-
-            // Fix path to be relative to member's directory
-            if let Some(path_val) = t.get("path").and_then(|v| v.as_str()) {
-                let rel = make_path_relative_to_member(path_val, member_rel_dir);
-                merged.insert("path".to_string(), toml::Value::String(rel));
-            }
-
-            // If this dep is a workspace member with a path dep, add version
-            if t.contains_key("path")
-                && version_map.contains_key(dep_name)
-                && let Some(version) = version_map.get(dep_name)
-            {
-                merged.insert("version".to_string(), toml::Value::String(version.clone()));
-            }
-
-            // Apply extra fields from the inline
-            for piece in inner.split(',').map(|s| s.trim()) {
-                let piece = piece.trim();
-                if piece.is_empty() || piece == "workspace = true" {
-                    continue;
-                }
-                // Parse `key = value` pairs
-                if let Some((raw_key, raw_val)) = piece.split_once('=') {
-                    let k = raw_key.trim();
-                    let v = raw_val.trim();
-                    if k.is_empty() {
-                        continue;
-                    }
-                    // Try to infer the value type
-                    if v == "true" {
-                        merged.insert(k.to_string(), toml::Value::Boolean(true));
-                    } else if v == "false" {
-                        merged.insert(k.to_string(), toml::Value::Boolean(false));
-                    } else if v.starts_with('"') && v.ends_with('"') {
-                        merged.insert(
-                            k.to_string(),
-                            toml::Value::String(v[1..v.len() - 1].to_string()),
-                        );
-                    } else if v.starts_with('[') && v.ends_with(']') {
-                        // Simple array parsing: strings only
-                        let arr: Vec = v[1..v.len() - 1]
-                            .split(',')
-                            .map(|s| {
-                                let s = s.trim().trim_matches('"');
-                                toml::Value::String(s.to_string())
-                            })
-                            .collect();
-                        merged.insert(k.to_string(), toml::Value::Array(arr));
-                    } else if let Ok(n) = v.parse::() {
-                        merged.insert(k.to_string(), toml::Value::Integer(n));
-                    } else if let Ok(f) = v.parse::() {
-                        merged.insert(k.to_string(), toml::Value::Float(f));
-                    } else {
-                        // Treat as string
-                        merged.insert(k.to_string(), toml::Value::String(v.to_string()));
-                    }
-                }
-            }
-
-            let items: Vec = merged
-                .iter()
-                .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
-                .collect();
-            format!("{{ {} }}", items.join(", "))
-        }
-        _ => toml_value_str(dep_def),
-    }
-}
-
-/// Resolve ALL workspace inheritance in a single member crate's Cargo.toml:
-///   - `version.workspace = true` → `version = "0.3.0"`
-///   - `dep.workspace = true` → inline the full definition from ws_deps
-///   - `dep = { workspace = true, ... }` → merge with ws_deps definition
-fn resolve_member_manifest(
-    content: &str,
-    member_rel_dir: &Path,
-    ws_package: &HashMap,
-    ws_deps: &HashMap,
-    version_map: &HashMap,
-) -> String {
-    let mut result = String::new();
-    let mut in_package = false;
-    let mut in_section_with_deps = false;
-
-    for line in content.lines() {
-        let trimmed = line.trim();
-        let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
-
-        // Track sections
-        if trimmed.starts_with('[') {
-            in_package = trimmed == "[package]";
-            in_section_with_deps = trimmed.starts_with("[dependencies")
-                || trimmed.starts_with("[build-dependencies")
-                || trimmed.starts_with("[dev-dependencies");
-            result.push_str(line);
-            result.push('\n');
-            continue;
-        }
-
-        // [package] section: resolve `field.workspace = true`
-        if in_package && trimmed.ends_with(".workspace = true") {
-            let key = trimmed.strip_suffix(".workspace = true").unwrap().trim();
-            if let Some(value) = ws_package.get(key) {
-                result.push_str(&format!("{indent}{key} = \"{value}\"\n"));
-                continue;
-            }
-            // Also check workspace.dependencies (for fields like `version.workspace`)
-            // when the member has its own version field inherited from workspace.package
-        }
-
-        // Dependency sections
-        if in_section_with_deps {
-            // Shorthand: `foo.workspace = true`
-            if trimmed.ends_with(".workspace = true") {
-                let key = trimmed.strip_suffix(".workspace = true").unwrap().trim();
-                if let Some(dep_def) = ws_deps.get(key) {
-                    let resolved = resolve_dep_def(key, dep_def, member_rel_dir, version_map);
-                    result.push_str(&format!("{indent}{key} = {resolved}\n"));
-                    continue;
-                }
-            }
-
-            // Inline: `foo = { workspace = true, optional = true, ... }`
-            if let Some(eq_pos) = trimmed.find("= {")
-                && trimmed.contains("workspace = true")
-            {
-                let dep_name = trimmed[..eq_pos].trim();
-                if let Some(dep_def) = ws_deps.get(dep_name) {
-                    let after_eq = trimmed[eq_pos + 1..].trim();
-                    let merged =
-                        merge_inline_dep(after_eq, dep_name, dep_def, member_rel_dir, version_map);
-                    result.push_str(&format!("{indent}{dep_name} = {merged}\n"));
-                    continue;
-                }
-            }
-        }
-
-        result.push_str(line);
-        result.push('\n');
-    }
-
-    result
-}
-
-/// Remove `[workspace.dependencies]`, `[workspace.package]`, and the root `[package]`
-/// section from root Cargo.toml, making it a pure virtual manifest.
-/// Keeps `[workspace]` with `members`, `resolver`, `exclude` so packaging still works.
-fn strip_workspace_config(content: &str) -> String {
-    let mut result = String::new();
-    let mut in_ws_deps = false;
-    let mut in_ws_package = false;
-    let mut in_root_package = false;
-
-    for line in content.lines() {
-        let trimmed = line.trim();
-
-        if trimmed == "[workspace.dependencies]" {
-            in_ws_deps = true;
-            continue;
-        }
-        if trimmed == "[workspace.package]" {
-            in_ws_package = true;
-            continue;
-        }
-        if trimmed == "[package]" && !in_ws_deps && !in_ws_package {
-            // Remove the root [package] section entirely (virtual manifest)
-            in_root_package = true;
-            continue;
-        }
-
-        if in_ws_deps {
-            if trimmed.starts_with('[') {
-                in_ws_deps = false;
-            } else {
-                continue;
-            }
-        }
-
-        if in_ws_package {
-            if trimmed.starts_with('[') {
-                in_ws_package = false;
-            } else {
-                continue;
-            }
-        }
-
-        if in_root_package {
-            if trimmed.starts_with('[') {
-                in_root_package = false;
-            } else {
-                continue;
-            }
-        }
-
-        result.push_str(line);
-        result.push('\n');
-    }
-
-    result
-}
-
-/// Recursively copy a directory.
-fn copy_dir(src: &Path, dst: &Path) {
-    copy_dir_filtered(src, dst, &|_: &Path| true)
-}
-
-/// Recursively copy a directory with a filter function.
-/// The filter receives the source path and returns `true` if the entry should be copied.
-fn copy_dir_filtered(src: &Path, dst: &Path, filter: &dyn Fn(&Path) -> bool) {
-    if !src.exists() {
-        return;
-    }
-    if !filter(src) {
-        return;
-    }
-    std::fs::create_dir_all(dst)
-        .unwrap_or_else(|e| panic!("failed to create {}: {e}", dst.display()));
-
-    for entry in std::fs::read_dir(src).expect("failed to read directory") {
-        let entry = entry.expect("failed to read entry");
-        let entry_type = entry.file_type().expect("failed to get file type");
-        let src_path = entry.path();
-        let dst_path = dst.join(entry.file_name());
-
-        if !filter(&src_path) {
-            continue;
-        }
-
-        if entry_type.is_dir() {
-            copy_dir_filtered(&src_path, &dst_path, filter);
-        } else if entry_type.is_file() || entry_type.is_symlink() {
-            copy_file(&src_path, &dst_path);
-        }
-    }
-}
-
-/// Copy a file, creating parent directories as needed.
-/// If src is a symlink, copies the target content (follow symlinks).
-fn copy_file(src: &Path, dst: &Path) {
-    if let Some(parent) = dst.parent() {
-        std::fs::create_dir_all(parent)
-            .unwrap_or_else(|e| panic!("failed to create {}: {e}", parent.display()));
-    }
-
-    let resolved = if src.is_symlink() {
-        let target = std::fs::read_link(src)
-            .unwrap_or_else(|e| panic!("failed to read symlink {}: {e}", src.display()));
-        if target.is_relative() {
-            src.parent().unwrap().join(target)
-        } else {
-            target
-        }
-    } else {
-        src.to_path_buf()
-    };
-
-    std::fs::copy(&resolved, dst).unwrap_or_else(|e| {
-        panic!(
-            "failed to copy {} -> {}: {e}",
-            resolved.display(),
-            dst.display()
-        )
-    });
-}
-
-/// Copy all files/directories from one directory into another.
-fn copy_dir_contents(src: &Path, dst: &Path) {
-    if !src.exists() {
-        return;
-    }
-    std::fs::create_dir_all(dst)
-        .unwrap_or_else(|e| panic!("failed to create {}: {e}", dst.display()));
-
-    for entry in std::fs::read_dir(src).expect("failed to read directory") {
-        let entry = entry.expect("failed to read entry");
-        let entry_type = entry.file_type().expect("failed to get file type");
-        let src_path = entry.path();
-        let dst_path = dst.join(entry.file_name());
-
-        if entry_type.is_dir() {
-            copy_dir(&src_path, &dst_path);
-        } else if entry_type.is_file() || entry_type.is_symlink() {
-            copy_file(&src_path, &dst_path);
-        }
-    }
-}
diff --git a/.run/src/bin/update-version.rs b/.run/src/bin/update-version.rs
deleted file mode 100644
index 2283662..0000000
--- a/.run/src/bin/update-version.rs
+++ /dev/null
@@ -1,104 +0,0 @@
-use std::io::Write as _;
-use std::path::Path;
-
-use serde::Deserialize;
-use tools::println_cargo_style;
-
-#[derive(Deserialize)]
-struct VersionFile {
-    file: String,
-    pattern: String,
-}
-
-#[derive(Deserialize)]
-struct Config {
-    #[serde(rename = "file")]
-    files: Vec,
-}
-
-fn main() {
-    let args: Vec = std::env::args().collect();
-
-    // Get new version
-    let new_ver = if args.len() > 1 {
-        args[1].clone()
-    } else {
-        print!("Update version to: ");
-        std::io::stdout().flush().unwrap();
-        let mut input = String::new();
-        std::io::stdin().read_line(&mut input).unwrap();
-        input.trim().to_string()
-    };
-
-    if new_ver.is_empty() {
-        eprintln!("Error: Version cannot be empty.");
-        std::process::exit(1);
-    }
-
-    // Read current version from root Cargo.toml's workspace.package.version
-    let root_cargo_path = "Cargo.toml";
-    let root_cargo_content =
-        std::fs::read_to_string(root_cargo_path).expect("Failed to read Cargo.toml");
-    let cargo_value: toml::Value = root_cargo_content
-        .parse()
-        .expect("Failed to parse Cargo.toml");
-
-    let current_ver = cargo_value["workspace"]["package"]["version"]
-        .as_str()
-        .expect("workspace.package.version not found in Cargo.toml")
-        .to_string();
-
-    if new_ver == current_ver {
-        println!("Version is already {}. Nothing to do.", current_ver);
-        return;
-    }
-
-    println_cargo_style!("Version: {} -> {}", current_ver, new_ver);
-
-    // Read version-files.toml
-    let config_path = Path::new(".config").join("version-files.toml");
-    let config_str =
-        std::fs::read_to_string(&config_path).expect("Failed to read .config/version-files.toml");
-    let config: Config =
-        toml::from_str(&config_str).expect("Failed to parse .config/version-files.toml");
-
-    let mut updated_count = 0;
-    let mut skipped_count = 0;
-
-    for vf in &config.files {
-        let file_path = &vf.file;
-        let old_pattern = vf.pattern.replace("{VER}", ¤t_ver);
-        let new_pattern = vf.pattern.replace("{VER}", &new_ver);
-
-        let content = match std::fs::read_to_string(file_path) {
-            Ok(c) => c,
-            Err(e) => {
-                eprintln!("Warning: Could not read {}: {}", file_path, e);
-                skipped_count += 1;
-                continue;
-            }
-        };
-
-        let new_content = content.replace(&old_pattern, &new_pattern);
-
-        if new_content == content {
-            eprintln!(
-                "Warning: Pattern '{}' not found in {}",
-                old_pattern, file_path
-            );
-            skipped_count += 1;
-            continue;
-        }
-
-        std::fs::write(file_path, &new_content)
-            .unwrap_or_else(|e| panic!("Failed to write {}: {}", file_path, e));
-        println_cargo_style!("Updated: {}", file_path);
-        updated_count += 1;
-    }
-
-    println_cargo_style!(
-        "Done: {} file(s) updated, {} file(s) skipped",
-        updated_count,
-        skipped_count
-    );
-}
diff --git a/.run/src/bin/windows-folder-hide.ps1 b/.run/src/bin/windows-folder-hide.ps1
deleted file mode 100644
index ff53202..0000000
--- a/.run/src/bin/windows-folder-hide.ps1
+++ /dev/null
@@ -1,115 +0,0 @@
-$skipDirs = @('.git', '.temp', 'target', 'node_modules', '.pnpm')
-$selfPath = (Get-Item -LiteralPath $MyInvocation.MyCommand.Path).Directory.FullName
-
-function Test-InSkipDir {
-    param(
-        [object]$Item
-    )
-    $path = if ($Item -is [string]) {
-        $Item
-    } elseif ($Item.PSPath) {
-        $Item.PSPath -replace '^.*::', ''
-    } else {
-        $Item.FullName
-    }
-
-    $parts = $path.Split([System.IO.Path]::DirectorySeparatorChar)
-    for ($i = 0; $i -lt $parts.Length - 1; $i++) {
-        if ($parts[$i] -in $skipDirs) {
-            return $true
-        }
-    }
-    return $false
-}
-
-function Invoke-UnhideRecursive {
-    param([string]$Path)
-    Get-ChildItem -LiteralPath $Path -Force | ForEach-Object {
-        if ($_.PSIsContainer) {
-            if ($_.Name -in $skipDirs) {
-                if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) {
-                    Write-Host "    -> unhiding skip directory (self only): `"$($_.FullName)`""
-                    $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden
-                }
-                return
-            }
-            Invoke-UnhideRecursive $_.FullName
-        } else {
-            if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) {
-                Write-Host "    -> unhiding: `"$($_.FullName)`""
-                $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden
-            }
-        }
-    }
-}
-
-function Test-GitPathSkippable {
-    param([string]$GitPath)
-    $parts = $GitPath.Split(@('/', '\'))
-    for ($i = 0; $i -lt $parts.Length - 1; $i++) {
-        if ($parts[$i] -in $skipDirs) {
-            return $true
-        }
-    }
-    return $false
-}
-
-Write-Host "Step 1: Unhiding all files and directories (skipping $($skipDirs -join ', '))..."
-
-Invoke-UnhideRecursive -Path (Get-Location).Path
-
-Write-Host "Step 2: Hiding git-ignored items..."
-
-git ls-files --others --ignored --exclude-standard | Where-Object {
-    -not (Test-GitPathSkippable $_)
-} | ForEach-Object {
-    $itemPath = $_
-    Write-Host "... checking: `"$itemPath`""
-    $item = Get-Item $_ -Force -ErrorAction SilentlyContinue
-    if (-not $item) { return }
-
-    if ($item.FullName -eq $selfPath) { return }
-
-    if (Test-InSkipDir $item) {
-        Write-Host "    -> skipping (inside skip directory)"
-        return
-    }
-
-    if ($item.PSIsContainer) {
-        if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) {
-            Write-Host "    -> hiding directory (non-recursive)"
-            $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden
-        }
-    } else {
-        if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) {
-            Write-Host "    -> hiding"
-            $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden
-        }
-    }
-}
-
-Write-Host "Step 3: Hiding dot-prefixed items..."
-Get-ChildItem -Path . -Force -Directory | Where-Object { $_.Name -match '^\.' } | ForEach-Object {
-    Write-Host "... checking: `"$($_.FullName)`""
-    if (Test-InSkipDir $_) {
-        Write-Host "    -> skipping (inside skip directory)"
-        return
-    }
-    if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) {
-        Write-Host "    -> hiding directory"
-        $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden
-    }
-}
-
-Get-ChildItem -Path . -Force -File | Where-Object { $_.Name -match '^\.' } | ForEach-Object {
-    if ($_.FullName -eq $selfPath) { return }
-    Write-Host "... checking: `"$($_.FullName)`""
-    if (Test-InSkipDir $_) {
-        Write-Host "    -> skipping (inside skip directory)"
-        return
-    }
-    if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) {
-        Write-Host "    -> hiding file"
-        $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden
-    }
-}
diff --git a/.run/src/dependency_order.rs b/.run/src/dependency_order.rs
deleted file mode 100644
index 145bdbd..0000000
--- a/.run/src/dependency_order.rs
+++ /dev/null
@@ -1,196 +0,0 @@
-use std::collections::{HashMap, HashSet};
-use std::path::{Path, PathBuf};
-
-/// Parse Cargo.toml content and return dependency names that start with `prefix`.
-fn parse_mingling_deps(content: &str, prefix: &str) -> Vec {
-    let value: toml::Value = match content.parse() {
-        Ok(v) => v,
-        Err(_) => return Vec::new(),
-    };
-
-    let mut names = Vec::new();
-
-    // Check [dependencies]
-    if let Some(deps) = value.get("dependencies").and_then(|d| d.as_table()) {
-        for key in deps.keys() {
-            if key.starts_with(prefix) {
-                names.push(key.clone());
-            }
-        }
-    }
-
-    // Check [build-dependencies]
-    if let Some(deps) = value.get("build-dependencies").and_then(|d| d.as_table()) {
-        for key in deps.keys() {
-            if key.starts_with(prefix) {
-                names.push(key.clone());
-            }
-        }
-    }
-
-    names
-}
-
-/// Read workspace members from the root Cargo.toml.
-fn get_workspace_members(workspace_root: &std::path::Path) -> Vec {
-    let cargo_path = workspace_root.join("Cargo.toml");
-    let content = match std::fs::read_to_string(&cargo_path) {
-        Ok(c) => c,
-        Err(_) => return Vec::new(),
-    };
-
-    let value: toml::Value = match content.parse() {
-        Ok(v) => v,
-        Err(_) => return Vec::new(),
-    };
-
-    value
-        .get("workspace")
-        .and_then(|w| w.get("members"))
-        .and_then(|m| m.as_array())
-        .map(|arr| {
-            arr.iter()
-                .filter_map(|v| v.as_str().map(String::from))
-                .collect()
-        })
-        .unwrap_or_default()
-}
-
-/// Hierarchical topological sort (process layer by layer, sort siblings alphabetically).
-///
-/// `dep_map` maps each crate to the list of crates it depends on.
-/// Returns the dependency order (dependent crates come first, dependents come later),
-/// with crates at the same layer (which can be built in parallel) sorted alphabetically.
-fn topological_sort(
-    all_crates: &HashSet,
-    dep_map: &HashMap>,
-) -> Vec {
-    // in_degree[crate] = number of remaining mingling_* dependencies not yet processed
-    let mut in_degree: HashMap<&str, usize> = HashMap::new();
-    // reverse[dependency] = list of crates that depend on it
-    let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
-
-    for name in all_crates {
-        in_degree.entry(name.as_str()).or_insert(0);
-        reverse.entry(name.as_str()).or_default();
-    }
-
-    for (crate_name, deps) in dep_map {
-        for dep in deps {
-            if all_crates.contains(dep.as_str()) {
-                reverse
-                    .get_mut(dep.as_str())
-                    .unwrap()
-                    .push(crate_name.as_str());
-                *in_degree.get_mut(crate_name.as_str()).unwrap() += 1;
-            }
-        }
-    }
-
-    let mut result: Vec = Vec::new();
-
-    // Process layer by layer: all crates with in_degree == 0 in one batch form a layer
-    loop {
-        let mut current: Vec<&str> = all_crates
-            .iter()
-            .filter(|n| in_degree.get(n.as_str()).copied().unwrap_or(0) == 0)
-            .filter(|n| !result.iter().any(|r| r.as_str() == n.as_str()))
-            .map(|s| s.as_str())
-            .collect();
-
-        if current.is_empty() {
-            break;
-        }
-
-        current.sort();
-        result.extend(current.iter().map(|s| s.to_string()));
-
-        for &node in ¤t {
-            if let Some(dependents) = reverse.get(node) {
-                for &dependent in dependents {
-                    if let Some(degree) = in_degree.get_mut(dependent) {
-                        *degree -= 1;
-                    }
-                }
-            }
-        }
-    }
-
-    result
-}
-
-/// Strip the `\\?\` prefix that `std::fs::canonicalize` may add on Windows.
-fn strip_verbatim_prefix(p: &Path) -> PathBuf {
-    let s = p.to_string_lossy();
-    let s_ref: &str = &s;
-    if let Some(rest) = s_ref.strip_prefix("\\\\?\\") {
-        PathBuf::from(rest)
-    } else {
-        p.to_path_buf()
-    }
-}
-
-/// Find the workspace root by looking for a Cargo.toml with `[workspace]` members.
-/// Starts from `start` and walks up the directory tree.
-pub fn find_workspace_root(start: &std::path::Path) -> Option {
-    let mut current = Some(strip_verbatim_prefix(
-        &std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf()),
-    ));
-    while let Some(dir) = current {
-        let members = get_workspace_members(&dir);
-        if !members.is_empty() {
-            return Some(dir);
-        }
-        current = dir.parent().map(|p| p.to_path_buf());
-    }
-    None
-}
-
-/// Output all crate paths in dependency order
-#[allow(unused)]
-pub fn display_dependency_order() -> Vec {
-    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
-
-    let workspace_root = match find_workspace_root(&cwd) {
-        Some(root) => root,
-        None => return Vec::new(),
-    };
-
-    // Read workspace members from root Cargo.toml
-    let members = get_workspace_members(&workspace_root);
-
-    // Filter to crates starting with "mingling" or "arg"
-    let mingling_crates: HashSet = members
-        .into_iter()
-        .filter(|m| m.starts_with("mingling") || m.starts_with("arg"))
-        .collect();
-
-    if mingling_crates.is_empty() {
-        return Vec::new();
-    }
-
-    // Build dependency graph
-    let mut dep_map: HashMap> = HashMap::new();
-
-    for crate_name in &mingling_crates {
-        let cargo_path = workspace_root.join(crate_name).join("Cargo.toml");
-        let content = match std::fs::read_to_string(&cargo_path) {
-            Ok(c) => c,
-            Err(_) => {
-                dep_map.insert(crate_name.clone(), Vec::new());
-                continue;
-            }
-        };
-        let deps = parse_mingling_deps(&content, "mingling");
-        // Only keep deps that are actually in our set
-        let filtered: Vec = deps
-            .into_iter()
-            .filter(|d| mingling_crates.contains(d.as_str()))
-            .collect();
-        dep_map.insert(crate_name.clone(), filtered);
-    }
-
-    let sorted = topological_sort(&mingling_crates, &dep_map);
-
-    sorted.into_iter().map(PathBuf::from).collect()
-}
diff --git a/.run/src/lib.rs b/.run/src/lib.rs
deleted file mode 100644
index b17a61f..0000000
--- a/.run/src/lib.rs
+++ /dev/null
@@ -1,459 +0,0 @@
-pub mod dependency_order;
-pub mod verify;
-
-use colored::Colorize;
-
-use std::io::IsTerminal as _;
-
-#[macro_export]
-macro_rules! run_cmd {
-    ($fmt:literal, $($arg:tt)*) => {
-        $crate::run_cmd(format!($fmt, $($arg)*))
-    };
-    ($cmd:expr) => {
-        $crate::run_cmd($cmd)
-    };
-}
-
-/// Run a shell command and capture its combined stdout+stderr output.
-/// Returns `Ok(output)` on success, `Err((exit_code, stderr))` on failure.
-#[macro_export]
-macro_rules! run_cmd_and_capture_stderr {
-    ($fmt:literal, $($arg:tt)*) => {
-        $crate::run_cmd_capture(format!($fmt, $($arg)*))
-    };
-    ($cmd:expr) => {
-        $crate::run_cmd_capture($cmd)
-    };
-}
-
-#[macro_export]
-macro_rules! println_cargo_style {
-    ($fmt:literal, $($arg:tt)*) => {
-        $crate::println_cargo_style(format!($fmt, $($arg)*))
-    };
-    ($cmd:expr) => {
-        $crate::println_cargo_style($cmd)
-    };
-}
-
-#[macro_export]
-macro_rules! eprintln_cargo_style {
-    ($fmt:literal, $($arg:tt)*) => {
-        $crate::eprintln_cargo_style(format!($fmt, $($arg)*))
-    };
-    ($cmd:expr) => {
-        $crate::eprintln_cargo_style($cmd)
-    };
-}
-
-#[macro_export]
-macro_rules! wprintln_cargo_style {
-    ($fmt:literal, $($arg:tt)*) => {
-        $crate::wprintln_cargo_style(format!($fmt, $($arg)*))
-    };
-    ($cmd:expr) => {
-        $crate::wprintln_cargo_style($cmd)
-    };
-}
-
-/// Print a message in cargo style format, with bold green prefix.
-///
-/// # Panics
-///
-/// Panics if the prefix (text before the first `:`) exceeds 12 characters.
-pub fn println_cargo_style(str: impl Into) {
-    let s = str.into();
-    let (prefix, content) = if let Some(pos) = s.find(':') {
-        (
-            s[..pos].trim().to_string(),
-            s[pos + 1..].trim_start().to_string(),
-        )
-    } else {
-        (String::new(), s.trim().to_string())
-    };
-
-    assert!(
-        prefix.len() <= 12,
-        "prefix length exceeds 12: '{}' has length {}",
-        prefix,
-        prefix.len()
-    );
-
-    let padding = " ".repeat(12 - prefix.len());
-
-    println!(
-        "{}{} {}",
-        padding,
-        prefix.bold().bright_green(),
-        content.trim()
-    );
-}
-
-pub fn eprintln_cargo_style(str: impl Into) {
-    println!("{}: {}", "error".bold().bright_red(), str.into());
-}
-
-/// Print a message in cargo style format, with bold yellow prefix (warning style).
-///
-/// # Panics
-///
-/// Panics if the prefix (text before the first `:`) exceeds 12 characters.
-pub fn wprintln_cargo_style(str: impl Into) {
-    let s = str.into();
-    let (prefix, content) = if let Some(pos) = s.find(':') {
-        (
-            s[..pos].trim().to_string(),
-            s[pos + 1..].trim_start().to_string(),
-        )
-    } else {
-        (String::new(), s.trim().to_string())
-    };
-
-    assert!(
-        prefix.len() <= 12,
-        "prefix length exceeds 12: '{}' has length {}",
-        prefix,
-        prefix.len()
-    );
-
-    let padding = " ".repeat(12 - prefix.len());
-
-    println!(
-        "{}{} {}",
-        padding,
-        prefix.bold().bright_yellow(),
-        content.trim()
-    );
-}
-
-/// Run a shell command in the current directory and return its exit status.
-///
-/// # Panics
-///
-/// Panics if the shell command cannot be spawned (e.g. the shell binary is not found).
-///
-/// # Errors
-///
-/// Returns `Err` with the exit code if the command finishes with a non-zero exit code.
-pub fn run_cmd(cmd: impl Into) -> Result<(), i32> {
-    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
-    run_cmd_with_dir(cmd.into(), &cwd)
-}
-
-/// Run a shell command in the specified directory and return its exit status.
-///
-/// # Panics
-///
-/// Panics if the shell command cannot be spawned (e.g. the shell binary is not found).
-///
-/// # Errors
-///
-/// Returns `Err` with the exit code if the command finishes with a non-zero exit code.
-pub fn run_cmd_with_dir(cmd: impl Into, dir: &std::path::Path) -> Result<(), i32> {
-    let shell = if cfg!(target_os = "windows") {
-        "powershell"
-    } else {
-        "sh"
-    };
-    let status = std::process::Command::new(shell)
-        .arg("-c")
-        .arg(cmd.into())
-        .current_dir(dir)
-        .status()
-        .expect("failed to execute command");
-
-    let exit_code = status.code().unwrap_or(1);
-    if exit_code == 0 {
-        Ok(())
-    } else {
-        Err(exit_code)
-    }
-}
-
-/// Run a shell command and capture its combined stdout+stderr output.
-///
-/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`.
-/// Stderr falls back to stdout if stderr is empty.
-pub fn run_cmd_capture(cmd: impl Into) -> Result {
-    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
-    run_cmd_capture_with_dir(cmd.into(), &cwd)
-}
-
-/// Run a shell command in the specified directory and capture its combined stdout+stderr output.
-///
-/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`.
-/// Stderr falls back to stdout if stderr is empty.
-pub fn run_cmd_capture_with_dir(
-    cmd: impl Into,
-    dir: &std::path::Path,
-) -> Result {
-    let shell = if cfg!(target_os = "windows") {
-        "powershell"
-    } else {
-        "sh"
-    };
-    let output = std::process::Command::new(shell)
-        .arg("-c")
-        .arg(cmd.into())
-        .current_dir(dir)
-        .output()
-        .expect("failed to execute command");
-
-    let exit_code = output.status.code().unwrap_or(1);
-    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
-    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
-    // Keep both streams so a failure is never hidden: when stderr carries
-    // warnings, the real failure details (e.g. the failing test name and
-    // assertion diff) usually live on stdout and must not be dropped.
-    let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) {
-        (false, false) => format!("{stdout}\n{stderr}"),
-        (false, true) => stdout,
-        (true, false) => stderr,
-        (true, true) => stdout,
-    };
-
-    if exit_code == 0 {
-        Ok(combined)
-    } else {
-        Err((exit_code, combined))
-    }
-}
-
-/// Extract a crate-style name from a `Cargo.toml` path.
-///
-/// Examples:
-/// - `mingling_core/Cargo.toml` → `mingling_core`
-/// - `.` → `(root)`
-pub fn crate_name_from(path: &std::path::Path) -> String {
-    path.parent()
-        .and_then(|p| p.file_name())
-        .and_then(|n| n.to_str())
-        .unwrap_or("(root)")
-        .to_string()
-}
-
-/// Run a list of `(label_for_errors, crate_name_for_bar, shell_command)` tuples
-/// in parallel with a progress bar.
-///
-/// - Success: silent, the bar tracks progress:
-///   `  Building [============================] 32/32: mingling_core`
-/// - Failure: `pb.println()` prints the error immediately above the bar.
-pub fn run_parallel(phase: &str, tasks: Vec<(String, String, String)>) -> Result<(), i32> {
-    let n = tasks.len();
-    if n == 0 {
-        return Ok(());
-    }
-
-    // Cargo-style prefix: right-aligned to 12 chars, bold bright cyan
-    let padding = " ".repeat(12 - phase.len());
-    let styled_prefix = format!("{}{}", padding, phase.bold().bright_cyan());
-
-    let pb = indicatif::ProgressBar::new(n as u64);
-    pb.set_style(
-        indicatif::ProgressStyle::default_bar()
-            .template(&format!(
-                "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}",
-                styled_prefix
-            ))
-            .unwrap()
-            .progress_chars("=> "),
-    );
-    pb.set_position(0);
-
-    // Pre-extract labels for error messages
-    let labels: Vec = tasks.iter().map(|(l, _, _)| l.clone()).collect();
-
-    let (tx, rx) = std::sync::mpsc::channel::<(usize, String, Result)>();
-
-    for (i, (_label, crate_name, cmd)) in tasks.into_iter().enumerate() {
-        let tx = tx.clone();
-        std::thread::spawn(move || {
-            let result = run_cmd_capture(&cmd);
-            let _ = tx.send((i, crate_name, result));
-        });
-    }
-    drop(tx);
-
-    let mut first_exit_code = 0;
-
-    while let Ok((i, crate_name, result)) = rx.recv() {
-        pb.inc(1);
-        pb.set_message(crate_name);
-
-        if let Err((code, output)) = result {
-            if first_exit_code == 0 {
-                first_exit_code = code;
-            }
-            let msg = format!(
-                "{}: {} failed (exit code {})",
-                "error".bright_red().bold(),
-                labels[i],
-                code,
-            );
-            let mut lines = Vec::new();
-            if !output.is_empty() {
-                lines.extend(output.lines().map(|l| format!("  {l}")));
-            }
-            if std::io::stdout().is_terminal() {
-                // On a TTY, render errors through the progress bar so they
-                // appear above it.
-                pb.println(&msg);
-                for line in &lines {
-                    pb.println(line);
-                }
-            } else {
-                // On a non-TTY (CI, piped output), `ProgressBar::println` can
-                // be swallowed, hiding the failure. Emit to plain stdout so the
-                // failure is always visible.
-                println!("{msg}");
-                for line in &lines {
-                    println!("{line}");
-                }
-            }
-        }
-    }
-
-    pb.finish_and_clear();
-
-    if first_exit_code != 0 {
-        Err(first_exit_code)
-    } else {
-        Ok(())
-    }
-}
-
-/// Run a single shell command with a progress bar, capturing its output.
-///
-/// - Success: bar clears silently.
-/// - Failure: error is printed above the bar, then the bar clears.
-pub fn run_cmd_with_progress(phase: &str, label: &str, cmd: String) -> Result<(), i32> {
-    let padding = " ".repeat(12 - phase.len());
-    let styled_prefix = format!("{}{}", padding, phase.bold().bright_cyan());
-
-    let pb = indicatif::ProgressBar::new(1);
-    pb.set_style(
-        indicatif::ProgressStyle::default_bar()
-            .template(&format!(
-                "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}",
-                styled_prefix
-            ))
-            .unwrap()
-            .progress_chars("=> "),
-    );
-    pb.set_message(label.to_owned());
-
-    let result = run_cmd_capture(&cmd);
-    pb.inc(1);
-    pb.finish_and_clear();
-
-    match result {
-        Ok(_) => Ok(()),
-        Err((code, output)) => {
-            eprintln_cargo_style(format!("{} failed (exit code {})", label, code));
-            if !output.is_empty() {
-                println!("{}", output.trim_end());
-            }
-            Err(code)
-        }
-    }
-}
-
-/// Read `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`.
-///
-/// Finds the git repository root, reads `mingling/Cargo.toml`, parses it as TOML,
-/// and extracts the feature list under `[package.metadata.docs.rs].features`.
-///
-/// # Errors
-///
-/// Returns `std::io::Error` if:
-/// - The git repository root cannot be found.
-/// - The manifest file cannot be read.
-/// - The TOML cannot be parsed.
-/// - The `[package.metadata.docs.rs].features` key is missing or empty.
-pub fn read_features() -> Result, std::io::Error> {
-    // Find git repo root
-    let mut current_dir = std::env::current_dir()?;
-    let repo_root = loop {
-        let git_dir = current_dir.join(".git");
-        if git_dir.exists() && git_dir.is_dir() {
-            break Some(current_dir);
-        }
-        if !current_dir.pop() {
-            break None;
-        }
-    };
-    let repo_root = repo_root.ok_or_else(|| {
-        std::io::Error::new(
-            std::io::ErrorKind::NotFound,
-            "Failed to find git repository root",
-        )
-    })?;
-
-    let manifest_path = repo_root.join("mingling/Cargo.toml");
-    if !manifest_path.exists() {
-        return Err(std::io::Error::new(
-            std::io::ErrorKind::NotFound,
-            format!("Manifest not found at {}", manifest_path.display()),
-        ));
-    }
-
-    let manifest_content = std::fs::read_to_string(&manifest_path)?;
-    let cargo_toml: toml::Value = manifest_content.parse().map_err(|e| {
-        std::io::Error::new(
-            std::io::ErrorKind::InvalidData,
-            format!("Failed to parse Cargo.toml: {}", e),
-        )
-    })?;
-
-    let doc_features = cargo_toml
-        .get("package")
-        .and_then(|p| p.get("metadata"))
-        .and_then(|m| m.get("docs"))
-        .and_then(|d| d.get("rs"))
-        .and_then(|rs| rs.get("features"))
-        .and_then(|f| f.as_array())
-        .ok_or_else(|| {
-            std::io::Error::new(
-                std::io::ErrorKind::NotFound,
-                "[package.metadata.docs.rs] or its 'features' key not found in mingling/Cargo.toml",
-            )
-        })?;
-
-    let features: Vec = doc_features
-        .iter()
-        .filter_map(|v| v.as_str().map(String::from))
-        .collect();
-
-    if features.is_empty() {
-        return Err(std::io::Error::new(
-            std::io::ErrorKind::InvalidData,
-            "No features defined in [package.metadata.docs.rs]",
-        ));
-    }
-
-    Ok(features)
-}
-
-#[must_use]
-pub fn cargo_tomls() -> Vec {
-    let mut cargo_tomls = Vec::new();
-    let mut dirs = vec![std::path::PathBuf::from(".")];
-    while let Some(dir) = dirs.pop() {
-        if let Ok(entries) = std::fs::read_dir(&dir) {
-            for entry in entries.flatten() {
-                let path = entry.path();
-                if path.is_dir() {
-                    // Skip the .run directory
-                    if path.file_name().and_then(|n| n.to_str()) == Some(".run") {
-                        continue;
-                    }
-                    dirs.push(path);
-                } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
-                    cargo_tomls.push(path);
-                }
-            }
-        }
-    }
-    cargo_tomls
-}
diff --git a/.run/src/verify.rs b/.run/src/verify.rs
deleted file mode 100644
index b79bb73..0000000
--- a/.run/src/verify.rs
+++ /dev/null
@@ -1,506 +0,0 @@
-use std::path::Path;
-
-use crate::println_cargo_style;
-
-/// Represents a parsed code block from a markdown file
-#[derive(Debug, Clone)]
-pub struct CodeBlock {
-    /// Source file path (for reporting)
-    pub source_file: String,
-    /// The line number in source file where this block starts
-    pub line: usize,
-    /// The raw Rust source code
-    pub code: String,
-    /// Feature flags extracted from `// Features: [...]` comment
-    pub features: Vec,
-    /// Whether the block had an explicit `// Features:` header
-    pub has_features_header: bool,
-    /// Whether the block has `// NOT VERIFIED` to opt out of testing
-    pub not_verified: bool,
-    /// External dependencies extracted from `// Dependencies:` comments
-    pub external_deps: Vec<(String, String)>,
-    /// Whether this block has a `fn main` entry point
-    pub has_main: bool,
-    /// Whether this block has `gen_program!()` call
-    pub has_gen_program: bool,
-    /// Whether this block has `// BUILD TIME` annotation (write to build.rs, not main.rs)
-    pub is_build_time: bool,
-}
-
-/// Parse all ```rust code blocks from markdown content
-pub fn parse_code_blocks(content: &str, source_file: &str) -> Vec {
-    let mut blocks = Vec::new();
-    let lines: Vec<&str> = content.lines().collect();
-    let mut i = 0;
-
-    while i < lines.len() {
-        if lines[i].trim() == "```rust" {
-            if let Some(block) = parse_single_block(&lines, i, source_file) {
-                blocks.push(block);
-            }
-            i += 1;
-            while i < lines.len() && lines[i].trim() != "```" {
-                i += 1;
-            }
-        }
-        i += 1;
-    }
-
-    blocks
-}
-
-/// Parse a single code block starting at the ```rust line
-fn parse_single_block(lines: &[&str], start: usize, source_file: &str) -> Option {
-    let line_num = start + 1; // 1-based line number
-
-    let mut code_lines: Vec = Vec::new();
-    let mut features: Vec = Vec::new();
-    let mut has_features_header = false;
-    let mut not_verified = false;
-    let mut external_deps: Vec<(String, String)> = Vec::new();
-    let mut has_main = false;
-    let mut has_gen_program = false;
-    let mut is_build_time = false;
-
-    let mut idx = start + 1;
-    let mut in_header = true;
-
-    while idx < lines.len() {
-        let raw_line = lines[idx];
-        let trimmed = raw_line.trim();
-
-        if trimmed == "```" {
-            break;
-        }
-
-        // @@@ lines: strip the prefix and treat as regular Rust code
-        // These lines are hidden in the rendered docs (filtered by a docsify plugin)
-        // but must still compile.
-        if let Some(stripped) = trimmed.strip_prefix("@@@") {
-            in_header = false;
-            // Strip @@@ and optionally one following space
-            let code = stripped.trim_start();
-            if code.contains("fn main") {
-                has_main = true;
-            }
-            if code.contains("gen_program!") {
-                has_gen_program = true;
-            }
-            code_lines.push(code.to_string());
-            idx += 1;
-            continue;
-        }
-
-        // Parse header comments
-        // Check for NOT VERIFIED marker
-        if in_header && trimmed == "// NOT VERIFIED" {
-            not_verified = true;
-            idx += 1;
-            continue;
-        }
-
-        if in_header && trimmed == "// BUILD TIME" {
-            is_build_time = true;
-            idx += 1;
-            continue;
-        }
-
-        if in_header && trimmed.starts_with("// ") {
-            if trimmed.starts_with("// Features:") {
-                has_features_header = true;
-                let feat_str = trimmed.trim_start_matches("// Features:").trim();
-                if feat_str.starts_with('[') && feat_str.ends_with(']') {
-                    let inner = &feat_str[1..feat_str.len() - 1];
-                    if !inner.is_empty() {
-                        features = inner
-                            .split(',')
-                            .map(|s| s.trim().trim_matches('"').to_string())
-                            .filter(|s| !s.is_empty())
-                            .collect();
-                    }
-                }
-                idx += 1;
-                continue;
-            }
-            if trimmed == "// Dependencies:" {
-                idx += 1;
-                // Collect subsequent `// crate = "version"` lines
-                while idx < lines.len() {
-                    let next = lines[idx].trim();
-                    if next == "```" {
-                        break;
-                    }
-                    if next.starts_with("// ") {
-                        let dep_line = next.trim_start_matches("// ").trim();
-                        if let Some((name, ver)) = dep_line.split_once(" = ") {
-                            external_deps.push((
-                                name.trim().to_string(),
-                                ver.trim().trim_matches('"').to_string(),
-                            ));
-                        }
-                        idx += 1;
-                    } else {
-                        break;
-                    }
-                }
-                continue;
-            }
-        }
-
-        in_header = false;
-
-        if raw_line.contains("fn main") {
-            has_main = true;
-        }
-        if raw_line.contains("gen_program!") {
-            has_gen_program = true;
-        }
-
-        code_lines.push(raw_line.to_string());
-        idx += 1;
-    }
-
-    if code_lines.is_empty() {
-        return None;
-    }
-
-    Some(CodeBlock {
-        source_file: source_file.to_string(),
-        line: line_num,
-        code: code_lines.join("\n"),
-        features,
-        has_features_header,
-        not_verified,
-        external_deps,
-        has_main,
-        has_gen_program,
-        is_build_time,
-    })
-}
-
-/// Generate a Cargo.toml for a block
-///
-/// `manifest_path` is the full path to the Cargo.toml file being written; it is used to
-/// compute the relative path to the `mingling` crate.
-pub fn generate_cargo_toml(block: &CodeBlock, package_name: &str, manifest_path: &Path) -> String {
-    let features_str = if !block.features.is_empty() {
-        let feats: Vec = block.features.iter().map(|f| format!("\"{f}\"")).collect();
-        format!("features = [{}]", feats.join(", "))
-    } else {
-        String::new()
-    };
-
-    let mut extra_deps = String::new();
-    for (name, version) in &block.external_deps {
-        if !version.starts_with('{') {
-            if name == "serde" || name == "clap" {
-                extra_deps.push_str(&format!(
-                    "{name} = {{ version = \"{version}\", features = [\"derive\"] }}\n"
-                ));
-            } else {
-                extra_deps.push_str(&format!("{name} = \"{version}\"\n"));
-            }
-        } else {
-            extra_deps.push_str(&format!("{name} = {version}\n"));
-        }
-    }
-
-    let mingling_path = find_mingling_relative_path(manifest_path);
-
-    let deps_section = if features_str.is_empty() {
-        format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}",)
-    } else {
-        format!(
-            "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}",
-        )
-    };
-
-    // Build-time blocks: mirror the declared features into [build-dependencies]
-    // so that build.rs can use the same feature set as the crate itself.
-    let build_deps_section = if block.is_build_time {
-        let feats_str: Vec = block.features.iter().map(|f| format!("\"{f}\"")).collect();
-        let build_feats = if feats_str.is_empty() {
-            String::new()
-        } else {
-            format!("features = [{}]", feats_str.join(", "))
-        };
-        format!(
-            "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n"
-        )
-    } else {
-        String::new()
-    };
-
-    format!(
-        r#"[package]
-	name = "{package_name}"
-	version = "0.0.0"
-	edition = "2024"
-
-{deps_section}{build_deps_section}
-[workspace]
-"#
-    )
-}
-
-/// Compute the relative path from a Cargo.toml's parent directory to the `mingling` crate.
-///
-/// The process current directory is expected to be the project root (where `mingling/` lives).
-/// Returns a forward-slash path safe for embedding in TOML strings.
-fn find_mingling_relative_path(manifest_path: &Path) -> String {
-    let manifest_dir = manifest_path
-        .parent()
-        .expect("manifest_path has no parent directory");
-    let cwd = std::env::current_dir().expect("failed to get current directory");
-
-    // Strip cwd prefix to get the relative components of the manifest directory
-    let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir);
-    let depth = relative_to_root.components().count();
-
-    let mut result = String::new();
-    for _ in 0..depth {
-        result.push_str("../");
-    }
-    result.push_str("mingling");
-    result
-}
-
-/// Generate main.rs for a block
-///
-/// Automatically prepends `use mingling::prelude::*;` if the block doesn't already have it.
-pub fn generate_main_rs(block: &CodeBlock) -> String {
-    let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
-
-    if !block.code.contains("use mingling::prelude::*;") {
-        output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n");
-    }
-
-    output.push_str(&block.code);
-    output.push('\n');
-
-    if !block.has_main {
-        output.push_str("\nfn main() {}\n");
-    }
-
-    if !block.has_gen_program {
-        output.push_str("\nmingling::macros::gen_program!();\n");
-    }
-
-    output
-}
-
-/// Generate build.rs for a build-time block
-///
-/// Default: code wrapped in `fn main() { }`.
-pub fn generate_build_rs(block: &CodeBlock) -> String {
-    let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
-
-    if block.has_main {
-        output.push_str(&block.code);
-    } else {
-        output.push_str("fn main() {\n");
-        for line in block.code.lines() {
-            output.push_str("    ");
-            output.push_str(line);
-            output.push('\n');
-        }
-        output.push_str("}\n");
-    }
-
-    output
-}
-
-/// Build a single code block as a Cargo project.
-///
-/// When `is_build_time` is true, `src_content` is written to `build.rs` instead of `src/main.rs`,
-/// and a minimal `src/main.rs` stub (`fn main() {}`) is created.
-pub fn build_block(
-    src_dir: &Path,
-    manifest_path: &Path,
-    cargo_toml: &str,
-    src_content: &str,
-    is_build_time: bool,
-) -> (bool, String) {
-    if let Err(e) = std::fs::create_dir_all(src_dir) {
-        return (false, format!("mkdir: {e}"));
-    }
-
-    // Write Cargo.toml
-    if let Err(e) = std::fs::write(manifest_path, cargo_toml) {
-        return (false, format!("write Cargo.toml: {e}"));
-    }
-
-    if is_build_time {
-        // Write build.rs and a stub main.rs
-        let crate_dir = manifest_path.parent().unwrap();
-        if let Err(e) = std::fs::write(crate_dir.join("build.rs"), src_content) {
-            return (false, format!("write build.rs: {e}"));
-        }
-        if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") {
-            return (false, format!("write main.rs: {e}"));
-        }
-    } else {
-        // Normal: write src/main.rs
-        if let Err(e) = std::fs::write(src_dir.join("main.rs"), src_content) {
-            return (false, format!("write main.rs: {e}"));
-        }
-    }
-
-    // Check code — inherit stderr so cargo output is real-time and colored
-    let shell = if cfg!(target_os = "windows") {
-        "powershell"
-    } else {
-        "sh"
-    };
-    let cmd = format!(
-        "cargo check --color=always --manifest-path {}",
-        manifest_path.to_string_lossy()
-    );
-
-    let mut child = match std::process::Command::new(shell)
-        .arg("-c")
-        .arg(&cmd)
-        .stdout(std::process::Stdio::inherit())
-        .stderr(std::process::Stdio::piped())
-        .current_dir(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))
-        .spawn()
-    {
-        Ok(c) => c,
-        Err(e) => return (false, format!("spawn: {e}")),
-    };
-
-    // Read stderr (buffered, not forwarded — groups print their own output contiguously)
-    use std::io::BufRead;
-    let stderr_handle = child.stderr.take().unwrap();
-    let reader = std::io::BufReader::new(stderr_handle);
-    let mut captured = String::new();
-    for line in reader.lines() {
-        match line {
-            Ok(l) => {
-                captured.push_str(&l);
-                captured.push('\n');
-            }
-            Err(_) => break,
-        }
-    }
-
-    let status = child.wait().unwrap_or_else(|_| std::process::exit(1));
-    let exit_code = status.code().unwrap_or(1);
-
-    if exit_code == 0 {
-        (true, String::new())
-    } else {
-        let mut last_lines: Vec<&str> = captured.lines().rev().take(20).collect();
-        last_lines.reverse();
-        let detail = last_lines.join("\n");
-        (false, format!("exit code {exit_code}\n{detail}"))
-    }
-}
-
-/// Compute a stable hash for a code block based on its dependency configuration.
-///
-/// Blocks with the same features and external dependencies produce the same hash,
-/// allowing them to share a compiled crate and avoid redundant recompilation.
-///
-/// Hash input (all sorted for stability):
-/// - Sorted mingling feature strings
-/// - Sorted external dependency names
-/// - Sorted external dependency versions
-/// - Sorted external deps as `name=version` pairs
-pub fn compute_block_hash(block: &CodeBlock) -> String {
-    let mut features: Vec<&str> = block.features.iter().map(|s| s.as_str()).collect();
-    features.sort();
-    let features_str = features.join(",");
-
-    let mut dep_names: Vec<&str> = block
-        .external_deps
-        .iter()
-        .map(|(n, _)| n.as_str())
-        .collect();
-    dep_names.sort();
-    let dep_names_str = dep_names.join(",");
-
-    let mut dep_versions: Vec<&str> = block
-        .external_deps
-        .iter()
-        .map(|(_, v)| v.as_str())
-        .collect();
-    dep_versions.sort();
-    let dep_versions_str = dep_versions.join(",");
-
-    let mut deps: Vec = block
-        .external_deps
-        .iter()
-        .map(|(n, v)| format!("{n}={v}"))
-        .collect();
-    deps.sort();
-    let deps_str = deps.join(",");
-
-    let canonical = format!("{features_str}\n{dep_names_str}\n{dep_versions_str}\n{deps_str}");
-
-    // FNV-1a 64-bit hash — stable across runs (no random seed)
-    let mut hash: u64 = 0xcbf29ce484222325;
-    for &byte in canonical.as_bytes() {
-        hash ^= byte as u64;
-        hash = hash.wrapping_mul(0x100000001b3);
-    }
-
-    format!("{:016x}", hash)
-}
-
-/// Determine if a block should be treated as a test candidate.
-/// A block is NOT testable only if it has `// NOT VERIFIED` marker.
-pub fn is_block_testable(block: &CodeBlock) -> bool {
-    !block.not_verified
-}
-
-/// Write a summary report
-pub fn write_summary_report(
-    path: &Path,
-    title: &str,
-    results: &[(String, usize, bool, String)],
-    total: usize,
-    passed: usize,
-    failed: usize,
-) {
-    let mut content = String::new();
-    content.push_str(&format!("# {title}\n\n"));
-    content.push_str(&format!(
-        "Tested **{total}** code blocks: **{passed}** passed, **{failed}** failed.\n\n"
-    ));
-    content.push_str("## Results\n\n");
-    content.push_str("| Block | File | Line | Status |\n");
-    content.push_str("|-------|------|------|--------|\n");
-
-    for (i, (file, line, ok, _)) in results.iter().enumerate() {
-        let status = if *ok { "PASS" } else { "FAIL" };
-        let short_file = file.rsplit('/').next().unwrap_or(file);
-        content.push_str(&format!(
-            "| {} | {} | {} | {status} |\n",
-            i + 1,
-            short_file,
-            line
-        ));
-    }
-
-    let has_failures = results.iter().any(|(_, _, ok, _)| !ok);
-    if has_failures {
-        content.push_str("\n## Failed Blocks\n\n");
-        for (i, (file, line, ok, err)) in results.iter().enumerate() {
-            if !ok {
-                content.push_str(&format!(
-                    "### Block {} (`{}`, line {})\n\n```\n{err}\n```\n\n",
-                    i + 1,
-                    file,
-                    line
-                ));
-            }
-        }
-    }
-
-    std::fs::write(path, &content).unwrap_or_else(|e| {
-        eprintln!("Warning: failed to write {path:?}: {e}");
-    });
-
-    println_cargo_style!("Report: written to {}", path.display());
-}
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 6a905a8..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-    "rust-analyzer.check.command": "clippy",
-    "rust-analyzer.checkOnSave": true,
-    "rust-analyzer.files.exclude": ["**/target/**", "**/.temp/**"],
-    "rust-analyzer.linkedProjects": [
-        ".run/Cargo.toml",
-        "mingling_ci/Cargo.toml",
-        "mingling_pathf/test/Cargo.toml",
-        "arg_picker/Cargo.toml",
-        "arg_picker/test/Cargo.toml",
-        "mingling_cli/Cargo.toml"
-    ],
-    "rust-analyzer.cargo.features": [],
-    "rust-analyzer.procMacro.enable": true,
-    "rust-analyzer.procMacro.attributes.enable": true
-}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
deleted file mode 100644
index 26460c1..0000000
--- a/.vscode/tasks.json
+++ /dev/null
@@ -1,122 +0,0 @@
-{
-  "version": "2.0.0",
-  "tasks": [
-    {
-      "label": "Mingling CI",
-      "command": "cargo ci",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "TEST: All *.md Codes",
-      "command": "cargo dev_tool test-all-markdown-code",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "TEST: Current *.md Codes",
-      "command": "cargo dev_tool test-all-markdown-code -- \"${fileRelative}\"",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "REFRESH: All",
-      "command": "cargo dev_tool refresh-docs && cargo dev_tool docs-code-box-fix && cargo dev_tool docsify-sidebar-gen && cargo dev_tool refresh-feature-mod && cargo dev_tool sync-examples",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "REFRESH: Docs Code Box Fix",
-      "command": "cargo dev_tool docs-code-box-fix",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "REFRESH: Docsify Sidebar Gen",
-      "command": "cargo dev_tool docsify-sidebar-gen",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "REFRESH: Docs",
-      "command": "cargo dev_tool refresh-docs",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "REFRESH: Feature Mod",
-      "command": "cargo dev_tool refresh-feature-mod",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-    {
-      "label": "REFRESH: Sync Examples",
-      "command": "cargo dev_tool sync-examples",
-      "type": "shell",
-      "presentation": {
-        "echo": true,
-        "reveal": "always",
-        "focus": false,
-        "panel": "shared",
-        "showReuseMessage": true,
-        "clear": false,
-      },
-    },
-  ],
-}
diff --git a/.zed/settings.json b/.zed/settings.json
deleted file mode 100644
index f4a55eb..0000000
--- a/.zed/settings.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
-    "lsp": {
-        "rust-analyzer": {
-            "initialization_options": {
-                "checkOnSave": true,
-                "check": { "command": "clippy" },
-                "files": {
-                    "exclude": ["**/target/**", "**/.temp/**"]
-                },
-                "linkedProjects": [
-                    ".run/Cargo.toml",
-                    "mingling_ci/Cargo.toml",
-                    "mingling_pathf/test/Cargo.toml",
-                    "arg_picker/Cargo.toml",
-                    "arg_picker/test/Cargo.toml",
-                    "mingling_cli/Cargo.toml"
-                ],
-                "cargo": {
-                    "features": []
-                },
-                "procMacro": {
-                    "enable": true,
-                    "attributes": {
-                        "enable": true
-                    }
-                }
-            }
-        }
-    }
-}
diff --git a/.zed/tasks.json b/.zed/tasks.json
deleted file mode 100644
index 550d950..0000000
--- a/.zed/tasks.json
+++ /dev/null
@@ -1,71 +0,0 @@
-[
-  {
-    "label": "Mingling CI",
-    "command": "cargo ci",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": false,
-    "hide": "on_success",
-    "save": "all",
-  },
-  {
-    "label": "TEST: All *.md Codes",
-    "command": "cargo dev_tool test-all-markdown-code",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-  },
-  {
-    "label": "TEST: Current *.md Codes",
-    "command": "cargo dev_tool test-all-markdown-code -- \"$ZED_RELATIVE_FILE\"",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-  },
-  {
-    "label": "REFRESH: Docs Code Box Fix",
-    "command": "cargo dev_tool docs-code-box-fix",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-    "tags": ["refresh"],
-  },
-  {
-    "label": "REFRESH: Docsify Sidebar Gen",
-    "command": "cargo dev_tool docsify-sidebar-gen",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-    "tags": ["refresh"],
-  },
-  {
-    "label": "REFRESH: Docs",
-    "command": "cargo dev_tool refresh-docs",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-    "tags": ["refresh"],
-  },
-  {
-    "label": "REFRESH: Feature Mod",
-    "command": "cargo dev_tool refresh-feature-mod",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-    "tags": ["refresh"],
-  },
-  {
-    "label": "REFRESH: Sync Examples",
-    "command": "cargo dev_tool sync-examples",
-    "use_new_terminal": true,
-    "allow_concurrent_runs": true,
-    "hide": "on_success",
-    "save": "all",
-    "tags": ["refresh"],
-  },
-]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4f1b4bf..60c1dbe 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,8 +25,8 @@ Before contributing, we recommend reading [README](README.md) to get an overview
 | **Dev Documents**       | `docs/dev/`          | Internal documents                                                      |
 | **Resources**           | `docs/res/`          | All resources                                                           |
 | Dev Tools               |                      |                                                                         |
-| **CI system**           | `mingling_ci/`       | CI crate built on the Mingling framework, invoked via `cargo ci`        |
-| **CI configs**          | `.config/`           | `ci-ignored-dirs.txt`, `verified-docs.toml`, `docs-lang.txt`            |
+| **CI system**           | `dev/ci/`            | CI crate built on the Mingling framework, invoked via `cargo ci`        |
+| **CI configs**          | `dev/configs/`       | `ci-ignored-dirs.txt`, `verified-docs.toml`, `docs-lang.txt`            |
 | **CI orchestration**    | `.run/src/bin/ci.py` | Full pipeline script (lock → checks → refresh → unlock)                 |
 | **Development tools**   | `.run/src/bin`       | Contains scripts and Rust tools (`deploy-api-docs`, `install-mling`, …) |
 | Misc                    |                      |                                                                         |
@@ -142,34 +142,34 @@ No strict requirements here — just modify the relevant `*.html` files. Preview
 
 ### Dev Tool Contribution
 
-`Mingling CI` code is under strict review. If you want to improve `mingling`'s CI pipeline (`mingling_ci/`) or other dev tools (under `.run/`),
+`Mingling CI` code is under strict review. If you want to improve `mingling`'s CI pipeline (`dev/ci/`) or other dev tools (under `.run/`),
 **please** first file an [Issue](https://github.com/mingling-rs/mingling/issues) and contact [Weicao-CatilGrass](https://github.com/Weicao-CatilGrass)!
 
 ## 3. Submission Guide 🖊
 
 1. **Pull Request**
-   - Submit a GitHub Pull Request and @Reviewer **[Weicao-CatilGrass](https://github.com/Weicao-CatilGrass)** for review
-   - Or send patches to **catil_grass@qq.com**
+    - Submit a GitHub Pull Request and @Reviewer **[Weicao-CatilGrass](https://github.com/Weicao-CatilGrass)** for review
+    - Or send patches to **catil_grass@qq.com**
 
 2. **Commit Messages**
-   - Clearly and concisely describe the changes, no stringent requirements
-   - Provide more detail for complex changes, keep it brief for simple changes
-   - But: if you use [Conventional Commits](https://www.conventionalcommits.org/), it would make me even happier :)
+    - Clearly and concisely describe the changes, no stringent requirements
+    - Provide more detail for complex changes, keep it brief for simple changes
+    - But: if you use [Conventional Commits](https://www.conventionalcommits.org/), it would make me even happier :)
 
 3. **CHANGELOG**
-   - If the submission includes functional changes or fixes, **the PR must include modifications to CHANGELOG.md** to describe the changes
-   - For minor changes like typo fixes, **CHANGELOG.md modification is not required**, and we will merge faster
+    - If the submission includes functional changes or fixes, **the PR must include modifications to CHANGELOG.md** to describe the changes
+    - For minor changes like typo fixes, **CHANGELOG.md modification is not required**, and we will merge faster
 
 4. **Multi-commit PR**
 
-   - A PR can contain multiple commits
-   - However, at least one commit must modify CHANGELOG.md
+    - A PR can contain multiple commits
+    - However, at least one commit must modify CHANGELOG.md
 
 5. **Review**
-   - After submission, please notify [Weicao-CatilGrass](https://github.com/Weicao-CatilGrass) for review — this is the most efficient way to get feedback
+    - After submission, please notify [Weicao-CatilGrass](https://github.com/Weicao-CatilGrass) for review — this is the most efficient way to get feedback
 
 6. **Binary Resources**
-   - For binary resource files (images, etc.), please be cautious about adding them to avoid repository bloat
+    - For binary resource files (images, etc.), please be cautious about adding them to avoid repository bloat
 
 ## 5. Regarding AI Agent Usage 🤖
 
diff --git a/Cargo.lock b/Cargo.lock
index fbcfb31..47aa118 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -316,6 +316,10 @@ dependencies = [
 [[package]]
 name = "mingling-workspace"
 version = "0.5.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
 
 [[package]]
 name = "mingling_core"
diff --git a/Cargo.toml b/Cargo.toml
index 595053b..34e36ab 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -83,3 +83,7 @@ name = "mingling-workspace"
 version.workspace = true
 edition.workspace = true
 publish = false
+
+[build-dependencies]
+serde.workspace = true
+serde_json.workspace = true
diff --git a/README.md b/README.md
index 2059384..1f036c6 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,7 @@ This is because the Rust ecosystem already has excellent and mature crates to ha
 - 📦 Repo - [Github](https://github.com/mingling-rs/mingling) | [Gitee](https://gitee.com/mingling-rs/mingling) | [Origin](https://catilgrass.cn/mingling.git)
 - 🚪 Mainpage - [Github](https://mingling-rs.github.io/mingling/) | [crates.io](https://crates.io/crates/mingling)
 - 💡 Examples - [Github](https://mingling-rs.github.io/mingling/docs/examples.html)
-- 📖 Help Doc - [EN](https://mingling-rs.github.io/mingling/docs/doc.html#/) | [中文](https://mingling-rs.github.io/mingling/docs/_zh_CN/index.html#/)
+- 📖 Help Doc - [EN](https://mingling-rs.github.io/mingling/docs/index.html#/) | [中文](https://mingling-rs.github.io/mingling/docs/_zh_CN/index.html#/)
 - 📖 API Doc - [docs.rs](https://docs.rs/mingling/latest/mingling/) | [latest](https://mingling-rs.github.io/mingling/docs/api-docs/mingling/)
 - 📖 Coverage Test - [LLVM Coverage](https://mingling-rs.github.io/mingling/docs/cov-test/)
 - 📖 Dev Doc - [Github](https://mingling-rs.github.io/mingling/docs/dev/)
diff --git a/build.rs b/build.rs
index 951ecf8..17a6d02 100644
--- a/build.rs
+++ b/build.rs
@@ -1,13 +1,162 @@
-use std::{env::current_dir, fs};
+//! This module initializes the Mingling local repository workspace.
+//!
+//! It automatically generates necessary files for development.
+
+use serde_json::Value;
+use std::env::current_dir;
+use std::fs;
 
 fn main() {
     gen_fake_cargo_toml_in_temp_dir();
+    gen_rust_analyzer_config_for_editors();
 }
 
+/// Generate a fake Cargo workspace to prevent temporary repositories under `.temp/`
+/// from finding the root directory
 fn gen_fake_cargo_toml_in_temp_dir() {
     fs::write(
-        current_dir().unwrap().join(".temp").join("Cargo.toml"),
+        current_dir().unwrap().join(".temp/Cargo.toml"),
         "[workspace]",
     )
     .unwrap();
 }
+
+/// Generate Rust Analyzer configuration for editors.
+///
+/// Copies the editor configuration at `dev/configs/rust-analyzer.json` to each
+/// editor's configuration file.
+///
+/// Supported editors:
+/// - `Zed Editor` : ".zed/settings.json"
+/// - `VS Code`: ".vscode/settings.json"
+fn gen_rust_analyzer_config_for_editors() {
+    // Re-run this build script whenever the source config changes.
+    println!("cargo:rerun-if-changed=dev/configs/rust-analyzer.json");
+
+    let root = current_dir().unwrap();
+    let source = root.join("dev/configs/rust-analyzer.json");
+    let Ok(source_content) = fs::read_to_string(&source) else {
+        eprintln!(
+            "warning: `{}` not found, skip editor config generation",
+            source.display()
+        );
+        return;
+    };
+
+    // VS Code uses the same flat `rust-analyzer.*` key format, so copy as-is.
+    let vscode_path = root.join(".vscode/settings.json");
+    if ra_settings::write_if_changed(&vscode_path, &source_content) {
+        eprintln!("generated {}", vscode_path.display());
+    }
+
+    // Zed nests the same settings under `lsp.rust-analyzer.initialization_options`.
+    let Ok(config) = serde_json::from_str::(&source_content) else {
+        eprintln!(
+            "warning: failed to parse `{}` as JSON, skip Zed config",
+            source.display()
+        );
+        return;
+    };
+    let zed_path = root.join(".zed/settings.json");
+    if ra_settings::write_if_changed(
+        &zed_path,
+        &ra_settings::to_pretty_json(&ra_settings::to_zed_settings(&config)),
+    ) {
+        eprintln!("generated {}", zed_path.display());
+    }
+}
+
+/// Internal helpers for generating editor configuration files.
+///
+/// This module contains utilities for writing config files only when their
+/// contents change, serializing settings in the repo's preferred JSON style,
+/// and converting between the flat `rust-analyzer.*` key format used by VS Code
+/// and the nested structure expected by the Zed editor.
+mod ra_settings {
+
+    use std::fs;
+    use std::path::Path;
+
+    use serde::Serialize;
+    use serde_json::{Map, Value};
+
+    /// Write `content` to `path` only when it differs, so editors do not reload
+    /// unchanged settings files on every build. Returns whether a write happened.
+    pub fn write_if_changed(path: &Path, content: &str) -> bool {
+        if fs::read_to_string(path).is_ok_and(|existing| existing == content) {
+            return false;
+        }
+        if let Some(parent) = path.parent() {
+            fs::create_dir_all(parent).unwrap();
+        }
+        fs::write(path, content).unwrap();
+        true
+    }
+
+    /// Serialize a value as 4-space-indented JSON (matching the repo's hand-written
+    /// config style), with a trailing newline.
+    pub fn to_pretty_json(value: &Value) -> String {
+        let mut buf = Vec::new();
+        let mut ser = serde_json::Serializer::with_formatter(
+            &mut buf,
+            serde_json::ser::PrettyFormatter::with_indent(b"    "),
+        );
+        value.serialize(&mut ser).unwrap();
+        String::from_utf8(buf).unwrap() + "\n"
+    }
+
+    /// Convert VSCode-style flat `rust-analyzer.*` keys into the nested
+    /// `lsp.rust-analyzer.initialization_options` structure that Zed expects.
+    pub fn to_zed_settings(vscode: &Value) -> Value {
+        let mut init = Map::new();
+        let Some(source) = vscode.as_object() else {
+            return Value::Object(init);
+        };
+
+        let unknown: Vec<&String> = source
+            .keys()
+            .filter(|k| !k.starts_with("rust-analyzer."))
+            .collect();
+        if !unknown.is_empty() {
+            eprintln!(
+                "warning: ignoring non-rust-analyzer keys when generating Zed config: {unknown:?}"
+            );
+        }
+
+        for (key, value) in source {
+            if let Some(rest) = key.strip_prefix("rust-analyzer.") {
+                insert_nested(&mut init, rest, value.clone());
+            }
+        }
+
+        let mut rust_analyzer = Map::new();
+        rust_analyzer.insert("initialization_options".into(), Value::Object(init));
+        let mut lsp = Map::new();
+        lsp.insert("rust-analyzer".into(), Value::Object(rust_analyzer));
+        let mut root = Map::new();
+        root.insert("lsp".into(), Value::Object(lsp));
+        Value::Object(root)
+    }
+
+    /// Insert `value` at the dotted path `a.b.c` inside `map`, creating any
+    /// intermediate objects on the way.
+    fn insert_nested(map: &mut Map, path: &str, value: Value) {
+        let Some((head, tail)) = path.split_once('.') else {
+            map.insert(path.to_string(), value);
+            return;
+        };
+
+        let child = map
+            .entry(head.to_string())
+            .or_insert_with(|| Value::Object(Map::new()));
+        match child {
+            Value::Object(child) => insert_nested(child, tail, value),
+            // A scalar already occupies this path; promote it to an object.
+            _ => {
+                let mut fresh = Map::new();
+                insert_nested(&mut fresh, tail, value);
+                *child = Value::Object(fresh);
+            }
+        }
+    }
+}
diff --git a/dev/ci/Cargo.lock b/dev/ci/Cargo.lock
new file mode 100644
index 0000000..796176a
--- /dev/null
+++ b/dev/ci/Cargo.lock
@@ -0,0 +1,777 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "arg-picker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e326de90a1c279562cc1583470daccfd4defe882661c61607edff75b797e483e"
+dependencies = [
+ "arg-picker-macros",
+ "just_fmt 0.2.1",
+]
+
+[[package]]
+name = "arg-picker-macros"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5f9bbe04b69744a30def8bee5526268b986616314bd1696804d7f819f03d1f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "colored"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "unicode-width 0.2.2",
+ "windows-sys",
+]
+
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "indicatif"
+version = "0.18.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
+dependencies = [
+ "console",
+ "portable-atomic",
+ "unicode-width 0.2.2",
+ "unit-prefix",
+ "web-time",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "just_fmt"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5454cda0d57db59778608d7a47bff5b16c6705598265869fb052b657f66cf05e"
+
+[[package]]
+name = "just_fmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91b935090fce9a995a79798a22d523f1742b202f57ad2d8fcab6ad3dff528baf"
+
+[[package]]
+name = "just_progress"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef1a564328a5061a4828b4f82b7275a7f3dbc7d4ed5778da986f6ab48563c88"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
+name = "just_template"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2a56a31287e7397340dd8d9997c8fd936ee68b27cb3f090ece2984e8a84d5bc"
+dependencies = [
+ "just_fmt 0.1.2",
+ "just_template_macros",
+]
+
+[[package]]
+name = "just_template_macros"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74f4328c632a33ef4c2c811e02bd2506f2db215bf7fbecbf934b0a61a17e7bea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libredox"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "might_be_async"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca871cea620b07bd2e6da0c883891a25bead698c43a9ab64b0fd663a7a78d5f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "toml",
+]
+
+[[package]]
+name = "mingling"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe3138d939ed9c987301b38a1f0577e4db5b0e9b14e16f73ebe37dad8e25380e"
+dependencies = [
+ "arg-picker",
+ "mingling_core",
+ "mingling_macros",
+]
+
+[[package]]
+name = "mingling-ci-system"
+version = "0.1.0"
+dependencies = [
+ "colored",
+ "indicatif",
+ "just_fmt 0.2.1",
+ "just_progress",
+ "just_template",
+ "mingling",
+ "prettytable-rs",
+ "serde",
+ "serde_json",
+ "tokio",
+ "toml",
+]
+
+[[package]]
+name = "mingling_core"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9165f58f51569b6627c09fd4292fa298c11ef2c467c0b77640a52339760249fb"
+dependencies = [
+ "just_fmt 0.2.1",
+ "might_be_async",
+ "mingling_pathf",
+]
+
+[[package]]
+name = "mingling_macros"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28cb7912933889738451b181d8bf958d215265555596328c5c4a8053c1be78d"
+dependencies = [
+ "just_fmt 0.2.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "mingling_pathf"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ed8d3adebe58b1c914f1fddce1b7a2afa2c9fca80665f0af251ea64662b8905"
+dependencies = [
+ "just_fmt 0.2.1",
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "prettytable-rs"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
+dependencies = [
+ "csv",
+ "encode_unicode",
+ "is-terminal",
+ "lazy_static",
+ "term",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "term"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
+dependencies = [
+ "dirs-next",
+ "rustversion",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "unit-prefix"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/dev/ci/Cargo.toml b/dev/ci/Cargo.toml
new file mode 100644
index 0000000..c96043b
--- /dev/null
+++ b/dev/ci/Cargo.toml
@@ -0,0 +1,47 @@
+# This CI system is built on Mingling and is used to validate the next
+# version of Mingling.
+
+[package]
+name = "mingling-ci-system"
+edition = "2024"
+version = "0.1.0"
+publish = false
+
+[workspace]
+
+[dependencies]
+# NOTE: Do not use the current version of Mingling. The CI must be built on an
+# earlier version than the mainline Mingling.
+#
+# > You know the reason! ... the **grandfather paradox**
+mingling = { version = "0.4.0", features = [
+    "async",
+    "dispatch_tree",
+    "extras",
+    "pathf",
+    "picker",
+] }
+
+just_progress = "0.1.3"
+colored = "3.1.1"
+indicatif = "0.18.4"
+tokio = { version = "1.53.1", features = [
+    "rt",
+    "rt-multi-thread",
+    "macros",
+    "process",
+] }
+
+prettytable-rs = "0.10.0"
+toml = "0.8"
+just_template = "0.2.1"
+just_fmt = "0.2.1"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+
+[build-dependencies]
+mingling = { version = "0.4.0", features = [
+    "build",
+    "dispatch_tree",
+    "pathf"
+] }
diff --git a/dev/ci/build.rs b/dev/ci/build.rs
new file mode 100644
index 0000000..e0bcc0c
--- /dev/null
+++ b/dev/ci/build.rs
@@ -0,0 +1,6 @@
+use mingling::build::analyze_and_build_type_mapping;
+
+fn main() {
+    analyze_and_build_type_mapping().unwrap();
+}
+
diff --git a/dev/ci/help.txt b/dev/ci/help.txt
new file mode 100644
index 0000000..de006bb
--- /dev/null
+++ b/dev/ci/help.txt
@@ -0,0 +1,34 @@
+This program is used to check the code quality of the Mingling project itself.
+
+USAGE: cargo ci  [SUBCOMMAND] 
+
+FLAGS:
+  -h, --help                       Print this help page
+  -q, --quiet                      Quiet output
+
+COMMANDS:
+  UTILS:
+   report-collect                  Collect and organize all inspection reports
+   report-clean                    Clean up all reports
+
+   git-lock                        Temporarily commit the workspace for CI
+   git-unlock                      Restore the workspace after CI
+
+   show-features                   Print the docs.rs feature list of mingling
+   show-manifests                  Print all crate paths that need to be checked
+
+  TOOLS:
+   example-refresh                 Regenerate example docs module and examples index
+   docsify-refresh                 Fix docsify code boxes and regenerate sidebars
+   features-refresh                Regenerate the features module
+
+  TASKS:
+   markdown-check            Verify rust code blocks in one markdown file
+   markdown-check-all              Verify rust code blocks in all configured markdown files
+   markdown-compare    Compare the structure of two markdown files/dirs
+   markdown-compare-all            Compare all translated docs against the reference
+   build-check                     Build all crates
+   clippy-check                    Run clippy with -D warnings on all crates
+   test-all                        Test all crates
+   example-check                   Build examples and run their test.toml cases
+   docs-check                      Build mingling docs with -D warnings
diff --git a/dev/ci/src/bin/ci.rs b/dev/ci/src/bin/ci.rs
new file mode 100644
index 0000000..5b1d748
--- /dev/null
+++ b/dev/ci/src/bin/ci.rs
@@ -0,0 +1,30 @@
+use mingling::setup::{
+    ConfirmSetup, DirectoryEnvironmentSetup, ExitCodeSetup,
+    picker::{ConfirmFlagSetup, HelpFlagSetup, QuietFlagSetup},
+};
+
+use mingling_ci_system::ThisProgram;
+use mingling_ci_system::res::*;
+
+#[tokio::main]
+async fn main() {
+    let mut program = ThisProgram::new();
+
+    // Plugins
+    program.with_setup(ExitCodeSetup::default());
+    program.with_setup(DirectoryEnvironmentSetup::default());
+
+    program.with_setup(HelpFlagSetup::default());
+    program.with_setup(ConfirmFlagSetup::default());
+    program.with_setup(QuietFlagSetup::default());
+
+    program.with_setup(ConfirmSetup);
+
+    // CI Plugins
+    program.with_setup(ManifestsSetup);
+    program.with_setup(FeaturesSetup);
+    program.with_setup(CrateConfigSetup);
+    program.with_setup(ReportSetup);
+
+    program.exec_and_exit().await;
+}
diff --git a/dev/ci/src/cmd.rs b/dev/ci/src/cmd.rs
new file mode 100644
index 0000000..b9a02dc
--- /dev/null
+++ b/dev/ci/src/cmd.rs
@@ -0,0 +1,6 @@
+pub(crate) mod cmd_git_lock;
+pub(crate) mod cmd_git_unlock;
+pub(crate) mod cmd_report_clean;
+pub(crate) mod cmd_report_collect;
+pub(crate) mod cmd_show_features;
+pub(crate) mod cmd_show_manifests;
diff --git a/dev/ci/src/cmd/cmd_git_lock.rs b/dev/ci/src/cmd/cmd_git_lock.rs
new file mode 100644
index 0000000..0e9bf22
--- /dev/null
+++ b/dev/ci/src/cmd/cmd_git_lock.rs
@@ -0,0 +1,77 @@
+use mingling::{
+    Grouped, RenderResult, Routable,
+    macros::{buffer, command, r_println, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::git::{CI_TEMP_COMMIT_MESSAGE, LOCK_FILE, TEMP_COMMIT_MESSAGE, run_git, worktree_clean};
+use crate::res::{CargoError, MessagePrinter};
+
+/// Temporarily commits the workspace so CI can run on a stable tree.
+///
+/// First pins the current HEAD to the `mingling/bkup` backup branch (created
+/// or force-reset). When the tree is dirty, all changes are packed into a
+/// plain `TEMP` commit first so they can be restored later; the `CI TEMP`
+/// commit then carries only the `MINGLING-CI-CHECKING` marker file, whose
+/// content (`true`/`false`) tells `git-unlock` which restore path to take.
+#[command(node = "git-lock")]
+pub fn git_lock() -> Next {
+    if let Err(e) = run_git(["branch", "-f", "mingling/bkup", "HEAD"]) {
+        return ErrorGitLock(e).to_chain();
+    }
+
+    let dirty = !worktree_clean();
+    if dirty {
+        if let Err(e) = run_git(["add", "."]) {
+            return ErrorGitLock(e).to_chain();
+        }
+        if let Err(e) = run_git(["commit", "-m", TEMP_COMMIT_MESSAGE]) {
+            return ErrorGitLock(e).to_chain();
+        }
+    }
+
+    let marker = if dirty { "true" } else { "false" };
+    if let Err(e) = std::fs::write(LOCK_FILE, marker) {
+        return ErrorGitLock(format!("failed to create {LOCK_FILE}: {e}")).to_chain();
+    }
+
+    if let Err(e) = run_git(["add", "."]) {
+        return ErrorGitLock(e).to_chain();
+    }
+    if let Err(e) = run_git(["commit", "-m", CI_TEMP_COMMIT_MESSAGE]) {
+        return ErrorGitLock(e).to_chain();
+    }
+
+    ResultGitLock { dirty }.to_chain()
+}
+
+/// Whether the tree was dirty (a base `TEMP` commit exists) when locking.
+#[derive(Grouped)]
+pub struct ResultGitLock {
+    dirty: bool,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorGitLock(pub String);
+
+#[renderer(buffer)]
+pub fn render_git_lock(r: ResultGitLock) {
+    if r.dirty {
+        r_println!("Locked: dirty workspace committed for CI");
+    } else {
+        r_println!("Locked: clean workspace marked for CI");
+    }
+}
+
+#[renderer]
+pub fn render_error_git_lock(
+    e: ErrorGitLock,
+    error: &CargoError,
+    exit_code: &mut ResExitCode,
+) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![format!("Git-Lock: {}", e.0)]);
+    exit_code.exit_code = 1;
+    render_result
+}
diff --git a/dev/ci/src/cmd/cmd_git_unlock.rs b/dev/ci/src/cmd/cmd_git_unlock.rs
new file mode 100644
index 0000000..41efefc
--- /dev/null
+++ b/dev/ci/src/cmd/cmd_git_unlock.rs
@@ -0,0 +1,116 @@
+use mingling::{
+    Grouped, RenderResult, Routable,
+    macros::{arg, buffer, command, r_println, renderer},
+    picker::{EntryPicker, value::Flag},
+    res::ResExitCode,
+};
+
+use crate::git::{LOCK_FILE, TEMP_COMMIT_MARK, head_message, run_git, worktree_clean};
+use crate::res::{CargoError, MessagePrinter};
+use crate::{Entry, Next};
+
+/// Undoes a CI temporary commit created by [`crate::cmd::cmd_git_lock`].
+///
+/// Only acts when the HEAD commit message contains `CI TEMP` (case-sensitive).
+/// The restore path is picked by the marker file content:
+///
+/// - `true`: a base `TEMP` commit with the dirty changes sits below; restore
+///   by hard-resetting past the marker commit, then soft-resetting and
+///   unstaging to put the user's changes back into the working tree.
+/// - `false`: the tree was clean; a single hard reset back to the original
+///   HEAD is enough.
+///
+/// When the working tree is dirty (e.g. CI left tracked changes behind) the
+/// restore still runs, but the command reports a non-zero exit code so the
+/// caller knows the CI phase contaminated the repository. With `--show-diff`
+/// the diff of those changes is printed before they are discarded.
+#[command(node = "git-unlock")]
+// `#[command]` rewrites an owned first param into the entry type, so the args
+// must be passed by value even though the body only reads them.
+#[allow(clippy::needless_pass_by_value)]
+pub fn git_unlock(args: Entry) -> Next {
+    let head = head_message().unwrap_or_default();
+    if !head.contains(TEMP_COMMIT_MARK) {
+        return ErrorGitUnlock(format!("HEAD is not a CI temporary commit: `{head}`")).to_chain();
+    }
+
+    // Record dirtiness before restoring: the restore discards those changes.
+    let dirty = !worktree_clean();
+
+    // The marker file lives in the HEAD (CI TEMP) commit, so it is readable
+    // from the working tree; a missing marker falls back to the clean path.
+    let based_on_dirty =
+        std::fs::read_to_string(LOCK_FILE).is_ok_and(|content| content.trim() == "true");
+
+    if dirty && *args.pick(&arg![show_diff: Flag]).unwrap() {
+        show_diff();
+    }
+
+    if let Err(e) = undo_ci_phase(based_on_dirty) {
+        return ErrorGitUnlock(e).to_chain();
+    }
+
+    ResultGitUnlock { dirty }.to_chain()
+}
+
+/// Prints the tracked changes the CI run left behind, before the restore
+/// discards them. Untracked files are not shown (they are removed by clean).
+fn show_diff() {
+    let Ok(diff) = run_git(["diff", "HEAD"]) else {
+        return;
+    };
+    if diff.is_empty() {
+        return;
+    }
+    println!("{diff}");
+}
+
+/// Restores the workspace, keeping the user's pre-lock changes.
+///
+/// With a base `TEMP` commit (`true`) the marker commit is dropped by a hard
+/// reset to `HEAD~1`, the `TEMP` commit is unwrapped into the staging area by
+/// a soft reset, and a plain reset unstages it back into the working tree.
+/// Without one (`false`) a single hard reset to `HEAD~1` removes the marker
+/// commit and lands on the original HEAD.
+fn undo_ci_phase(based_on_dirty: bool) -> Result<(), String> {
+    run_git(["reset", "--hard", "HEAD~1"])?;
+    if based_on_dirty {
+        // Unwrap the `TEMP` commit into the staging area, then unstage it
+        // back into the working tree.
+        run_git(["reset", "--soft", "HEAD~1"])?;
+        run_git(["reset"])?;
+    }
+    std::fs::remove_file(LOCK_FILE).ok();
+    Ok(())
+}
+
+/// Whether the working tree was dirty when the unlock started.
+#[derive(Grouped)]
+pub struct ResultGitUnlock {
+    dirty: bool,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorGitUnlock(pub String);
+
+#[renderer(buffer)]
+pub fn render_git_unlock(r: ResultGitUnlock, exit_code: &mut ResExitCode) {
+    if r.dirty {
+        r_println!("Unlocked: workspace restored (working tree was dirty)");
+        exit_code.exit_code = 1;
+    } else {
+        r_println!("Unlocked: workspace restored");
+    }
+}
+
+#[renderer]
+pub fn render_error_git_unlock(
+    e: ErrorGitUnlock,
+    error: &CargoError,
+    exit_code: &mut ResExitCode,
+) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![format!("Git-Unlock: {}", e.0)]);
+    exit_code.exit_code = 1;
+    render_result
+}
diff --git a/dev/ci/src/cmd/cmd_report_clean.rs b/dev/ci/src/cmd/cmd_report_clean.rs
new file mode 100644
index 0000000..976851e
--- /dev/null
+++ b/dev/ci/src/cmd/cmd_report_clean.rs
@@ -0,0 +1,54 @@
+use std::path::PathBuf;
+
+use mingling::{
+    Grouped, RenderResult, Routable,
+    macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::reporter::{COLLECT_DIR, REPORT_PATH};
+use crate::res::{CargoError, MessagePrinter};
+
+/// Removes collected logs and the generated report.
+#[command(node = "report-clean")]
+pub fn report_clean() -> Next {
+    let mut removed = Vec::new();
+    for path in [PathBuf::from(COLLECT_DIR), PathBuf::from(REPORT_PATH)] {
+        match std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path)) {
+            Ok(()) => removed.push(path),
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+            Err(e) => {
+                return ErrorReportClean(format!("failed to remove {}: {e}", path.display()))
+                    .to_chain();
+            }
+        }
+    }
+    ResultReportClean { removed }.to_chain()
+}
+
+/// Paths removed by `report-clean`.
+#[derive(Grouped)]
+pub struct ResultReportClean {
+    pub removed: Vec,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorReportClean(pub String);
+
+#[renderer(buffer)]
+pub fn render_report_clean(r: ResultReportClean) {
+    if r.removed.is_empty() {
+        r_println!("Report data already clean");
+    } else {
+        for path in r.removed {
+            r_println!("Removed {}", path.display());
+        }
+    }
+}
+
+#[renderer]
+pub fn render_error_report_clean(e: ErrorReportClean, error: &CargoError) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![format!("Report: {}", e.0)]);
+    render_result
+}
diff --git a/dev/ci/src/cmd/cmd_report_collect.rs b/dev/ci/src/cmd/cmd_report_collect.rs
new file mode 100644
index 0000000..2eff074
--- /dev/null
+++ b/dev/ci/src/cmd/cmd_report_collect.rs
@@ -0,0 +1,150 @@
+use std::collections::{BTreeMap, HashMap};
+use std::path::PathBuf;
+
+use just_template::Template;
+use mingling::{
+    Grouped, RenderResult, Routable,
+    macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::reporter::{COLLECT_DIR, REPORT_PATH};
+use crate::res::{CargoError, MessagePrinter, ResCollectLogs};
+
+const REPORT_TEMPLATE: &str = include_str!("../../tmpls/report.md");
+const TASK_SECTION_TEMPLATE: &str = include_str!("../../tmpls/task_section.md");
+
+/// Maps a package to its per-OS pass/fail status.
+type OsStatuses = BTreeMap;
+
+/// A row in a task section: item name and its per-OS statuses.
+type TaskRow<'a> = (&'a String, &'a OsStatuses);
+
+/// Rows grouped by task name.
+type RowsByTask<'a> = BTreeMap<&'a String, Vec>>;
+
+#[command(node = "report-collect")]
+pub fn report_collect(logs: &ResCollectLogs) -> Next {
+    if !PathBuf::from(COLLECT_DIR).is_dir() {
+        return ErrorNoCollectDir.to_chain();
+    }
+
+    // Group rows by task: task -> [(item, os_statuses)].
+    let by_task: RowsByTask =
+        logs.statuses
+            .iter()
+            .fold(BTreeMap::new(), |mut acc, ((task, item), os_statuses)| {
+                acc.entry(task).or_default().push((item, os_statuses));
+                acc
+            });
+
+    // Render one section per task (table rows + this task's failures).
+    let mut fail_count = 0;
+    let mut sections: Vec> = Vec::new();
+    for (task, rows) in by_task {
+        let mut row_arms = Vec::new();
+        let mut fail_arms = Vec::new();
+        for (item, os_statuses) in rows {
+            let location = logs
+                .locations
+                .get(&(task.clone(), item.clone()))
+                .cloned()
+                .unwrap_or_default();
+            row_arms.push(HashMap::from([
+                ("item_name".to_string(), item.clone()),
+                ("location".to_string(), location),
+                (
+                    "pass_win".to_string(),
+                    pass_cell(os_statuses.get("Windows")),
+                ),
+                (
+                    "pass_linux".to_string(),
+                    pass_cell(os_statuses.get("Linux")),
+                ),
+                ("pass_mac".to_string(), pass_cell(os_statuses.get("MacOS"))),
+            ]));
+
+            for (os, ok) in os_statuses {
+                if !ok {
+                    let stdout = logs
+                        .err_outputs
+                        .get(&(task.clone(), os.clone(), item.clone()))
+                        .cloned()
+                        .unwrap_or_default();
+                    fail_arms.push(HashMap::from([
+                        ("item_name".to_string(), item.clone()),
+                        ("stdout".to_string(), stdout),
+                    ]));
+                    fail_count += 1;
+                }
+            }
+        }
+
+        let mut section = Template::from(TASK_SECTION_TEMPLATE);
+        section.insert_param("task_name".to_string(), task.clone());
+        *section.add_impl("rows".to_string()) = row_arms;
+        *section.add_impl("fails".to_string()) = fail_arms;
+        sections.push(HashMap::from([(
+            "section".to_string(),
+            section.expand().unwrap_or_default(),
+        )]));
+    }
+
+    let mut template = Template::from(REPORT_TEMPLATE);
+
+    template.insert_param("date".to_string(), logs.git.date.clone());
+    template.insert_param("commit_hash".to_string(), logs.git.commit_hash.clone());
+    *template.add_impl("task_sections".to_string()) = sections;
+
+    let expanded = template.expand().unwrap_or_default();
+    let output = PathBuf::from(REPORT_PATH);
+    let parent = output.parent().expect("output path has a parent");
+
+    if let Err(e) = std::fs::create_dir_all(parent).and_then(|()| std::fs::write(&output, expanded))
+    {
+        return ErrorReportWrite(format!("failed to write {}: {e}", output.display())).to_chain();
+    }
+
+    ResultCollectResults { output, fail_count }.to_chain()
+}
+
+fn pass_cell(status: Option<&bool>) -> String {
+    match status {
+        Some(true) => "✅".to_string(),
+        Some(false) => "❌".to_string(),
+        None => "—".to_string(),
+    }
+}
+
+/// The generated report.
+#[derive(Grouped)]
+pub struct ResultCollectResults {
+    pub output: PathBuf,
+    pub fail_count: usize,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorNoCollectDir;
+
+#[derive(Grouped, Default)]
+pub struct ErrorReportWrite(pub String);
+
+#[renderer(buffer)]
+pub fn render_collect_results(r: ResultCollectResults) {
+    r_println!("Collected {} failing logs", r.fail_count);
+    r_println!("Report generated at {}", r.output.display());
+}
+
+#[renderer]
+pub fn render_error_no_collect_dir(_: ErrorNoCollectDir, error: &CargoError) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![format!("No collect directory: {COLLECT_DIR}")]);
+    render_result
+}
+
+#[renderer]
+pub fn render_error_report_write(e: ErrorReportWrite, error: &CargoError) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![format!("Report: {}", e.0)]);
+    render_result
+}
diff --git a/dev/ci/src/cmd/cmd_show_features.rs b/dev/ci/src/cmd/cmd_show_features.rs
new file mode 100644
index 0000000..5fff0c5
--- /dev/null
+++ b/dev/ci/src/cmd/cmd_show_features.rs
@@ -0,0 +1,26 @@
+use mingling::{
+    Grouped,
+    macros::{buffer, command, r_println, renderer},
+};
+
+use crate::res::ResFeatureList;
+
+#[command(node = "show-features")]
+pub fn show_features(features: &ResFeatureList) -> ResultShowFeatures {
+    ResultShowFeatures {
+        features: features.list.clone(),
+    }
+}
+
+/// The docs.rs feature list of `mingling`.
+#[derive(Grouped)]
+pub struct ResultShowFeatures {
+    pub features: Vec,
+}
+
+#[renderer(buffer)]
+pub fn render_show_features(r: ResultShowFeatures) {
+    for feature in r.features {
+        r_println!("{feature}");
+    }
+}
diff --git a/dev/ci/src/cmd/cmd_show_manifests.rs b/dev/ci/src/cmd/cmd_show_manifests.rs
new file mode 100644
index 0000000..2be82d2
--- /dev/null
+++ b/dev/ci/src/cmd/cmd_show_manifests.rs
@@ -0,0 +1,71 @@
+use std::path::PathBuf;
+
+use mingling::{
+    Grouped,
+    macros::{buffer, command, r_println, renderer},
+};
+
+use prettytable::{
+    Cell, Row, Table,
+    format::{FormatBuilder, LinePosition, LineSeparator},
+};
+
+use crate::res::Manifests;
+
+#[command(node = "show-manifests")]
+pub fn show_manifests(manifests: &Manifests) -> ResultPrintManifests {
+    let mut entries: Vec = manifests
+        .package_dirs
+        .iter()
+        .map(|(name, path)| ManifestEntry {
+            name: name.clone(),
+            path: path.clone(),
+        })
+        .collect();
+    entries.sort_by(|a, b| a.path.cmp(&b.path));
+    ResultPrintManifests { entries }
+}
+
+/// All manifests the CI will check, sorted by path.
+#[derive(Grouped)]
+pub struct ResultPrintManifests {
+    pub entries: Vec,
+}
+
+#[derive(Debug, Clone)]
+pub struct ManifestEntry {
+    pub name: String,
+    pub path: PathBuf,
+}
+
+#[renderer(buffer)]
+pub fn render_print_manifests(r: ResultPrintManifests) {
+    let mut table = Table::new();
+
+    table.set_format(
+        FormatBuilder::new()
+            .column_separator('│')
+            .borders('│')
+            .separator(LinePosition::Top, LineSeparator::new('─', '┬', '┌', '┐'))
+            .separator(LinePosition::Title, LineSeparator::new('─', '┼', '├', '┤'))
+            .separator(LinePosition::Bottom, LineSeparator::new('─', '┴', '└', '┘'))
+            .padding(1, 1)
+            .build(),
+    );
+
+    table.set_titles(Row::new(vec![
+        Cell::new("#"),
+        Cell::new("Package-Name"),
+        Cell::new("Package-Path"),
+    ]));
+
+    for (index, entry) in r.entries.iter().enumerate() {
+        table.add_row(Row::new(vec![
+            Cell::new(&(index + 1).to_string()),
+            Cell::new(&entry.name),
+            Cell::new(&entry.path.to_string_lossy()),
+        ]));
+    }
+
+    r_println!("{table}");
+}
diff --git a/dev/ci/src/examples.rs b/dev/ci/src/examples.rs
new file mode 100644
index 0000000..92d3475
--- /dev/null
+++ b/dev/ci/src/examples.rs
@@ -0,0 +1,186 @@
+//! Example binary testing: build each example and run its `test.toml` cases.
+
+use std::process::Output;
+
+/// A single `[[runs]]` entry of an example's `test.toml`.
+pub(crate) struct TestCase {
+    input: Vec,
+    expect: Expect,
+}
+
+struct Expect {
+    exit_code: i32,
+    result: String,
+}
+
+/// One example and its test cases.
+pub(crate) struct ExampleCase {
+    name: String,
+    cases: Vec,
+}
+
+/// Outcome of checking one example.
+pub(crate) struct ExampleOutcome {
+    pub name: String,
+    pub location: String,
+    pub ok: bool,
+    pub output: String,
+}
+
+/// Loads `examples//test.toml` for every example that has one, in
+/// alphabetical order of the example directory name.
+pub(crate) fn load_test_configs() -> Vec {
+    let mut configs = Vec::new();
+    if let Ok(entries) = std::fs::read_dir("examples") {
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if !path.is_dir() {
+                continue;
+            }
+            let test_toml = path.join("test.toml");
+            if !test_toml.is_file() {
+                continue;
+            }
+            let name = path
+                .file_name()
+                .and_then(|n| n.to_str())
+                .unwrap_or_default()
+                .to_string();
+            let Ok(content) = std::fs::read_to_string(&test_toml) else {
+                continue;
+            };
+            let Ok(table) = content.parse::() else {
+                continue;
+            };
+            let Some(cases) = parse_cases(&table) else {
+                continue;
+            };
+            configs.push(ExampleCase { name, cases });
+        }
+    }
+    configs.sort_by(|a, b| a.name.cmp(&b.name));
+    configs
+}
+
+fn parse_cases(table: &toml::Value) -> Option> {
+    let runs = table.get("runs")?.as_array()?;
+    let mut cases = Vec::new();
+    for run in runs {
+        let input: Vec = run
+            .get("input")?
+            .as_array()?
+            .iter()
+            .filter_map(|v| v.as_str().map(str::to_string))
+            .collect();
+        let expect = run.get("expect")?;
+        let exit_code = expect
+            .get("exit-code")?
+            .as_integer()
+            .and_then(|e| i32::try_from(e).ok())
+            .unwrap_or(-1);
+        let result = expect
+            .get("result")
+            .and_then(|r| r.as_str())
+            .unwrap_or_default()
+            .to_string();
+        cases.push(TestCase {
+            input,
+            expect: Expect { exit_code, result },
+        });
+    }
+    Some(cases)
+}
+
+/// Builds the example, then runs all of its test cases.
+pub(crate) fn check_example(example: ExampleCase) -> ExampleOutcome {
+    let location = format!("./examples/{}", example.name);
+
+    // Phase 1: build.
+    let manifest = format!("examples/{}/Cargo.toml", example.name);
+    let build = std::process::Command::new("cargo")
+        .args(["build", "--manifest-path", &manifest])
+        .output();
+    match build {
+        Ok(output) if !output.status.success() => ExampleOutcome {
+            name: example.name,
+            location,
+            ok: false,
+            output: build_error(&output),
+        },
+        Err(e) => ExampleOutcome {
+            name: example.name,
+            location,
+            ok: false,
+            output: format!("failed to run cargo: {e}"),
+        },
+        Ok(_) => {
+            // Phase 2: run the test cases against the built binary.
+            let mut failures = Vec::new();
+            for case in &example.cases {
+                if let Err(detail) = run_case(&example.name, case) {
+                    failures.push(detail);
+                }
+            }
+            ExampleOutcome {
+                name: example.name,
+                location,
+                ok: failures.is_empty(),
+                output: failures.join("\n\n"),
+            }
+        }
+    }
+}
+
+/// Runs a single test case against the built binary.
+fn run_case(name: &str, case: &TestCase) -> Result<(), String> {
+    let exe = if cfg!(target_os = "windows") {
+        ".exe"
+    } else {
+        ""
+    };
+    let binary = format!(".temp/target/debug/{name}{exe}");
+
+    let output = std::process::Command::new(&binary)
+        .args(&case.input)
+        .output();
+    let Ok(output) = output else {
+        return Err(format!("failed to run {binary}"));
+    };
+
+    let actual_exit_code = output.status.code().unwrap_or(-1);
+    let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+    let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+
+    let exit_ok = actual_exit_code == case.expect.exit_code;
+    let result_ok =
+        actual_stdout == case.expect.result || actual_stdout.contains(&case.expect.result);
+
+    if exit_ok && result_ok {
+        return Ok(());
+    }
+
+    let mut details = vec![format!("input: {}", case.input.join(" "))];
+    if !exit_ok {
+        details.push(format!(
+            "expected exit code {}, actual {actual_exit_code}",
+            case.expect.exit_code
+        ));
+    }
+    if !result_ok {
+        details.push(format!("expected output {:?}", case.expect.result));
+        details.push(format!("actual stdout {actual_stdout:?}"));
+        if !actual_stderr.is_empty() {
+            details.push(format!("actual stderr {actual_stderr:?}"));
+        }
+    }
+    Err(details.join("\n"))
+}
+
+/// Tail of a failed build's combined output.
+fn build_error(output: &Output) -> String {
+    let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+    log.push_str(&String::from_utf8_lossy(&output.stderr));
+    let lines: Vec<&str> = log.lines().collect();
+    let tail = &lines[lines.len().saturating_sub(20)..];
+    format!("build failed\n{}", tail.join("\n"))
+}
diff --git a/dev/ci/src/git.rs b/dev/ci/src/git.rs
new file mode 100644
index 0000000..a6fab2c
--- /dev/null
+++ b/dev/ci/src/git.rs
@@ -0,0 +1,69 @@
+//! Thin wrappers around the `git` CLI used by the CI phase lock/unlock pair.
+
+use std::ffi::OsStr;
+use std::process::Command;
+
+/// Marker file created by `git-lock` in the CI temporary commit; its content
+/// is `true` when the tree was dirty (a base TEMP commit exists below) or
+/// `false` when it was clean. `git-unlock` reads it to pick the restore path.
+pub(crate) const LOCK_FILE: &str = "MINGLING-CI-CHECKING";
+
+/// First temporary commit: packs the dirty workspace changes so they can be
+/// restored later. Only created when the tree is dirty.
+pub(crate) const TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] TEMP [DO NOT PUSH]";
+
+/// Second temporary commit: carries the marker file, and its message is what
+/// `git-unlock` matches to confirm the CI phase.
+pub(crate) const CI_TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] CI TEMP [DO NOT PUSH]";
+
+/// Case-sensitive substring that identifies a CI temporary commit in the HEAD
+/// commit message.
+pub(crate) const TEMP_COMMIT_MARK: &str = "CI TEMP";
+
+/// Runs `git `, returning stdout on success.
+///
+/// # Errors
+///
+/// Returns the git error message (stderr) when the command exits non-zero, or
+/// when git itself cannot be spawned.
+pub(crate) fn run_git(args: I) -> Result
+where
+    I: IntoIterator,
+    S: AsRef,
+{
+    let output = Command::new("git")
+        .args(args)
+        .output()
+        .map_err(|e| format!("failed to run git: {e}"))?;
+    if output.status.success() {
+        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+    } else {
+        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
+    }
+}
+
+/// Returns `true` when the working tree has no tracked changes relative to
+/// HEAD. Git failures count as "not clean" so the caller falls back to the
+/// marker-file path.
+///
+/// Uses the porcelain `git diff --quiet HEAD` rather than the plumbing
+/// `git diff-index --quiet HEAD`: after a full compile the source files'
+/// mtimes can be newer than the index stat records even though their content
+/// is unchanged, and `diff-index` reports that stale stat as a change. The
+/// porcelain diff refreshes the index first (via `diff.autoRefreshIndex`),
+/// so it only reports real content differences.
+pub(crate) fn worktree_clean() -> bool {
+    Command::new("git")
+        .args(["diff", "--quiet", "HEAD", "--"])
+        .status()
+        .is_ok_and(|status| status.success())
+}
+
+/// The subject line of the HEAD commit.
+///
+/// # Errors
+///
+/// Returns the git error message when the log command fails.
+pub(crate) fn head_message() -> Result {
+    run_git(["log", "-1", "--pretty=%s"]).map(|subject| subject.trim().to_string())
+}
diff --git a/dev/ci/src/lib.rs b/dev/ci/src/lib.rs
new file mode 100644
index 0000000..32a0cbd
--- /dev/null
+++ b/dev/ci/src/lib.rs
@@ -0,0 +1,28 @@
+#![deny(clippy::pedantic)]
+#![deny(clippy::nursery)]
+#![allow(clippy::redundant_pub_crate)]
+#![allow(clippy::missing_const_for_fn)]
+
+use mingling::macros::{gen_program, help};
+
+pub(crate) mod cmd;
+pub(crate) mod git;
+pub(crate) mod task;
+
+/// Mingling CI's Resources
+pub mod res;
+
+/// Log exporter for CI reports
+pub mod reporter;
+
+pub(crate) mod examples;
+pub(crate) mod markdown;
+pub(crate) mod progress;
+pub(crate) mod tools;
+
+#[help]
+pub fn render_fallback(_: EntryFallback) -> String {
+    include_str!("../help.txt").to_string()
+}
+
+gen_program!();
diff --git a/dev/ci/src/markdown.rs b/dev/ci/src/markdown.rs
new file mode 100644
index 0000000..75f2cbe
--- /dev/null
+++ b/dev/ci/src/markdown.rs
@@ -0,0 +1,3 @@
+pub(crate) mod compare;
+pub(crate) mod project;
+pub(crate) mod test;
diff --git a/dev/ci/src/markdown/compare.rs b/dev/ci/src/markdown/compare.rs
new file mode 100644
index 0000000..1bf3c57
--- /dev/null
+++ b/dev/ci/src/markdown/compare.rs
@@ -0,0 +1,203 @@
+//! Structural comparison of markdown docs (reference vs translation).
+//!
+//! For each file pair the comparison uses a *structural signature*: one token
+//! per line, classifying headings (both Markdown `#` and HTML ``), fenced
+//! code blocks (including their language tag), `@@@` hidden-compilation lines,
+//! blank lines, blockquotes, lists and plain text. Translated text is allowed
+//! to differ; the structure is not.
+
+use std::path::{Path, PathBuf};
+
+/// Collects all `.md` files under `dir`, returned relative to it.
+pub(crate) fn collect_md_files(dir: &Path) -> Vec {
+    let mut out = Vec::new();
+    let mut stack = vec![dir.to_path_buf()];
+    while let Some(current) = stack.pop() {
+        let Ok(entries) = std::fs::read_dir(¤t) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if path.is_dir() {
+                stack.push(path);
+            } else if path.extension().is_some_and(|e| e == "md") {
+                out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf());
+            }
+        }
+    }
+    out.sort();
+    out
+}
+
+/// Compares the structural signatures of two markdown files.
+///
+/// Returns the human-readable diff lines (up to a small window) on the first
+/// structural difference.
+pub(crate) fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec> {
+    let ref_content = std::fs::read_to_string(ref_path).unwrap_or_default();
+    let lang_content = std::fs::read_to_string(lang_path).unwrap_or_default();
+
+    let ref_sig = signature_of(&ref_content);
+    let lang_sig = signature_of(&lang_content);
+
+    if ref_sig == lang_sig {
+        return Ok(());
+    }
+
+    let ref_lines: Vec<&str> = ref_content.lines().collect();
+    let lang_lines: Vec<&str> = lang_content.lines().collect();
+
+    let mut diffs = Vec::new();
+    let mut window = 0;
+    let max = ref_sig.len().max(lang_sig.len());
+    for i in 0..max {
+        let ref_tok = ref_sig.get(i);
+        let lang_tok = lang_sig.get(i);
+        if ref_tok == lang_tok {
+            continue;
+        }
+        if window >= 5 {
+            diffs.push(format!("... ({}-line window truncated)", max - i));
+            break;
+        }
+        window += 1;
+        let ref_line = ref_lines.get(i).copied().unwrap_or("");
+        let lang_line = lang_lines.get(i).copied().unwrap_or("");
+        diffs.push(format!("line {}", i + 1));
+        diffs.push(format!(
+            "expect `{}` {}",
+            token_label(ref_tok.map_or("", String::as_str)),
+            display_line(ref_line)
+        ));
+        diffs.push(format!(
+            "found  `{}` {}",
+            token_label(lang_tok.map_or("", String::as_str)),
+            display_line(lang_line)
+        ));
+        if ref_sig.len() != lang_sig.len() && window >= 5 {
+            diffs.push(format!(
+                "note: reference has {} lines, translation has {} lines",
+                ref_sig.len(),
+                lang_sig.len()
+            ));
+            break;
+        }
+    }
+    if diffs.is_empty() {
+        diffs.push("signatures differ in length (see line count note)".to_string());
+    }
+    Err(diffs)
+}
+
+/// Builds the structural signature of a markdown file.
+fn signature_of(content: &str) -> Vec {
+    let mut sig = Vec::new();
+    let mut in_fence = false;
+    let mut fence_lang = String::new();
+
+    for raw_line in content.lines() {
+        let line = raw_line.trim();
+
+        if in_fence {
+            if line.starts_with("```") {
+                in_fence = false;
+                sig.push(format!("F:{fence_lang}"));
+            } else if line.starts_with("@@@") {
+                sig.push("A".to_string());
+            } else if line.is_empty() {
+                sig.push("B".to_string());
+            } else {
+                sig.push("P".to_string());
+            }
+            continue;
+        }
+
+        if line.starts_with("```") {
+            in_fence = true;
+            fence_lang = line.trim_start_matches("```").trim().to_string();
+            sig.push(format!("F:{fence_lang}"));
+        } else if line.starts_with('#') {
+            let level = line.chars().take_while(|c| *c == '#').count();
+            sig.push(format!("H{level}"));
+        } else if line.starts_with("` / ``)
+            let level = line
+                .trim_start_matches(['<', '/'])
+                .chars()
+                .next()
+                .and_then(|c| c.to_digit(10))
+                .unwrap_or(1);
+            sig.push(format!("H{level}"));
+        } else if line.starts_with("@@@") {
+            sig.push("A".to_string());
+        } else if line.is_empty() {
+            sig.push("B".to_string());
+        } else if line.starts_with('>') {
+            sig.push("Q".to_string());
+        } else if is_list_line(line) {
+            sig.push("L".to_string());
+        } else {
+            sig.push("P".to_string());
+        }
+    }
+    sig
+}
+
+/// Human-readable label for a structural token.
+fn token_label(token: &str) -> String {
+    match token {
+        "B" => "blank".to_string(),
+        "A" => "@@@".to_string(),
+        "Q" => "quote".to_string(),
+        "L" => "list".to_string(),
+        "P" => "text".to_string(),
+        t if t.starts_with('H') => format!("heading-{}", &t[1..]),
+        t if t.starts_with("F:") => {
+            let lang = &t[2..];
+            if lang.is_empty() {
+                "fence".to_string()
+            } else {
+                format!("fence:{lang}")
+            }
+        }
+        _ => token.to_string(),
+    }
+}
+
+/// Renders a source line for display: blank lines become ``.
+fn display_line(line: &str) -> String {
+    if line.trim().is_empty() {
+        "".to_string()
+    } else {
+        truncate(line)
+    }
+}
+
+fn truncate(line: &str) -> String {
+    const MAX: usize = 60;
+    if line.chars().count() <= MAX {
+        line.to_string()
+    } else {
+        let cut: String = line.chars().take(MAX).collect();
+        format!("{cut}...")
+    }
+}
+
+fn is_list_line(line: &str) -> bool {
+    let trimmed = line.trim_start();
+    trimmed.starts_with("- ")
+        || trimmed.starts_with("* ")
+        || trimmed.starts_with("+ ")
+        || is_numbered_list(trimmed)
+}
+
+/// A numbered list item: `1. text`, `1) text`, `10. text`, ...
+fn is_numbered_list(line: &str) -> bool {
+    let digit_count = line.chars().take_while(char::is_ascii_digit).count();
+    if digit_count == 0 {
+        return false;
+    }
+    let rest = &line[digit_count..];
+    (rest.starts_with(". ") || rest.starts_with(") "))
+        && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t')
+}
diff --git a/dev/ci/src/markdown/project.rs b/dev/ci/src/markdown/project.rs
new file mode 100644
index 0000000..d781b7e
--- /dev/null
+++ b/dev/ci/src/markdown/project.rs
@@ -0,0 +1,347 @@
+//! Model of a testable rust code block extracted from markdown: its dependency
+//! configuration (features + deps) and the code itself.
+
+use std::fmt::Write as _;
+use std::path::Path;
+
+/// A single testable `rust` code block, modeled as a test project.
+pub(crate) struct MarkdownTestProject {
+    pub features: Vec,
+    pub deps: Vec<(String, String)>,
+    pub code: String,
+    pub is_build_time: bool,
+    pub has_main: bool,
+    pub has_gen_program: bool,
+    pub source_file: String,
+    pub line: usize,
+}
+
+impl MarkdownTestProject {
+    /// FNV-1a 64-bit hash over the dependency configuration (features + deps).
+    ///
+    /// Blocks with the same hash share one temporary crate and avoid redundant
+    /// recompilation. The input is sorted so the hash is stable.
+    #[must_use]
+    pub fn compute_hash(&self) -> String {
+        let mut features: Vec<&str> = self.features.iter().map(String::as_str).collect();
+        features.sort_unstable();
+        let mut dep_names: Vec<&str> = self.deps.iter().map(|(n, _)| n.as_str()).collect();
+        dep_names.sort_unstable();
+        let mut dep_versions: Vec<&str> = self.deps.iter().map(|(_, v)| v.as_str()).collect();
+        dep_versions.sort_unstable();
+        let mut deps: Vec = self.deps.iter().map(|(n, v)| format!("{n}={v}")).collect();
+        deps.sort();
+
+        let canonical = format!(
+            "{}\n{}\n{}\n{}",
+            features.join(","),
+            dep_names.join(","),
+            dep_versions.join(","),
+            deps.join(",")
+        );
+
+        // FNV-1a 64-bit — stable across runs (no random seed).
+        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+        for &byte in canonical.as_bytes() {
+            hash ^= u64::from(byte);
+            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+        }
+        format!("{hash:016x}")
+    }
+}
+
+/// Parses all fenced `rust` blocks from markdown content.
+///
+/// Blocks marked `// NOT VERIFIED` are skipped.
+pub(crate) fn parse_markdown(content: &str, source_file: &str) -> Vec {
+    let mut projects = Vec::new();
+    let lines: Vec<&str> = content.lines().collect();
+    let mut i = 0;
+    while i < lines.len() {
+        if lines[i].trim() == "```rust" {
+            if let Some(proj) = parse_block(&lines, i, source_file) {
+                projects.push(proj);
+            }
+            while i < lines.len() && lines[i].trim() != "```" {
+                i += 1;
+            }
+        }
+        i += 1;
+    }
+    projects
+}
+
+/// Parses a single code block starting at a `rust` fence line.
+fn parse_block(lines: &[&str], start: usize, source_file: &str) -> Option {
+    let mut code_lines: Vec = Vec::new();
+    let mut features: Vec = Vec::new();
+    let mut not_verified = false;
+    let mut deps: Vec<(String, String)> = Vec::new();
+    let mut has_main = false;
+    let mut has_gen_program = false;
+    let mut is_build_time = false;
+
+    let mut idx = start + 1;
+    let mut in_header = true;
+
+    while idx < lines.len() {
+        let raw_line = lines[idx];
+        let trimmed = raw_line.trim();
+
+        if trimmed == "```" {
+            break;
+        }
+
+        // `@@@` lines: hidden in the rendered docs (filtered by a docsify
+        // plugin) but must still compile.
+        if let Some(stripped) = trimmed.strip_prefix("@@@") {
+            in_header = false;
+            let code = stripped.trim_start();
+            if code.contains("fn main") {
+                has_main = true;
+            }
+            if code.contains("gen_program!") {
+                has_gen_program = true;
+            }
+            code_lines.push(code.to_string());
+            idx += 1;
+            continue;
+        }
+
+        if in_header && trimmed == "// NOT VERIFIED" {
+            not_verified = true;
+            idx += 1;
+            continue;
+        }
+        if in_header && trimmed == "// BUILD TIME" {
+            is_build_time = true;
+            idx += 1;
+            continue;
+        }
+        if in_header && trimmed.starts_with("// ") {
+            if let Some(feat_str) = trimmed.strip_prefix("// Features:") {
+                let feat_str = feat_str.trim();
+                if feat_str.starts_with('[') && feat_str.ends_with(']') {
+                    let inner = &feat_str[1..feat_str.len() - 1];
+                    if !inner.is_empty() {
+                        features = inner
+                            .split(',')
+                            .map(|s| s.trim().trim_matches('"').to_string())
+                            .filter(|s| !s.is_empty())
+                            .collect();
+                    }
+                }
+                idx += 1;
+                continue;
+            }
+            if trimmed == "// Dependencies:" {
+                idx += 1;
+                while idx < lines.len() {
+                    let next = lines[idx].trim();
+                    if next == "```" {
+                        break;
+                    }
+                    if let Some(dep_line) = next.strip_prefix("// ") {
+                        if let Some((name, ver)) = dep_line.split_once(" = ") {
+                            deps.push((
+                                name.trim().to_string(),
+                                ver.trim().trim_matches('"').to_string(),
+                            ));
+                        }
+                        idx += 1;
+                    } else {
+                        break;
+                    }
+                }
+                continue;
+            }
+        }
+
+        in_header = false;
+        if raw_line.contains("fn main") {
+            has_main = true;
+        }
+        if raw_line.contains("gen_program!") {
+            has_gen_program = true;
+        }
+        code_lines.push(raw_line.to_string());
+        idx += 1;
+    }
+
+    if code_lines.is_empty() || not_verified {
+        return None;
+    }
+
+    Some(MarkdownTestProject {
+        features,
+        deps,
+        code: code_lines.join("\n"),
+        is_build_time,
+        has_main,
+        has_gen_program,
+        source_file: source_file.to_string(),
+        line: start + 1,
+    })
+}
+
+/// Builds the extra `[dependencies]` entries declared by a block's
+/// `// Dependencies:` header comments.
+///
+/// Markdown blocks declare companion crates like this:
+///
+/// ```text
+/// // Dependencies:
+/// // serde = "1"
+/// // clap = "4"
+/// // tokio = { version = "1", features = ["full"] }
+/// ```
+///
+/// Each `name = value` pair becomes one dependency of the generated test
+/// crate (in addition to `mingling` itself), so doc blocks can freely use
+/// external crates without repeating the whole manifest.
+///
+/// # Special case: serde / clap
+///
+/// Doc blocks pervasively derive serialization and argument parsing:
+/// structural-renderer examples use `#[derive(Serialize)]`, the clap examples
+/// use `#[derive(Parser)]` — and those derives live behind the `derive`
+/// feature of `serde` / `clap`. Requiring every block to spell out
+/// `// serde = { version = "1", features = ["derive"] }` would be
+/// boilerplate repeated dozens of times, so the two crates automatically get
+/// `features = ["derive"]` appended.
+///
+/// Version values starting with `{` are inline tables (e.g. `tokio` with a
+/// `features` list above) and are passed through verbatim — they already
+/// carry their own features and must not be rewritten.
+fn build_extra_deps(proj: &MarkdownTestProject) -> String {
+    let mut extra_deps = String::new();
+    for (name, version) in &proj.deps {
+        if version.starts_with('{') {
+            // Inline table (path/features/…): the block already expressed its
+            // full dependency, so emit it unchanged.
+            let _ = writeln!(extra_deps, "{name} = {version}");
+        } else if name == "serde" || name == "clap" {
+            // serde/clap derive: `#[derive(Serialize, Deserialize)]` and
+            // `#[derive(Parser)]` are used everywhere in the docs; auto-enable
+            // the `derive` feature to keep blocks terse.
+            let _ = writeln!(
+                extra_deps,
+                "{name} = {{ version = \"{version}\", features = [\"derive\"] }}"
+            );
+        } else {
+            // Plain `name = "version"`.
+            let _ = writeln!(extra_deps, "{name} = \"{version}\"");
+        }
+    }
+    extra_deps
+}
+
+/// Generates the `Cargo.toml` for a project.
+///
+/// `manifest_path` is used to compute the relative path to the `mingling` crate.
+pub(crate) fn generate_cargo_toml(proj: &MarkdownTestProject, manifest_path: &Path) -> String {
+    let features_str = if proj.features.is_empty() {
+        String::new()
+    } else {
+        let feats: Vec = proj.features.iter().map(|f| format!("\"{f}\"")).collect();
+        format!("features = [{}]", feats.join(", "))
+    };
+
+    let extra_deps = build_extra_deps(proj);
+
+    let mingling_path = find_mingling_relative_path(manifest_path);
+    let deps_section = if proj.features.is_empty() {
+        format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}")
+    } else {
+        format!(
+            "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}"
+        )
+    };
+
+    // Build-time projects mirror the features into [build-dependencies] so
+    // build.rs sees the same feature set.
+    let build_deps_section = if proj.is_build_time {
+        let feats: Vec = proj.features.iter().map(|f| format!("\"{f}\"")).collect();
+        let build_feats = if feats.is_empty() {
+            String::new()
+        } else {
+            format!("features = [{}]", feats.join(", "))
+        };
+        format!(
+            "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n"
+        )
+    } else {
+        String::new()
+    };
+
+    format!(
+        r#"[package]
+	name = "test-doc"
+	version = "0.0.0"
+	edition = "2024"
+
+{deps_section}{build_deps_section}
+[workspace]
+"#
+    )
+}
+
+/// Computes the relative path from a manifest's parent directory to `mingling`.
+///
+/// The process current directory is expected to be the project root.
+fn find_mingling_relative_path(manifest_path: &Path) -> String {
+    let manifest_dir = manifest_path
+        .parent()
+        .expect("manifest path has no parent directory");
+    let cwd = std::env::current_dir().expect("failed to get current directory");
+
+    let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir);
+    let depth = relative_to_root.components().count();
+
+    let mut result = String::new();
+    for _ in 0..depth {
+        result.push_str("../");
+    }
+    result.push_str("mingling");
+    result
+}
+
+/// Generates `main.rs` for a project.
+///
+/// Automatically prepends `use mingling::prelude::*;` and appends `fn main() {}`
+/// and `gen_program!()` when the block does not provide them.
+pub(crate) fn generate_main_rs(proj: &MarkdownTestProject) -> String {
+    let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
+
+    if !proj.code.contains("use mingling::prelude::*;") {
+        output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n");
+    }
+    output.push_str(&proj.code);
+    output.push('\n');
+
+    if !proj.has_main {
+        output.push_str("\nfn main() {}\n");
+    }
+    if !proj.has_gen_program {
+        output.push_str("\nmingling::macros::gen_program!();\n");
+    }
+    output
+}
+
+/// Generates `build.rs` for a build-time project: the code wrapped in
+/// `fn main() { }` unless the block already provides one.
+pub(crate) fn generate_build_rs(proj: &MarkdownTestProject) -> String {
+    let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
+    if proj.has_main {
+        output.push_str(&proj.code);
+    } else {
+        output.push_str("fn main() {\n");
+        for line in proj.code.lines() {
+            output.push_str("    ");
+            output.push_str(line);
+            output.push('\n');
+        }
+        output.push_str("}\n");
+    }
+    output
+}
diff --git a/dev/ci/src/markdown/test.rs b/dev/ci/src/markdown/test.rs
new file mode 100644
index 0000000..8ecf18d
--- /dev/null
+++ b/dev/ci/src/markdown/test.rs
@@ -0,0 +1,152 @@
+//! Parallel execution of markdown test projects.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use colored::Colorize;
+
+use crate::progress::task_progress_bar;
+
+use super::project::{
+    MarkdownTestProject, generate_build_rs, generate_cargo_toml, generate_main_rs,
+};
+
+/// Temporary root for the generated test crates.
+const TEMP_BASE: &str = ".temp/doc-test";
+
+/// Outcome of testing one code block.
+pub(crate) struct MarkdownBlockOutcome {
+    pub source_file: String,
+    pub line: usize,
+    pub ok: bool,
+    /// Failure detail; empty when `ok`.
+    pub output: String,
+}
+
+/// Runs the given projects in parallel.
+///
+/// Projects sharing a dependency hash share one temporary crate (written
+/// serially within the group); groups run in parallel. Progress is shown on
+/// stderr; failures print there too. Returns one outcome per block.
+pub(crate) async fn try_test_markdown_project(
+    projs: Vec,
+) -> Vec {
+    // Group by dependency hash for crate sharing.
+    let mut groups: BTreeMap> = BTreeMap::new();
+    for proj in projs {
+        groups.entry(proj.compute_hash()).or_default().push(proj);
+    }
+
+    let total: usize = groups.values().map(Vec::len).sum();
+    let pb = task_progress_bar(total, "Testing");
+    pb.set_message("blocks");
+
+    // One blocking task per group; blocks within a group are serial because
+    // they share the same crate directory.
+    let mut handles = Vec::new();
+    for (hash, blocks) in groups {
+        let pb = pb.clone();
+        handles.push(tokio::task::spawn_blocking(move || {
+            let crate_dir = PathBuf::from(TEMP_BASE).join(&hash);
+            let src_dir = crate_dir.join("src");
+            let manifest_path = crate_dir.join("Cargo.toml");
+            let cargo_toml = generate_cargo_toml(&blocks[0], &manifest_path);
+
+            let mut group_outcomes = Vec::new();
+            for proj in &blocks {
+                let label = format!("{}:{}", proj.source_file, proj.line);
+                pb.set_message(label.clone());
+
+                let main_rs = if proj.is_build_time {
+                    generate_build_rs(proj)
+                } else {
+                    generate_main_rs(proj)
+                };
+                let (ok, err) = build_block(
+                    &src_dir,
+                    &manifest_path,
+                    &cargo_toml,
+                    &main_rs,
+                    proj.is_build_time,
+                );
+                pb.inc(1);
+
+                if !ok {
+                    // Plain stderr: `pb.println` is swallowed on non-TTY (CI).
+                    eprintln!("  {} {label}", "failed".bold().bright_red());
+                    eprintln!("  {label} FAILED:\n{err}");
+                }
+                group_outcomes.push(MarkdownBlockOutcome {
+                    source_file: proj.source_file.clone(),
+                    line: proj.line,
+                    ok,
+                    output: err,
+                });
+            }
+            group_outcomes
+        }));
+    }
+
+    let mut all_outcomes = Vec::new();
+    for handle in handles {
+        if let Ok(group_outcomes) = handle.await {
+            all_outcomes.extend(group_outcomes);
+        }
+    }
+
+    pb.finish_and_clear();
+    all_outcomes
+}
+
+/// Writes the temporary crate files and runs `cargo check`.
+///
+/// When `is_build_time` is true, the content goes to `build.rs` with a stub
+/// `main.rs`; otherwise it goes to `src/main.rs`.
+fn build_block(
+    src_dir: &Path,
+    manifest_path: &Path,
+    cargo_toml: &str,
+    content: &str,
+    is_build_time: bool,
+) -> (bool, String) {
+    if let Err(e) = std::fs::create_dir_all(src_dir) {
+        return (false, format!("mkdir: {e}"));
+    }
+    if let Err(e) = std::fs::write(manifest_path, cargo_toml) {
+        return (false, format!("write Cargo.toml: {e}"));
+    }
+
+    if is_build_time {
+        let crate_dir = manifest_path
+            .parent()
+            .expect("manifest path has a parent directory");
+        if let Err(e) = std::fs::write(crate_dir.join("build.rs"), content) {
+            return (false, format!("write build.rs: {e}"));
+        }
+        if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") {
+            return (false, format!("write main.rs: {e}"));
+        }
+    } else if let Err(e) = std::fs::write(src_dir.join("main.rs"), content) {
+        return (false, format!("write main.rs: {e}"));
+    }
+
+    let output = std::process::Command::new("cargo")
+        .args(["check", "--color=always", "--manifest-path"])
+        .arg(manifest_path)
+        .output();
+    match output {
+        Ok(output) if output.status.success() => (true, String::new()),
+        Ok(output) => {
+            let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+            log.push_str(&String::from_utf8_lossy(&output.stderr));
+            let lines: Vec<&str> = log.lines().collect();
+            let tail = &lines[lines.len().saturating_sub(20)..];
+            let exit = output
+                .status
+                .code()
+                .map_or_else(|| "?".to_string(), |c| c.to_string());
+            (false, format!("exit code {exit}\n{}", tail.join("\n")))
+        }
+        Err(e) => (false, format!("failed to run cargo: {e}")),
+    }
+}
diff --git a/dev/ci/src/progress.rs b/dev/ci/src/progress.rs
new file mode 100644
index 0000000..bd62ae1
--- /dev/null
+++ b/dev/ci/src/progress.rs
@@ -0,0 +1,24 @@
+//! Shared task progress bar.
+
+use colored::Colorize;
+use indicatif::{ProgressBar, ProgressStyle};
+
+/// Creates a task progress bar with the CI's standard style.
+///
+/// `prefix` is the phase label shown before the bar, right-aligned to 12
+/// columns (e.g. `Building`, `Clippy`, `Testing`). The caller sets the
+/// initial message and drives the position.
+pub(crate) fn task_progress_bar(len: usize, prefix: &str) -> ProgressBar {
+    let padding = " ".repeat(12usize.saturating_sub(prefix.len()));
+    let styled_prefix = format!("{padding}{}", prefix.bold().bright_cyan());
+    let pb = ProgressBar::new(len as u64);
+    pb.set_style(
+        ProgressStyle::default_bar()
+            .template(&format!(
+                "{styled_prefix} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}"
+            ))
+            .unwrap()
+            .progress_chars("=> "),
+    );
+    pb
+}
diff --git a/dev/ci/src/reporter.rs b/dev/ci/src/reporter.rs
new file mode 100644
index 0000000..1a1ac08
--- /dev/null
+++ b/dev/ci/src/reporter.rs
@@ -0,0 +1,208 @@
+//! Minimal log exporter for CI reports.
+//!
+//! Writes per-package results into `collect/{task}/{platform}/{package}.{ok|err}`
+//! so that the [`crate::cmd::collect_results`] command can assemble the final
+//! report. The task name is set once per CI phase via [`set_task`].
+
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+use std::sync::{LazyLock, Mutex};
+
+/// Root of the collected CI logs (relative to the repo root).
+pub const COLLECT_DIR: &str = "./.temp/reports/collect";
+
+/// Generated report output (relative to the repo root).
+pub const REPORT_PATH: &str = "./.temp/reports/result.md";
+
+/// The platform a package check ran on.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
+pub enum ReportPlatform {
+    Windows,
+    Linux,
+    MacOS,
+}
+
+impl ReportPlatform {
+    /// Directory name used under the task folder.
+    const fn dir_name(self) -> &'static str {
+        match self {
+            Self::Windows => "Windows",
+            Self::Linux => "Linux",
+            Self::MacOS => "MacOS",
+        }
+    }
+}
+
+/// The outcome of a package check.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReportResult {
+    /// Check passed.
+    Ok,
+    /// Check failed, with the captured output.
+    Error(String),
+}
+
+/// Current task name (e.g. `Build-All`); set via [`set_task`].
+static CURRENT_TASK: Mutex> = Mutex::new(None);
+
+/// Pending success entries: `(item, location)`.
+type PendingOk = (String, String);
+
+/// Successful items pending a [`flush`], grouped by platform.
+static OK_BUFFER: LazyLock>>> =
+    LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Sets the task that subsequent [`export`] calls write under.
+///
+/// # Panics
+///
+/// Panics if the internal mutex is poisoned.
+pub fn set_task(task: &str) {
+    *CURRENT_TASK.lock().unwrap() = Some(task.to_string());
+}
+
+/// Exports one item result.
+///
+/// `item` and `location` are free-form strings chosen by the generator.
+/// Successes are buffered and written to the `ok` file by [`flush`]; failures
+/// write `{task}.{platform}.{item}.err` immediately (first line is the
+/// location). Errors are reported to stderr and otherwise ignored.
+///
+/// # Panics
+///
+/// Panics if the internal task mutex is poisoned.
+pub fn export(item: &str, location: &str, result: ReportResult) {
+    export_on(item, location, current_platform(), result);
+}
+
+/// The `ReportPlatform` for the currently compiling target.
+fn current_platform() -> ReportPlatform {
+    if cfg!(target_os = "windows") {
+        ReportPlatform::Windows
+    } else if cfg!(target_os = "macos") {
+        ReportPlatform::MacOS
+    } else {
+        ReportPlatform::Linux
+    }
+}
+
+/// Exports one item result for a specific platform.
+///
+/// `item` and `location` are free-form strings chosen by the generator.
+/// Successes are buffered and written to the `ok` file by [`flush`]; failures
+/// write `{task}.{platform}.{item}.err` immediately (first line is the
+/// location). Errors are reported to stderr and otherwise ignored.
+///
+/// # Panics
+///
+/// Panics if the internal task mutex is poisoned.
+pub fn export_on(item: &str, location: &str, platform: ReportPlatform, result: ReportResult) {
+    match result {
+        ReportResult::Ok => OK_BUFFER
+            .lock()
+            .unwrap()
+            .entry(platform)
+            .or_default()
+            .push((item.to_string(), location.to_string())),
+        ReportResult::Error(output) => write_err(item, location, platform, &output),
+    }
+}
+
+/// Writes buffered successes to `collect/{task}.{platform}.ok`, one `item` (or
+/// `item = location`) per line.
+///
+/// # Panics
+///
+/// Panics if the internal task mutex is poisoned.
+pub fn flush() {
+    let Some(task) = CURRENT_TASK.lock().unwrap().clone() else {
+        eprintln!("reporter: no current task; call reporter::set_task first");
+        return;
+    };
+
+    let buffered = std::mem::take(&mut *OK_BUFFER.lock().unwrap());
+    if buffered.is_empty() {
+        return;
+    }
+
+    if let Err(e) = fs::create_dir_all(COLLECT_DIR) {
+        eprintln!("reporter: failed to create {COLLECT_DIR}: {e}");
+        return;
+    }
+
+    for (platform, items) in buffered {
+        let lines: Vec = items
+            .iter()
+            .map(|(item, location)| {
+                if location.is_empty() {
+                    item.clone()
+                } else {
+                    format!("{item} = {location}")
+                }
+            })
+            .collect();
+        let content = if lines.is_empty() {
+            String::new()
+        } else {
+            lines.join("\n") + "\n"
+        };
+        let platform_name = platform.dir_name();
+        let path = Path::new(COLLECT_DIR).join(format!("{task}.{platform_name}.ok"));
+        if let Err(e) = fs::write(&path, content) {
+            eprintln!("reporter: failed to write {}: {e}", path.display());
+        }
+    }
+}
+
+/// Writes a failure entry to `collect/{task}.{platform}.{item}.err`, with the
+/// location as the first line (empty when unknown).
+fn write_err(item: &str, location: &str, platform: ReportPlatform, output: &str) {
+    let Some(task) = CURRENT_TASK.lock().unwrap().clone() else {
+        eprintln!("reporter: no current task; call reporter::set_task first");
+        return;
+    };
+
+    if let Err(e) = fs::create_dir_all(COLLECT_DIR) {
+        eprintln!("reporter: failed to create {COLLECT_DIR}: {e}");
+        return;
+    }
+
+    let platform_name = platform.dir_name();
+    let path = Path::new(COLLECT_DIR).join(format!("{task}.{platform_name}.{item}.err"));
+    if let Err(e) = fs::write(&path, format!("{location}\n{output}")) {
+        eprintln!("reporter: failed to write {}: {e}", path.display());
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn export_writes_ok_and_err_files() {
+        set_task("reporter-test");
+        let platform_name = current_platform().dir_name();
+        let ok_path = Path::new(COLLECT_DIR).join(format!("reporter-test.{platform_name}.ok"));
+        let err_path =
+            Path::new(COLLECT_DIR).join(format!("reporter-test.{platform_name}.pkg-b.err"));
+        fs::remove_file(&ok_path).ok();
+        fs::remove_file(&err_path).ok();
+
+        export("pkg-a", "./pkg-a", ReportResult::Ok);
+        export("pkg-b", "./pkg-b", ReportResult::Error("boom".to_string()));
+        export("pkg-c", "", ReportResult::Ok); // no location
+        flush();
+
+        assert!(ok_path.is_file());
+        assert_eq!(
+            fs::read_to_string(&ok_path).unwrap(),
+            "pkg-a = ./pkg-a\npkg-c\n"
+        );
+        assert!(err_path.is_file());
+        assert_eq!(fs::read_to_string(&err_path).unwrap(), "./pkg-b\nboom");
+
+        fs::remove_file(ok_path).ok();
+        fs::remove_file(err_path).ok();
+    }
+}
diff --git a/dev/ci/src/res.rs b/dev/ci/src/res.rs
new file mode 100644
index 0000000..54ed503
--- /dev/null
+++ b/dev/ci/src/res.rs
@@ -0,0 +1,14 @@
+mod collect_logs;
+pub use collect_logs::*;
+
+mod crate_config;
+pub use crate_config::*;
+
+mod features;
+pub use features::*;
+
+mod manifests;
+pub use manifests::*;
+
+mod print;
+pub use print::*;
diff --git a/dev/ci/src/res/collect_logs.rs b/dev/ci/src/res/collect_logs.rs
new file mode 100644
index 0000000..6017168
--- /dev/null
+++ b/dev/ci/src/res/collect_logs.rs
@@ -0,0 +1,203 @@
+//! IO side of the report command: reads the collect directory once and keeps
+//! the parsed data in a resource, so chains only do computation.
+
+use std::collections::BTreeMap;
+
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+use crate::reporter::COLLECT_DIR;
+
+/// Git commit date and short hash for the report.
+#[derive(Default, Clone, Debug)]
+pub struct GitInfo {
+    pub date: String,
+    pub commit_hash: String,
+}
+
+/// Parsed contents of the collect directory.
+#[derive(Default, Clone)]
+pub struct ResCollectLogs {
+    /// `(task, item) -> os -> ok`
+    pub statuses: BTreeMap<(String, String), BTreeMap>,
+    /// `(task, item) -> location`
+    pub locations: BTreeMap<(String, String), String>,
+    /// `(task, os, item) -> stripped error output (location line removed)`
+    pub err_outputs: BTreeMap<(String, String, String), String>,
+    pub git: GitInfo,
+}
+
+impl ResCollectLogs {
+    /// Reads the flat `collect/` directory — aggregate `{task}.{os}.ok` files
+    /// (`item` or `item = location` per line) and per-item
+    /// `{task}.{os}.{item}.err` files (first line is the location) — plus the
+    /// git info.
+    #[must_use]
+    pub fn read() -> Self {
+        let mut logs = Self::default();
+
+        if let Ok(entries) = std::fs::read_dir(COLLECT_DIR) {
+            for entry in entries.flatten() {
+                let file_name = entry.file_name().to_string_lossy().into_owned();
+                if let Some((task, os)) = parse_ok_name(&file_name) {
+                    // Aggregate success file: `item` or `item = location` per line.
+                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
+                        for line in content.lines().filter(|l| !l.is_empty()) {
+                            let (item, location) = line
+                                .split_once('=')
+                                .map_or((line, ""), |(name, loc)| (name.trim(), loc.trim()));
+                            logs.statuses
+                                .entry((task.clone(), item.to_string()))
+                                .or_default()
+                                .insert(os.clone(), true);
+                            logs.locations
+                                .insert((task.clone(), item.to_string()), location.to_string());
+                        }
+                    }
+                } else if let Some((task, os, item)) = parse_err_name(&file_name) {
+                    let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
+                    let mut lines = content.splitn(2, '\n');
+                    let location = lines.next().unwrap_or_default().to_string();
+                    let output = lines.next().unwrap_or_default().to_string();
+                    logs.statuses
+                        .entry((task.clone(), item.clone()))
+                        .or_default()
+                        .insert(os.clone(), false);
+                    logs.locations
+                        .insert((task.clone(), item.clone()), location);
+                    logs.err_outputs
+                        .insert((task, os, item), strip_ansi(&output));
+                }
+            }
+        }
+
+        logs.git = git_info();
+        logs
+    }
+}
+
+/// Parses a `{task}.{os}.ok` file name.
+fn parse_ok_name(file_name: &str) -> Option<(String, String)> {
+    let name = file_name.strip_suffix(".ok")?;
+    let mut parts = name.rsplitn(2, '.');
+    let os = parts.next()?.to_string();
+    let task = parts.next()?.to_string();
+    Some((task, os))
+}
+
+/// Parses a `{task}.{os}.{package}.err` file name.
+///
+/// Split from the right: package names cannot contain dots (cargo forbids
+/// them), while task names may.
+fn parse_err_name(file_name: &str) -> Option<(String, String, String)> {
+    let name = file_name.strip_suffix(".err")?;
+    let mut parts = name.rsplitn(3, '.');
+    let package = parts.next()?.to_string();
+    let os = parts.next()?.to_string();
+    let task = parts.next()?.to_string();
+    Some((task, os, package))
+}
+
+#[program_setup]
+pub fn report_setup(p: &mut Program) {
+    p.with_resource(ResCollectLogs::read());
+}
+
+/// Strips ANSI escape sequences from `input`.
+///
+/// Handles CSI (`ESC [ ...`), OSC (`ESC ] ...` terminated by BEL or `ESC \`)
+/// and other single-character escapes, while preserving UTF-8 text. Literal
+/// `^[` (caret-bracket, produced by some terminal captures) is normalized to
+/// `ESC` first.
+fn strip_ansi(input: &str) -> String {
+    // Normalize literal `^[` (0x5E 0x5B) to a real ESC byte.
+    let normalized = input.replace("^[", "\u{1b}");
+    let mut out = String::with_capacity(normalized.len());
+    let mut rest = normalized.as_str();
+    while let Some(idx) = rest.find('\u{1b}') {
+        out.push_str(&rest[..idx]);
+        rest = &rest[idx..];
+        rest = &rest[ansi_len(rest)..];
+    }
+    out.push_str(rest);
+    out
+}
+
+/// Byte length of the ANSI escape sequence starting at `s[0]` (`s[0]` is `ESC`).
+fn ansi_len(s: &str) -> usize {
+    let b = s.as_bytes();
+    match b.get(1) {
+        Some(b'[') => {
+            // CSI: `ESC [` params/intermediates (0x20-0x3F) then a final byte (0x40-0x7E).
+            let mut i = 2;
+            while i < b.len() {
+                let byte = b[i];
+                i += 1;
+                if (0x40..=0x7E).contains(&byte) {
+                    break;
+                }
+                if !(0x20..=0x3F).contains(&byte) {
+                    break;
+                }
+            }
+            i
+        }
+        Some(b']') => {
+            // OSC: `ESC ]` ... terminated by BEL (0x07) or `ESC \`.
+            let mut i = 2;
+            while i < b.len() {
+                let byte = b[i];
+                i += 1;
+                if byte == 0x07 {
+                    break;
+                }
+                if byte == 0x1b {
+                    if b.get(i) == Some(&b'\\') {
+                        i += 1;
+                    }
+                    break;
+                }
+            }
+            i
+        }
+        Some(_) => 2.min(b.len()),
+        None => 1,
+    }
+}
+
+/// Commit date (`YYYY-MM-DD`) and short commit hash; empty on failure.
+fn git_info() -> GitInfo {
+    let run = |args: &[&str]| {
+        std::process::Command::new("git")
+            .args(args)
+            .output()
+            .ok()
+            .filter(|o| o.status.success())
+            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+            .unwrap_or_default()
+    };
+    GitInfo {
+        date: run(&["log", "-1", "--format=%cs"]),
+        commit_hash: run(&["rev-parse", "--short", "HEAD"]),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::strip_ansi;
+
+    #[test]
+    fn strips_csi_and_osc_and_literal_caret() {
+        let input =
+            "\u{1b}[1m\u{1b}[92mok\u{1b}[0m \u{1b}]8;;https://x\u{1b}\\done\u{1b}]8;;\u{1b}\\\n";
+        assert_eq!(strip_ansi(input), "ok done\n");
+
+        // Literal `^[` (caret-bracket) captured by some terminals.
+        assert_eq!(strip_ansi("^[[31mred^[[0m"), "red");
+    }
+
+    #[test]
+    fn preserves_utf8() {
+        assert_eq!(strip_ansi("你好\u{1b}[1m世界!\u{1b}[0m"), "你好世界!");
+    }
+}
diff --git a/dev/ci/src/res/crate_config.rs b/dev/ci/src/res/crate_config.rs
new file mode 100644
index 0000000..b20e83d
--- /dev/null
+++ b/dev/ci/src/res/crate_config.rs
@@ -0,0 +1,79 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+use crate::res::{Manifests, ResFeatureList};
+
+/// Per-crate CI overrides from `mingling-ci.toml` (optional, crate root).
+///
+/// Currently only `[test] command` is read; `clippy.command` / `build.command`
+/// will follow the same shape.
+#[derive(Default, Clone)]
+pub struct ResCrateConfig {
+    /// Package name -> test command argv (with `<<>>` expanded).
+    test_commands: HashMap>,
+}
+
+impl ResCrateConfig {
+    /// The configured `[test] command` for a package, if any.
+    #[must_use]
+    pub fn test_command(&self, package: &str) -> Option<&[String]> {
+        self.test_commands.get(package).map(Vec::as_slice)
+    }
+}
+
+#[program_setup]
+pub fn crate_config_setup(p: &mut Program) {
+    let features = p
+        .res::()
+        .map(|f| f.list.clone())
+        .unwrap_or_default();
+    let joined_features = features.join(",");
+
+    let Some(manifests) = p.res::() else {
+        return;
+    };
+
+    let mut test_commands = HashMap::new();
+    for (name, manifest_path) in &manifests.package_dirs {
+        let config_path = manifest_path
+            .parent()
+            .unwrap_or_else(|| Path::new("."))
+            .join("mingling-ci.toml");
+
+        let Ok(content) = std::fs::read_to_string(&config_path) else {
+            continue;
+        };
+
+        let Ok(table) = content.parse::() else {
+            continue;
+        };
+
+        let Some(command) = table
+            .get("test")
+            .and_then(|t| t.get("command"))
+            .and_then(|c| c.as_array())
+        else {
+            continue;
+        };
+
+        let argv: Vec = command
+            .iter()
+            .filter_map(|v| v.as_str().map(str::to_string))
+            .collect();
+
+        if argv.is_empty() {
+            continue;
+        }
+
+        let argv = argv
+            .into_iter()
+            .map(|arg| arg.replace("<<>>", &joined_features))
+            .collect();
+        test_commands.insert(name.clone(), argv);
+    }
+
+    p.with_resource(ResCrateConfig { test_commands });
+}
diff --git a/dev/ci/src/res/features.rs b/dev/ci/src/res/features.rs
new file mode 100644
index 0000000..8009514
--- /dev/null
+++ b/dev/ci/src/res/features.rs
@@ -0,0 +1,47 @@
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+
+/// Manifest that declares the documented feature list.
+///
+/// Path is relative to the repo root (the CI's working directory).
+const FEATURES_MANIFEST: &str = "./mingling/Cargo.toml";
+
+/// The docs.rs feature list of `mingling`, the single source of truth for the
+/// feature combinations used by CI checks.
+#[derive(Default, Clone)]
+pub struct ResFeatureList {
+    pub list: Vec,
+}
+
+#[program_setup]
+pub fn features_setup(p: &mut Program) {
+    p.with_resource(ResFeatureList {
+        list: docs_rs_features(),
+    });
+}
+
+/// Reads `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`.
+#[must_use]
+fn docs_rs_features() -> Vec {
+    let Ok(content) = std::fs::read_to_string(FEATURES_MANIFEST) else {
+        return Vec::new();
+    };
+    let Ok(toml_value) = content.parse::() else {
+        return Vec::new();
+    };
+    toml_value
+        .get("package")
+        .and_then(|p| p.get("metadata"))
+        .and_then(|m| m.get("docs"))
+        .and_then(|d| d.get("rs"))
+        .and_then(|rs| rs.get("features"))
+        .and_then(|f| f.as_array())
+        .map(|features| {
+            features
+                .iter()
+                .filter_map(|v| v.as_str().map(str::to_string))
+                .collect()
+        })
+        .unwrap_or_default()
+}
diff --git a/dev/ci/src/res/manifests.rs b/dev/ci/src/res/manifests.rs
new file mode 100644
index 0000000..7c72146
--- /dev/null
+++ b/dev/ci/src/res/manifests.rs
@@ -0,0 +1,103 @@
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use mingling::{Program, macros::program_setup};
+
+use crate::ThisProgram;
+
+/// Directories whose manifests are excluded from CI checks.
+///
+/// Path is relative to the crate source file (`dev/ci/src/res/`).
+const IGNORED_DIRS_FILE: &str = include_str!("../../../configs/ci-ignored-dirs.txt");
+
+/// All `Cargo.toml` manifests the CI will check.
+#[derive(Default, Clone)]
+pub struct Manifests {
+    pub path: Vec,
+    /// Package name -> its manifest path.
+    pub package_dirs: HashMap,
+}
+
+#[program_setup]
+pub fn manifests_setup(p: &mut Program) {
+    let path = cargo_tomls();
+    let package_dirs = path.iter().map(|p| (package_name(p), p.clone())).collect();
+    p.with_resource(Manifests { path, package_dirs });
+}
+
+/// Recursively collects every `Cargo.toml` under the current directory,
+/// skipping the legacy `.run` CI directory and any directory listed in
+/// `dev/configs/ci-ignored-dirs.txt`.
+#[must_use]
+fn cargo_tomls() -> Vec {
+    let ignored = ignored_dirs();
+    let mut cargo_tomls = Vec::new();
+    let mut dirs = vec![PathBuf::from(".")];
+    while let Some(dir) = dirs.pop() {
+        if is_ignored(&dir.to_string_lossy(), &ignored) {
+            continue;
+        }
+        if let Ok(entries) = std::fs::read_dir(&dir) {
+            for entry in entries.flatten() {
+                let path = entry.path();
+                if path.is_dir() {
+                    // Skip the legacy `.run` CI directory
+                    if path.file_name().and_then(|n| n.to_str()) == Some(".run") {
+                        continue;
+                    }
+                    dirs.push(path);
+                } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
+                    cargo_tomls.push(path);
+                }
+            }
+        }
+    }
+    cargo_tomls
+}
+
+/// Parses `dev/configs/ci-ignored-dirs.txt` into directory prefixes:
+/// non-empty lines that do not start with `#`, with the trailing `/` stripped
+/// (e.g. `./.temp/` → `./.temp`).
+fn ignored_dirs() -> Vec {
+    IGNORED_DIRS_FILE
+        .lines()
+        .map(str::trim)
+        .filter(|line| !line.is_empty() && !line.starts_with('#'))
+        .map(|line| line.trim_end_matches('/').to_string())
+        .collect()
+}
+
+/// Whether `path` (a walk directory, e.g. `./.temp` or `./examples`) is inside
+/// one of the ignored directories.
+fn is_ignored(path: &str, ignored: &[String]) -> bool {
+    ignored.iter().any(|dir| {
+        path.strip_prefix(dir.as_str())
+            .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
+    })
+}
+
+/// Extracts the package name from a `Cargo.toml`.
+///
+/// Falls back to the parent directory name (e.g. `mingling_core/Cargo.toml` →
+/// `mingling_core`, workspace root → `(root)`), matching the legacy CI.
+fn package_name(path: &Path) -> String {
+    let fallback = || {
+        path.parent()
+            .and_then(|p| p.file_name())
+            .and_then(|n| n.to_str())
+            .unwrap_or("(root)")
+            .to_string()
+    };
+
+    let Ok(content) = std::fs::read_to_string(path) else {
+        return fallback();
+    };
+    let Ok(toml_value) = content.parse::() else {
+        return fallback();
+    };
+    toml_value
+        .get("package")
+        .and_then(|p| p.get("name"))
+        .and_then(|n| n.as_str())
+        .map_or_else(fallback, str::to_string)
+}
diff --git a/dev/ci/src/res/print.rs b/dev/ci/src/res/print.rs
new file mode 100644
index 0000000..9d844a6
--- /dev/null
+++ b/dev/ci/src/res/print.rs
@@ -0,0 +1,174 @@
+use colored::Colorize;
+use mingling::config::ErrorOutput;
+use mingling::hook::ProgramHook;
+use mingling::{Program, macros::program_setup};
+use mingling::{StringVec, this};
+
+use crate::ThisProgram;
+
+#[program_setup]
+pub fn print_setup(p: &mut Program) {
+    p.with_resource(CargoError::default());
+    p.with_resource(CargoWarn::default());
+    p.with_resource(CargoHelp::default());
+    p.with_resource(CargoStatus::default());
+
+    p.with_hook(ProgramHook::empty().on_begin::<_, ()>(move |_| {
+        let p = this::();
+        let silence_err = p.stdout_setting.error_output == ErrorOutput::Hide;
+
+        p.modify_res(|r: &mut CargoError| r.silence = silence_err);
+        p.modify_res(|r: &mut CargoWarn| r.silence = silence_err);
+        p.modify_res(|r: &mut CargoHelp| r.silence = silence_err);
+        p.modify_res(|r: &mut CargoStatus| r.silence = silence_err);
+    }));
+}
+
+#[derive(Default, Clone)]
+pub struct CargoError {
+    silence: bool,
+}
+
+impl MessagePrinter for CargoError {
+    fn format(&self, msg: impl Into) -> String {
+        format!("{}: {}", "error".bold().bright_red(), msg.into().join(""))
+    }
+
+    fn std_mode(&self) -> StandardOutMode {
+        if self.silence {
+            StandardOutMode::Silence
+        } else {
+            StandardOutMode::Error
+        }
+    }
+}
+
+#[derive(Default, Clone)]
+pub struct CargoWarn {
+    silence: bool,
+}
+
+impl MessagePrinter for CargoWarn {
+    fn format(&self, msg: impl Into) -> String {
+        format!("{}: {}", "warning".bright_yellow(), msg.into().join(""))
+    }
+
+    fn std_mode(&self) -> StandardOutMode {
+        if self.silence {
+            StandardOutMode::Silence
+        } else {
+            StandardOutMode::Error
+        }
+    }
+}
+
+#[derive(Default, Clone)]
+pub struct CargoHelp {
+    silence: bool,
+}
+
+impl MessagePrinter for CargoHelp {
+    fn format(&self, msg: impl Into) -> String {
+        format!("{}: {}", "help".bright_white(), msg.into().join(""))
+    }
+
+    fn std_mode(&self) -> StandardOutMode {
+        if self.silence {
+            StandardOutMode::Silence
+        } else {
+            StandardOutMode::Error
+        }
+    }
+}
+
+#[derive(Default, Clone)]
+pub struct CargoStatus {
+    silence: bool,
+}
+
+impl MessagePrinter for CargoStatus {
+    fn format(&self, msg: impl Into) -> String {
+        let parts: Vec = msg.into().to_vec();
+        let first = if parts.is_empty() {
+            String::new()
+        } else {
+            parts[0].trim().to_string()
+        };
+
+        let (prefix, content) = if first.is_empty() {
+            // Empty: fall back to Info with full message
+            ("Info".to_string(), parts.join(" "))
+        } else if first.chars().count() == 1 {
+            // Single character: prefix is Info, entire message is content
+            ("Info".to_string(), parts.join(" "))
+        } else if first.chars().count() <= 12 {
+            // Single part that is a status prefix (no message after it)
+            if parts.len() == 1 {
+                ("Info".to_string(), first)
+            } else {
+                // First part is a status prefix, remaining parts are the message
+                let content = parts[1..].join(" ").trim_start().to_string();
+                (first, content)
+            }
+        } else {
+            // First part too long: all is message, fall back to Info
+            ("Info".to_string(), parts.join(" "))
+        };
+
+        let padding = " ".repeat(12usize.saturating_sub(prefix.chars().count()));
+
+        format!(
+            "{}{} {}",
+            padding,
+            prefix.bold().bright_green(),
+            content.trim()
+        )
+    }
+
+    fn std_mode(&self) -> StandardOutMode {
+        if self.silence {
+            StandardOutMode::Silence
+        } else {
+            StandardOutMode::Out
+        }
+    }
+}
+
+pub trait MessagePrinter {
+    #[doc(hidden)]
+    fn println(&self, msg: impl Into) {
+        match self.std_mode() {
+            StandardOutMode::Out => println!("{}", self.format(msg)),
+            StandardOutMode::Error => eprintln!("{}", self.format(msg)),
+            StandardOutMode::Silence => {}
+        }
+    }
+
+    #[doc(hidden)]
+    fn print(&self, msg: impl Into) {
+        match self.std_mode() {
+            StandardOutMode::Out => print!("{}", self.format(msg)),
+            StandardOutMode::Error => eprint!("{}", self.format(msg)),
+            StandardOutMode::Silence => {}
+        }
+    }
+
+    /// Formats the message string before output.
+    fn format(&self, msg: impl Into) -> String;
+
+    /// Returns the standard output mode (stdout or stderr).
+    fn std_mode(&self) -> StandardOutMode;
+}
+
+/// Specifies where standard output messages should be directed.
+///
+/// This enum determines whether messages are printed to stdout, stderr, or suppressed entirely.
+#[repr(u8)]
+pub enum StandardOutMode {
+    /// Print messages to standard output (stdout).
+    Out,
+    /// Print messages to standard error (stderr).
+    Error,
+    /// Suppress all output.
+    Silence,
+}
diff --git a/dev/ci/src/task.rs b/dev/ci/src/task.rs
new file mode 100644
index 0000000..a42e458
--- /dev/null
+++ b/dev/ci/src/task.rs
@@ -0,0 +1,9 @@
+pub(crate) mod cmd_build_check;
+pub(crate) mod cmd_clippy_check;
+pub(crate) mod cmd_docs_check;
+pub(crate) mod cmd_example_check;
+pub(crate) mod cmd_markdown_check;
+pub(crate) mod cmd_markdown_compare;
+pub(crate) mod cmd_test;
+pub(crate) mod run;
+
diff --git a/dev/ci/src/task/cmd_build_check.rs b/dev/ci/src/task/cmd_build_check.rs
new file mode 100644
index 0000000..f67fe2e
--- /dev/null
+++ b/dev/ci/src/task/cmd_build_check.rs
@@ -0,0 +1,47 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use mingling::{
+    Grouped, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::Manifests;
+use crate::task::run::{location, run_parallel_checks};
+
+#[command(node = "build-check")]
+pub async fn build_check(manifests: &Manifests) -> Next {
+    let tasks = manifests
+        .package_dirs
+        .iter()
+        .map(|(name, path)| (name.clone(), location(path), build_args(path)))
+        .collect();
+    let fail_count = run_parallel_checks("Build-Check", "Building", tasks).await;
+    ResultBuildCheck { fail_count }.to_chain()
+}
+
+/// `cargo build --manifest-path `
+fn build_args(path: &Path) -> Vec {
+    vec![
+        "cargo".into(),
+        "build".into(),
+        "--manifest-path".into(),
+        path.as_os_str().to_os_string(),
+    ]
+}
+
+/// Number of packages that failed to build.
+#[derive(Grouped)]
+pub struct ResultBuildCheck {
+    pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any build failed.
+#[renderer(buffer)]
+pub fn render_build_check(r: ResultBuildCheck, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
diff --git a/dev/ci/src/task/cmd_clippy_check.rs b/dev/ci/src/task/cmd_clippy_check.rs
new file mode 100644
index 0000000..a0dd46e
--- /dev/null
+++ b/dev/ci/src/task/cmd_clippy_check.rs
@@ -0,0 +1,50 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use mingling::{
+    Grouped, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::Manifests;
+use crate::task::run::{location, run_parallel_checks};
+
+#[command(node = "clippy-check")]
+pub async fn clippy_check(manifests: &Manifests) -> Next {
+    let tasks = manifests
+        .package_dirs
+        .iter()
+        .map(|(name, path)| (name.clone(), location(path), clippy_args(path)))
+        .collect();
+    let fail_count = run_parallel_checks("Clippy-Check", "Clippy", tasks).await;
+    ResultClippyCheck { fail_count }.to_chain()
+}
+
+/// `cargo clippy --manifest-path  -- -D warnings`
+fn clippy_args(path: &Path) -> Vec {
+    vec![
+        "cargo".into(),
+        "clippy".into(),
+        "--manifest-path".into(),
+        path.as_os_str().to_os_string(),
+        "--".into(),
+        "-D".into(),
+        "warnings".into(),
+    ]
+}
+
+/// Number of packages that failed clippy.
+#[derive(Grouped)]
+pub struct ResultClippyCheck {
+    pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any clippy check failed.
+#[renderer(buffer)]
+pub fn render_clippy_check(r: ResultClippyCheck, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
diff --git a/dev/ci/src/task/cmd_docs_check.rs b/dev/ci/src/task/cmd_docs_check.rs
new file mode 100644
index 0000000..3a77d4d
--- /dev/null
+++ b/dev/ci/src/task/cmd_docs_check.rs
@@ -0,0 +1,44 @@
+use std::ffi::OsString;
+
+use mingling::{
+    Grouped, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::ResFeatureList;
+use crate::task::run::run_parallel_checks;
+
+#[command(node = "docs-check")]
+pub async fn docs_check(features: &ResFeatureList) -> Next {
+    let args = vec![
+        OsString::from("cargo"),
+        OsString::from("rustdoc"),
+        OsString::from("--features"),
+        OsString::from(features.list.join(",")),
+        OsString::from("-p"),
+        OsString::from("mingling"),
+        OsString::from("--"),
+        OsString::from("-D"),
+        OsString::from("warnings"),
+    ];
+    let tasks = vec![("mingling".to_string(), "./mingling".to_string(), args)];
+    let fail_count = run_parallel_checks("Docs-Check", "Docs", tasks).await;
+
+    ResultDocsCheck { fail_count }.to_chain()
+}
+
+/// Number of failed doc builds (0 or 1).
+#[derive(Grouped)]
+pub struct ResultDocsCheck {
+    pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when the doc build failed.
+#[renderer(buffer)]
+pub fn render_docs_check(r: ResultDocsCheck, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
diff --git a/dev/ci/src/task/cmd_example_check.rs b/dev/ci/src/task/cmd_example_check.rs
new file mode 100644
index 0000000..1b9f440
--- /dev/null
+++ b/dev/ci/src/task/cmd_example_check.rs
@@ -0,0 +1,69 @@
+use colored::Colorize;
+use mingling::{
+    Grouped, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::examples::{check_example, load_test_configs};
+use crate::progress::task_progress_bar;
+use crate::reporter::{self, ReportResult};
+
+#[command(node = "example-check")]
+pub async fn example_check() -> Next {
+    reporter::set_task("Example-Check");
+
+    let configs = load_test_configs();
+    let total = configs.len();
+    let pb = task_progress_bar(total, "Testing");
+    pb.set_message("examples");
+
+    // One blocking task per example: build + run its test cases.
+    let mut handles = Vec::new();
+    for example in configs {
+        handles.push(tokio::task::spawn_blocking(move || check_example(example)));
+    }
+
+    let mut fail_count = 0;
+    for handle in handles {
+        let Ok(outcome) = handle.await else {
+            continue;
+        };
+        pb.set_message(outcome.name.clone());
+        pb.inc(1);
+
+        if outcome.ok {
+            reporter::export(&outcome.name, &outcome.location, ReportResult::Ok);
+        } else {
+            fail_count += 1;
+            // Plain stderr: `pb.println` is swallowed on non-TTY (CI).
+            eprintln!("  {} {}", "failed".bright_red(), outcome.name);
+            eprintln!("  {}", outcome.output);
+            reporter::export(
+                &outcome.name,
+                &outcome.location,
+                ReportResult::Error(outcome.output),
+            );
+        }
+    }
+
+    pb.finish_and_clear();
+    reporter::flush();
+
+    ResultExampleCheck { fail_count }.to_chain()
+}
+
+/// Number of examples that failed to build or pass their tests.
+#[derive(Grouped)]
+pub struct ResultExampleCheck {
+    pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any example failed.
+#[renderer(buffer)]
+pub fn render_example_check(r: ResultExampleCheck, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
diff --git a/dev/ci/src/task/cmd_markdown_check.rs b/dev/ci/src/task/cmd_markdown_check.rs
new file mode 100644
index 0000000..eb50fe5
--- /dev/null
+++ b/dev/ci/src/task/cmd_markdown_check.rs
@@ -0,0 +1,192 @@
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use just_fmt::snake_case;
+use mingling::{
+    Grouped, RenderResult, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::markdown::project::parse_markdown;
+use crate::markdown::test::{MarkdownBlockOutcome, try_test_markdown_project};
+use crate::reporter::{self, ReportResult};
+use crate::res::{CargoError, MessagePrinter};
+
+const VERIFIED_DOCS: &str = "dev/configs/verified-docs.toml";
+
+#[command(node = "markdown-check")]
+pub async fn markdown_check(args: Vec) -> Next {
+    let Some(path_str) = args.first() else {
+        return ErrorMarkdownArgs("missing  argument".to_string()).to_chain();
+    };
+    let path =
+        std::env::current_dir().map_or_else(|_| PathBuf::from(path_str), |cwd| cwd.join(path_str));
+    if !path.is_file() {
+        return ErrorMarkdownArgs(format!("{} is not a file", path.display())).to_chain();
+    }
+    let Ok(content) = std::fs::read_to_string(&path) else {
+        return ErrorMarkdownArgs(format!("failed to read {}", path.display())).to_chain();
+    };
+
+    let location = path.to_string_lossy().into_owned();
+    let item = format!("doc-{}", snake_case!(&stem_of(&path)));
+    reporter::set_task("Markdown-Check");
+
+    let projects = parse_markdown(&content, &location);
+    let outcomes = try_test_markdown_project(projects).await;
+    let file_info = HashMap::from([(location.clone(), (item, location))]);
+    let fail_count = report_files(&outcomes, &file_info);
+    reporter::flush();
+
+    ResultMarkdownCheck { fail_count }.to_chain()
+}
+
+#[command(node = "markdown-check-all")]
+pub async fn markdown_check_all() -> Next {
+    let Some(files) = verified_md_files() else {
+        return ErrorMarkdownConfig.to_chain();
+    };
+    reporter::set_task("Markdown-Check-All");
+
+    // Collect all projects; remember each file's report identity
+    // (`{key}-{snake_case(file_stem)}` -> location).
+    let mut projects = Vec::new();
+    let mut file_info: HashMap = HashMap::new();
+    for (label, path) in files {
+        let Ok(content) = std::fs::read_to_string(&path) else {
+            continue;
+        };
+        let file_name = path.file_name().unwrap().to_string_lossy();
+        let source_file = format!("{label}/{file_name}");
+        let item = format!("{label}-{}", snake_case!(&stem_of(&path)));
+        let location = path.to_string_lossy().into_owned();
+        file_info.insert(source_file.clone(), (item, location));
+        projects.extend(parse_markdown(&content, &source_file));
+    }
+
+    let outcomes = try_test_markdown_project(projects).await;
+    let fail_count = report_files(&outcomes, &file_info);
+    reporter::flush();
+
+    ResultMarkdownCheck { fail_count }.to_chain()
+}
+
+/// The file name without extension, e.g. `README.md` → `README`.
+pub(crate) fn stem_of(path: &Path) -> String {
+    path.file_stem()
+        .unwrap_or_default()
+        .to_string_lossy()
+        .into_owned()
+}
+
+/// Exports one report entry per source file: `ok` when every block passed,
+/// otherwise an error carrying the failed blocks' details.
+fn report_files(
+    outcomes: &[MarkdownBlockOutcome],
+    file_info: &HashMap,
+) -> usize {
+    let mut by_file: HashMap<&str, (bool, Vec)> = HashMap::new();
+    for outcome in outcomes {
+        let (ok, outputs) = by_file
+            .entry(outcome.source_file.as_str())
+            .or_insert((true, Vec::new()));
+        if !outcome.ok {
+            *ok = false;
+            outputs.push(format!(
+                "{}:{}:\n{}",
+                outcome.source_file, outcome.line, outcome.output
+            ));
+        }
+    }
+
+    let mut fail_count = 0;
+    for (source_file, (ok, outputs)) in by_file {
+        let Some((item, location)) = file_info.get(source_file) else {
+            continue;
+        };
+        if ok {
+            reporter::export(item, location, ReportResult::Ok);
+        } else {
+            fail_count += outputs.len();
+            reporter::export(item, location, ReportResult::Error(outputs.join("\n\n")));
+        }
+    }
+    fail_count
+}
+
+/// Reads `verified-docs.toml` and collects all `.md` files: single files,
+/// directories, or `**` globs (walked from the base directory).
+fn verified_md_files() -> Option> {
+    let content = std::fs::read_to_string(VERIFIED_DOCS).ok()?;
+    let table: toml::Table = content.parse().ok()?;
+
+    let mut files: Vec<(String, PathBuf)> = Vec::new();
+    for (label, value) in table.get("verified")?.as_table()? {
+        let value_str = value.as_str()?;
+        let candidate = PathBuf::from(value_str);
+        if candidate.is_dir() {
+            collect_md_files(&candidate, &mut files, label);
+        } else if candidate.is_file() {
+            files.push((label.clone(), candidate));
+        } else if candidate.extension().is_none() {
+            // Glob like "docs/pages/**": walk the base directory.
+            let base = PathBuf::from(value_str.trim_end_matches("/**").trim_end_matches('*'));
+            if base.is_dir() {
+                collect_md_files(&base, &mut files, label);
+            }
+        }
+    }
+
+    files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
+    Some(files)
+}
+
+/// Recursively collects all `.md` files under a directory.
+fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, label: &str) {
+    if let Ok(entries) = std::fs::read_dir(dir) {
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if path.is_dir() {
+                collect_md_files(&path, files, label);
+            } else if path.extension().is_some_and(|ext| ext == "md") {
+                files.push((label.to_string(), path));
+            }
+        }
+    }
+}
+
+/// Number of code blocks that failed to build.
+#[derive(Grouped)]
+pub struct ResultMarkdownCheck {
+    pub fail_count: usize,
+}
+
+#[derive(Grouped, Default)]
+pub struct ErrorMarkdownArgs(pub String);
+
+#[derive(Grouped, Default)]
+pub struct ErrorMarkdownConfig;
+
+/// Silently sets a non-zero exit code when any block failed.
+#[renderer(buffer)]
+pub fn render_markdown_check(r: ResultMarkdownCheck, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
+
+#[renderer]
+pub fn render_error_markdown_args(e: ErrorMarkdownArgs, error: &CargoError) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![e.0]);
+    render_result
+}
+
+#[renderer]
+pub fn render_error_markdown_config(_: ErrorMarkdownConfig, error: &CargoError) -> RenderResult {
+    let render_result = RenderResult::new();
+    error.println(vec![format!("failed to read {VERIFIED_DOCS}")]);
+    render_result
+}
diff --git a/dev/ci/src/task/cmd_markdown_compare.rs b/dev/ci/src/task/cmd_markdown_compare.rs
new file mode 100644
index 0000000..f3a6a1f
--- /dev/null
+++ b/dev/ci/src/task/cmd_markdown_compare.rs
@@ -0,0 +1,221 @@
+use std::collections::BTreeSet;
+use std::path::{Path, PathBuf};
+
+use colored::Colorize;
+use just_fmt::snake_case;
+use mingling::{
+    Grouped, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::markdown::compare::{collect_md_files, compare_signature};
+use crate::reporter::{self, ReportResult};
+use crate::task::cmd_markdown_check::{ErrorMarkdownArgs, ErrorMarkdownConfig, stem_of};
+
+const DOCS_DIR: &str = "./docs";
+const LANG_CONFIG: &str = "dev/configs/docs-lang.txt";
+
+/// One file-pair outcome of a structure comparison.
+struct CompareOutcome {
+    item: String,
+    location: String,
+    ok: bool,
+    output: String,
+}
+
+#[command(node = "markdown-compare")]
+// `#[command]` rewrites an owned first param into the entry type, so the args
+// must be passed by value even though the body only reads them.
+#[allow(clippy::needless_pass_by_value)]
+pub fn markdown_compare(args: Vec) -> Next {
+    let [ref_arg, trans_arg] = args.as_slice() else {
+        return ErrorMarkdownArgs("missing  and  arguments".to_string())
+            .to_chain();
+    };
+    let ref_path = cwd().join(ref_arg);
+    let trans_path = cwd().join(trans_arg);
+
+    reporter::set_task("Markdown-Compare");
+    let outcomes = if ref_path.is_dir() && trans_path.is_dir() {
+        compare_dirs(&ref_path, &trans_path, "doc")
+    } else if ref_path.is_file() && trans_path.is_file() {
+        compare_files(&ref_path, &trans_path, "doc")
+    } else {
+        return ErrorMarkdownArgs(
+            "both arguments must be files or both must be directories".to_string(),
+        )
+        .to_chain();
+    };
+    let fail_count = export_outcomes(&outcomes);
+    reporter::flush();
+
+    ResultMarkdownCompare { fail_count }.to_chain()
+}
+
+#[command(node = "markdown-compare-all")]
+pub fn markdown_compare_all() -> Next {
+    let Some(langs) = lang_config() else {
+        return ErrorMarkdownConfig.to_chain();
+    };
+    let Some(reference) = langs.first() else {
+        return ErrorMarkdownConfig.to_chain();
+    };
+    let ref_dir = PathBuf::from(DOCS_DIR).join(reference);
+    if !ref_dir.is_dir() {
+        return ErrorMarkdownArgs(format!(
+            "reference docs directory `{}` does not exist",
+            ref_dir.display()
+        ))
+        .to_chain();
+    }
+
+    reporter::set_task("Markdown-Compare-All");
+    let mut fail_count = 0;
+    for lang in &langs[1..] {
+        let lang_dir = PathBuf::from(DOCS_DIR).join(lang);
+        if !lang_dir.is_dir() {
+            eprintln!(
+                "  {}: `{}` does not exist",
+                "ERROR".bright_red(),
+                lang_dir.display()
+            );
+            fail_count += 1;
+            continue;
+        }
+        let outcomes = compare_dirs(&ref_dir, &lang_dir, &lang_key(lang));
+        fail_count += export_outcomes(&outcomes);
+    }
+    reporter::flush();
+
+    ResultMarkdownCompare { fail_count }.to_chain()
+}
+
+/// Compares one file pair (reference vs translation).
+fn compare_files(ref_path: &Path, trans_path: &Path, prefix: &str) -> Vec {
+    let item = format!("{prefix}-{}", snake_case!(&stem_of(ref_path)));
+    let location = trans_path.to_string_lossy().into_owned();
+    match compare_signature(ref_path, trans_path) {
+        Ok(()) => vec![CompareOutcome {
+            item,
+            location,
+            ok: true,
+            output: String::new(),
+        }],
+        Err(diffs) => vec![CompareOutcome {
+            item,
+            location,
+            ok: false,
+            output: diffs.join("\n"),
+        }],
+    }
+}
+
+/// Compares two directories: every `.md` file in the reference must exist in
+/// the translation with the same structural signature; extra files are errors.
+fn compare_dirs(ref_dir: &Path, trans_dir: &Path, prefix: &str) -> Vec {
+    let ref_files = collect_md_files(ref_dir);
+    let ref_set: BTreeSet = ref_files.iter().cloned().collect();
+    let trans_set: BTreeSet = collect_md_files(trans_dir).into_iter().collect();
+
+    let mut outcomes = Vec::new();
+    for file in ref_files {
+        let item = format!("{prefix}-{}", snake_case!(&stem_of(&file)));
+        let trans_path = trans_dir.join(&file);
+        let location = trans_path.to_string_lossy().into_owned();
+        if !trans_set.contains(&file) {
+            outcomes.push(CompareOutcome {
+                item,
+                location,
+                ok: false,
+                output: "missing in translation".to_string(),
+            });
+            continue;
+        }
+        outcomes.push(match compare_signature(&ref_dir.join(&file), &trans_path) {
+            Ok(()) => CompareOutcome {
+                item,
+                location,
+                ok: true,
+                output: String::new(),
+            },
+            Err(diffs) => CompareOutcome {
+                item,
+                location,
+                ok: false,
+                output: diffs.join("\n"),
+            },
+        });
+    }
+
+    for file in trans_set.difference(&ref_set) {
+        let item = format!("{prefix}-{}", snake_case!(&stem_of(file)));
+        let trans_path = trans_dir.join(file);
+        outcomes.push(CompareOutcome {
+            item,
+            location: trans_path.to_string_lossy().into_owned(),
+            ok: false,
+            output: "extra file, not in reference".to_string(),
+        });
+    }
+    outcomes
+}
+
+/// Exports the outcomes via `reporter`; failures also print to stderr.
+fn export_outcomes(outcomes: &[CompareOutcome]) -> usize {
+    let mut fail_count = 0;
+    for outcome in outcomes {
+        if outcome.ok {
+            reporter::export(&outcome.item, &outcome.location, ReportResult::Ok);
+        } else {
+            fail_count += 1;
+            eprintln!("  {} {}", "failed".bright_red(), outcome.item);
+            eprintln!("  {}\n{}", outcome.location, outcome.output);
+            reporter::export(
+                &outcome.item,
+                &outcome.location,
+                ReportResult::Error(outcome.output.clone()),
+            );
+        }
+    }
+    fail_count
+}
+
+/// Reads `dev/configs/docs-lang.txt`: the first line is the reference directory
+/// (relative to `./docs/`), the rest are translations that must mirror it.
+fn lang_config() -> Option> {
+    let content = std::fs::read_to_string(LANG_CONFIG).ok()?;
+    Some(
+        content
+            .lines()
+            .map(str::trim)
+            .filter(|l| !l.is_empty() && !l.starts_with('#'))
+            .map(|l| l.trim_start_matches("./").to_string())
+            .collect(),
+    )
+}
+
+/// Turns a lang directory path into a report-item key, e.g.
+/// `./_zh_CN/pages/` → `_zh_CN_pages`.
+fn lang_key(lang: &str) -> String {
+    lang.trim_matches('/').replace('/', "_")
+}
+
+fn cwd() -> PathBuf {
+    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
+}
+
+/// Number of files that failed the structure comparison.
+#[derive(Grouped)]
+pub struct ResultMarkdownCompare {
+    pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any comparison failed.
+#[renderer(buffer)]
+pub fn render_markdown_compare(r: ResultMarkdownCompare, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
diff --git a/dev/ci/src/task/cmd_test.rs b/dev/ci/src/task/cmd_test.rs
new file mode 100644
index 0000000..5b9f55a
--- /dev/null
+++ b/dev/ci/src/task/cmd_test.rs
@@ -0,0 +1,54 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use mingling::{
+    Grouped, Routable,
+    macros::{buffer, command, renderer},
+    res::ResExitCode,
+};
+
+use crate::Next;
+use crate::res::{Manifests, ResCrateConfig};
+use crate::task::run::{location, run_parallel_checks};
+
+#[command(node = "test-all")]
+pub async fn test_all(manifests: &Manifests, config: &ResCrateConfig) -> Next {
+    let tasks = manifests
+        .package_dirs
+        .iter()
+        .map(|(name, path)| {
+            let args = config.test_command(name).map_or_else(
+                || test_args(path),
+                |cmd| cmd.iter().map(|s| OsString::from(s.as_str())).collect(),
+            );
+            (name.clone(), location(path), args)
+        })
+        .collect();
+    let fail_count = run_parallel_checks("Test-All", "Testing", tasks).await;
+    ResultTestAll { fail_count }.to_chain()
+}
+
+/// Default: `cargo test --manifest-path ` (crates without a
+/// `mingling-ci.toml` override).
+fn test_args(path: &Path) -> Vec {
+    vec![
+        "cargo".into(),
+        "test".into(),
+        "--manifest-path".into(),
+        path.as_os_str().to_os_string(),
+    ]
+}
+
+/// Number of packages that failed tests.
+#[derive(Grouped)]
+pub struct ResultTestAll {
+    pub fail_count: usize,
+}
+
+/// Silently sets a non-zero exit code when any test failed.
+#[renderer(buffer)]
+pub fn render_test_all(r: ResultTestAll, exit_code: &mut ResExitCode) {
+    if r.fail_count > 0 {
+        exit_code.exit_code = 1;
+    }
+}
diff --git a/dev/ci/src/task/run.rs b/dev/ci/src/task/run.rs
new file mode 100644
index 0000000..ba752dd
--- /dev/null
+++ b/dev/ci/src/task/run.rs
@@ -0,0 +1,114 @@
+use std::ffi::OsString;
+use std::path::Path;
+
+use colored::Colorize;
+
+use crate::progress::task_progress_bar;
+use crate::reporter::{self, ReportResult};
+
+/// The manifest's parent directory, e.g. `./mingling` — the report location
+/// for a crate-based item.
+pub(crate) fn location(path: &Path) -> String {
+    path.parent()
+        .map_or_else(|| ".".to_string(), |d| d.to_string_lossy().into_owned())
+}
+
+/// Outcome of a `cargo` subcommand.
+struct CargoResult {
+    ok: bool,
+    exit_code: Option,
+    output: String,
+}
+
+/// Runs the given cargo task list in parallel.
+///
+/// Each task is an `(item, location, argv)` triple; progress and failures go
+/// to stderr: a failing task prints its output immediately and writes its
+/// report entry at the same time. Returns the number of failing tasks.
+pub(crate) async fn run_parallel_checks(
+    task: &str,
+    phase: &str,
+    tasks: Vec<(String, String, Vec)>,
+) -> usize {
+    reporter::set_task(task);
+
+    let n = tasks.len();
+    let pb = task_progress_bar(n, phase);
+    pb.set_message("tasks");
+
+    // Run each task in parallel.
+    let mut set = tokio::task::JoinSet::new();
+    for (item, location, args) in tasks {
+        set.spawn(async move { (item, location, run_cargo(args).await) });
+    }
+
+    let mut fail_count = 0;
+    while let Some(joined) = set.join_next().await {
+        let Ok((item, location, result)) = joined else {
+            continue;
+        };
+        pb.inc(1);
+        pb.set_message(item.clone());
+
+        if result.ok {
+            reporter::export(&item, &location, ReportResult::Ok);
+        } else {
+            fail_count += 1;
+            // Failures print to stderr immediately (bar suspended to avoid
+            // interleaving) and write their report entry at the same time.
+            pb.suspend(|| {
+                eprintln!(
+                    "{}: {} failed{}",
+                    phase.bold().bright_cyan(),
+                    item,
+                    result
+                        .exit_code
+                        .map_or_else(String::new, |c| format!(" (exit code {c})"))
+                );
+                for line in result.output.lines() {
+                    eprintln!("  {line}");
+                }
+            });
+            reporter::export(&item, &location, ReportResult::Error(result.output));
+        }
+    }
+
+    pb.finish_and_clear();
+    reporter::flush();
+    fail_count
+}
+
+/// Runs a `cargo` subcommand, capturing its output.
+/// Runs a cargo subcommand (`argv[0]` is the program), capturing its output.
+async fn run_cargo(argv: Vec) -> CargoResult {
+    let mut argv = argv.into_iter();
+    let Some(program) = argv.next() else {
+        return CargoResult {
+            ok: false,
+            exit_code: None,
+            output: "empty command".to_string(),
+        };
+    };
+
+    let output = tokio::process::Command::new(program)
+        .args(argv)
+        .output()
+        .await;
+
+    match output {
+        Ok(output) => {
+            let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+            log.push_str(&String::from_utf8_lossy(&output.stderr));
+            CargoResult {
+                ok: output.status.success(),
+                exit_code: output.status.code(),
+                output: log,
+            }
+        }
+        Err(e) => CargoResult {
+            ok: false,
+            exit_code: None,
+            output: format!("failed to run cargo: {e}"),
+        },
+    }
+}
diff --git a/dev/ci/src/tools.rs b/dev/ci/src/tools.rs
new file mode 100644
index 0000000..13c2ec4
--- /dev/null
+++ b/dev/ci/src/tools.rs
@@ -0,0 +1,3 @@
+pub(crate) mod docsify_refresh;
+pub(crate) mod example_refresh;
+pub(crate) mod features_refresh;
diff --git a/dev/ci/src/tools/docsify_refresh.rs b/dev/ci/src/tools/docsify_refresh.rs
new file mode 100644
index 0000000..dfb9b11
--- /dev/null
+++ b/dev/ci/src/tools/docsify_refresh.rs
@@ -0,0 +1,373 @@
+//! Docsify maintenance: fix code-box blank lines and regenerate `_sidebar.md`
+//! files under `docs/`.
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use mingling::{
+    Grouped, RenderResult, Routable,
+    macros::{buffer, command, r_println, renderer},
+};
+
+use crate::Next;
+use crate::res::{CargoError, MessagePrinter};
+
+const DOCS_DIR: &str = "./docs";
+const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n";
+
+#[command(node = "docsify-refresh")]
+pub fn docsify_refresh() -> Next {
+    match refresh_all() {
+        Ok(written) => ResultDocsifyRefresh { written }.to_chain(),
+        Err(e) => ErrorDocsifyRefresh(e).to_chain(),
+    }
+}
+
+fn refresh_all() -> Result, String> {
+    let mut written = Vec::new();
+    written.extend(fix_code_boxes());
+    written.extend(gen_sidebars()?);
+    Ok(written)
+}
+
+/// Part 1: docsify renders code blocks poorly when the blank lines around
+/// them are completely empty — replace them with a single space.
+fn fix_code_boxes() -> Vec {
+    let mut file_count = 0;
+    let mut fixed_count = 0;
+    let mut written = Vec::new();
+
+    collect_md_files(Path::new(DOCS_DIR), &mut |path| {
+        if path
+            .file_name()
+            .is_some_and(|n| n.to_string_lossy().to_lowercase() == "_sidebar.md")
+        {
+            return;
+        }
+        let content = fs::read_to_string(path).unwrap_or_default();
+        if content.is_empty() {
+            return;
+        }
+        let new_content = fix_code_box_empty_lines(&content);
+        if new_content != content {
+            fs::write(path, &new_content).unwrap();
+            written.push(format!("fixed: {}", path.display()));
+            fixed_count += 1;
+        }
+        file_count += 1;
+    });
+
+    written.push(format!("scanned {file_count} files, fixed {fixed_count}"));
+    written
+}
+
+/// Replaces completely empty lines adjacent to fenced code blocks with lines
+/// containing a single space.
+fn fix_code_box_empty_lines(content: &str) -> String {
+    let mut result = String::new();
+    let lines: Vec<&str> = content.lines().collect();
+    let len = lines.len();
+
+    let mut i = 0;
+    while i < len {
+        let line = lines[i];
+        result.push_str(line);
+        result.push('\n');
+        i += 1;
+
+        if !line.trim_start().starts_with("```") {
+            continue;
+        }
+
+        // In a code block: find the closing fence.
+        let code_start = i;
+        let mut code_end = len;
+        let mut found_end = false;
+        while i < len {
+            let cline = lines[i];
+            if cline.trim_start().starts_with("```") && !cline.trim().is_empty() {
+                code_end = i;
+                found_end = true;
+                break;
+            }
+            i += 1;
+        }
+
+        ensure_space_before_code_block(&mut result);
+
+        for code_line in lines.iter().take(code_end).skip(code_start) {
+            if code_line.is_empty() {
+                result.push(' ');
+            } else {
+                result.push_str(code_line);
+            }
+            result.push('\n');
+        }
+
+        if found_end {
+            result.push_str(lines[code_end]);
+            result.push('\n');
+            i += 1;
+
+            if i < len && lines[i].trim().is_empty() && lines[i].is_empty() {
+                result.push(' ');
+                result.push('\n');
+                i += 1;
+            }
+        }
+    }
+
+    while result.ends_with('\n') {
+        result.pop();
+    }
+    result.push('\n');
+    result
+}
+
+/// Turns a trailing `\n\n` before a code block into `\n \n`.
+fn ensure_space_before_code_block(result: &mut String) {
+    let len = result.len();
+    if len >= 2 && &result[len - 2..] == "\n\n" {
+        result.insert(len - 1, ' ');
+    }
+}
+
+/// Part 2: find every README.md under `docs/` (each is a site root) and
+/// regenerate its `_sidebar.md`.
+fn gen_sidebars() -> Result, String> {
+    let mut written = Vec::new();
+    for readme_path in find_all_readmes(Path::new(DOCS_DIR)) {
+        let site_root = readme_path
+            .parent()
+            .ok_or_else(|| format!("{} has no parent", readme_path.display()))?;
+        if let Some(content_dir) = find_content_dir(site_root) {
+            let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD);
+            let sidebar_path = site_root.join("_sidebar.md");
+            fs::write(&sidebar_path, lines)
+                .map_err(|e| format!("failed to write {}: {e}", sidebar_path.display()))?;
+            written.push(format!("generated: {}", sidebar_path.display()));
+        }
+    }
+    Ok(written)
+}
+
+/// Recursively finds all README.md files under a directory.
+fn find_all_readmes(dir: &Path) -> Vec {
+    let mut results = Vec::new();
+    if let Ok(read_dir) = fs::read_dir(dir) {
+        let mut entries: Vec<_> = read_dir.flatten().collect();
+        entries.sort_by_key(std::fs::DirEntry::path);
+        for entry in entries {
+            let path = entry.path();
+            if path.is_dir() {
+                results.extend(find_all_readmes(&path));
+            } else if path.file_name().is_some_and(|n| n == "README.md") {
+                results.push(path);
+            }
+        }
+    }
+    results
+}
+
+/// The content directory of a site: `pages/` if present, else the first
+/// subdirectory containing markdown files.
+fn find_content_dir(site_root: &Path) -> Option {
+    let pages_dir = site_root.join("pages");
+    if pages_dir.is_dir() {
+        return Some(pages_dir);
+    }
+    if let Ok(read_dir) = fs::read_dir(site_root) {
+        let mut entries: Vec<_> = read_dir.flatten().collect();
+        entries.sort_by_key(std::fs::DirEntry::path);
+        for entry in entries {
+            let path = entry.path();
+            if path.is_dir() && has_markdown_files(&path) {
+                return Some(path);
+            }
+        }
+    }
+    None
+}
+
+fn has_markdown_files(dir: &Path) -> bool {
+    if let Ok(read_dir) = fs::read_dir(dir) {
+        for entry in read_dir.flatten() {
+            let path = entry.path();
+            if path.is_dir() {
+                if has_markdown_files(&path) {
+                    return true;
+                }
+            } else if path.extension().is_some_and(|ext| ext == "md") {
+                return true;
+            }
+        }
+    }
+    false
+}
+
+#[derive(Clone)]
+struct SidebarEntry {
+    title: String,
+    link: String,
+}
+
+/// Builds the sidebar content from the markdown files under `pages_dir`.
+fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String {
+    let mut lines = String::from(sidebar_head);
+
+    let mut root_files: Vec = Vec::new();
+    let mut sub_dirs: BTreeMap> = BTreeMap::new();
+
+    if let Ok(read_dir) = fs::read_dir(pages_dir) {
+        for entry in read_dir.flatten() {
+            let path = entry.path();
+            if path.is_dir() {
+                let dir_name = entry.file_name().to_string_lossy().into_owned();
+                let entries = collect_markdown_files(&path, base_dir);
+                if !entries.is_empty() {
+                    let display_name = get_directory_display_name(&path, &dir_name);
+                    sub_dirs.insert(display_name, entries);
+                }
+            } else if path.extension().is_some_and(|ext| ext == "md") {
+                root_files.push(SidebarEntry {
+                    title: extract_title(&path),
+                    link: relative_link(&path, base_dir),
+                });
+            }
+        }
+    }
+
+    root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link));
+    for f in &root_files {
+        let _ = writeln!(lines, "* [{}]({})", f.title, f.link);
+    }
+
+    for (dir_name, entries) in &sub_dirs {
+        let mut sorted_entries = entries.clone();
+        sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link));
+        let _ = writeln!(lines, "* {dir_name}");
+        for f in &sorted_entries {
+            let _ = writeln!(lines, "  * [{}]({})", f.title, f.link);
+        }
+    }
+
+    lines
+}
+
+/// All `.md` files directly under `dir`, as sidebar entries.
+fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec {
+    let mut entries = Vec::new();
+    if let Ok(read_dir) = fs::read_dir(dir) {
+        for entry in read_dir.flatten() {
+            let path = entry.path();
+            if path.extension().is_some_and(|ext| ext == "md") {
+                entries.push(SidebarEntry {
+                    title: extract_title(&path),
+                    link: relative_link(&path, base_dir),
+                });
+            }
+        }
+    }
+    entries
+}
+
+/// The link of a file relative to `base_dir`, without the `.md` suffix.
+fn relative_link(path: &Path, base_dir: &Path) -> String {
+    path.strip_prefix(base_dir)
+        .unwrap_or(path)
+        .to_string_lossy()
+        .replace('\\', "/")
+        .strip_suffix(".md")
+        .unwrap_or_default()
+        .to_string()
+}
+
+/// Extracts the title from the first line `

TITLE

`, +/// falling back to the file stem. +fn extract_title(path: &Path) -> String { + let content = fs::read_to_string(path).unwrap_or_default(); + if let Some(first_line) = content.lines().next() { + let trimmed = first_line.trim(); + if let Some(start) = trimmed.find('>') { + let after_start = &trimmed[start + 1..]; + if let Some(end) = after_start.find('<') { + return after_start[..end].to_string(); + } + } + } + path.file_stem().map_or_else( + || "Untitled".to_string(), + |s| s.to_string_lossy().into_owned(), + ) +} + +/// Reads a directory's `.name` file to override its sidebar display name. +fn get_directory_display_name(dir_path: &Path, fallback: &str) -> String { + let name_file = dir_path.join(".name"); + if name_file.is_file() { + fs::read_to_string(&name_file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| fallback.to_string()) + } else { + fallback.to_string() + } +} + +/// Numeric-aware comparison: `1-x` sorts before `10-x`, unnumbered last. +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + extract_leading_number(a) + .cmp(&extract_leading_number(b)) + .then_with(|| a.cmp(b)) +} + +/// The leading numeric prefix of a link's file stem, `usize::MAX` if absent. +fn extract_leading_number(link: &str) -> usize { + if let Some(file_stem) = link.rsplit('/').next() + && let Some(num_end) = file_stem.find('-') + && let Ok(num) = file_stem[..num_end].parse::() + { + return num; + } + usize::MAX +} + +/// Recursively collects all `.md` files under a directory. +fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_files(&path, callback); + } else if path.extension().is_some_and(|ext| ext == "md") { + callback(&path); + } + } + } +} + +/// Files written by `docsify-refresh`. +#[derive(Grouped)] +pub struct ResultDocsifyRefresh { + pub written: Vec, +} + +#[derive(Grouped, Default)] +pub struct ErrorDocsifyRefresh(pub String); + +#[renderer(buffer)] +pub fn render_docsify_refresh(r: ResultDocsifyRefresh) { + for item in r.written { + r_println!("{item}"); + } +} + +#[renderer] +pub fn render_error_docsify_refresh(e: ErrorDocsifyRefresh, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![e.0]); + render_result +} diff --git a/dev/ci/src/tools/example_refresh.rs b/dev/ci/src/tools/example_refresh.rs new file mode 100644 index 0000000..056ec60 --- /dev/null +++ b/dev/ci/src/tools/example_refresh.rs @@ -0,0 +1,279 @@ +//! Regenerates the example documentation module and the examples index. + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use just_fmt::snake_case; +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; +use serde::Serialize; + +use crate::Next; +use crate::res::{CargoError, MessagePrinter}; + +const EXAMPLE_ROOT: &str = "./examples"; +const EXAMPLE_DOCS_OUTPUT: &str = "./mingling/src/example_docs.rs"; +const EXAMPLE_DOCS_TEMPLATE: &str = include_str!("../../../../mingling/src/example_docs.rs.tmpl"); +const EXAMPLES_JSON_OUTPUT: &str = "./docs/examples.json"; + +#[command(node = "example-refresh")] +pub fn example_refresh() -> Next { + match refresh_all() { + Ok(written) => ResultExampleRefresh { written }.to_chain(), + Err(e) => ErrorExampleRefresh(e).to_chain(), + } +} + +fn refresh_all() -> Result, String> { + let mut written = Vec::new(); + written.extend(refresh_example_docs()?); + written.extend(sync_examples()?); + Ok(written) +} + +/// Part 1: regenerate `mingling/src/example_docs.rs` from the examples' +/// `src/main.rs` (header `//!` + code) and `Cargo.toml`. +fn refresh_example_docs() -> Result, String> { + let mut template = Template::from(EXAMPLE_DOCS_TEMPLATE); + + let mut examples = Vec::new(); + let entries = + fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with("example-") { + continue; + } + examples.push(ExampleContent::read(&name)); + } + examples.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut written = Vec::new(); + for example in examples { + template + .add_impl("examples".to_string()) + .push(HashMap::from([ + ("example_header".to_string(), example.header), + ("example_import".to_string(), example.cargo_toml), + ("example_code".to_string(), example.code), + ("example_name".to_string(), snake_case!(&example.name)), + ])); + written.push(format!("example_docs: {}", example.name)); + } + + let template_str = template.to_string(); + let template_str = template_str + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + + "\n"; + fs::write(EXAMPLE_DOCS_OUTPUT, template_str) + .map_err(|e| format!("failed to write {EXAMPLE_DOCS_OUTPUT}: {e}"))?; + written.push(format!("written: {EXAMPLE_DOCS_OUTPUT}")); + Ok(written) +} + +struct ExampleContent { + name: String, + header: String, + code: String, + cargo_toml: String, +} + +impl ExampleContent { + fn read(name: &str) -> Self { + let prefix = |s: &str| { + s.lines() + .map(|line| format!("/// {line}")) + .collect::>() + .join("\n") + }; + + let (header, code) = read_header_and_code(name); + Self { + name: name.to_string(), + header: prefix(&header), + code: prefix(&code), + cargo_toml: prefix(&read_cargo_toml(name)), + } + } +} + +/// Reads an example's `src/main.rs`, splitting `//!` doc header from code. +fn read_header_and_code(name: &str) -> (String, String) { + let content = fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("src/main.rs")) + .unwrap_or_default(); + let mut lines = content.lines(); + let mut header = String::new(); + let mut code = String::new(); + + for line in lines.by_ref() { + if line.trim_start().starts_with("//!") { + header.push_str(line.trim_start_matches("//!")); + header.push('\n'); + } else { + code.push_str(line); + code.push('\n'); + break; + } + } + for line in lines { + code.push_str(line); + code.push('\n'); + } + + (header.trim().to_string(), code.trim().to_string()) +} + +fn read_cargo_toml(name: &str) -> String { + fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("Cargo.toml")).unwrap_or_default() +} + +/// Part 2: regenerate `docs/example-pages/examples.json` from each example's +/// `page.toml`. +fn sync_examples() -> Result, String> { + fs::create_dir_all("docs/example-pages") + .map_err(|e| format!("failed to create docs/example-pages: {e}"))?; + + let mut examples = Vec::new(); + let entries = + fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let dir_name = entry.file_name().to_string_lossy().into_owned(); + let page_toml = path.join("page.toml"); + if !page_toml.is_file() { + continue; + } + let Ok(content) = fs::read_to_string(&page_toml) else { + continue; + }; + let Ok(table) = content.parse::() else { + eprintln!("Warning: failed to parse {}", page_toml.display()); + continue; + }; + let Some(example) = table.get("example") else { + continue; + }; + + let get = |key: &str| { + example + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or_default() + }; + let str_vec = |key: &str| { + example + .get(key) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + + let id = get("id"); + examples.push(ExampleMeta { + id: if id.is_empty() { + dir_name.clone() + } else { + id.to_string() + }, + name: { + let name = get("name"); + if name.is_empty() { + dir_name.clone() + } else { + name.to_string() + } + }, + icon: { + let icon = get("icon"); + if icon.is_empty() { + "📦".to_string() + } else { + icon.to_string() + } + }, + category: get("category").to_string(), + desc: get("desc").to_string(), + tags: str_vec("tags"), + files: { + let files = str_vec("files"); + if files.is_empty() { + vec!["Cargo.toml".to_string(), "src/main.rs".to_string()] + } else { + files + } + }, + }); + } + + // Basic first, then alphabetical. + examples.sort_by( + |a, b| match (a.id == "example-basic", b.id == "example-basic") { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.id.cmp(&b.id), + }, + ); + + let json = serde_json::to_string_pretty(&examples) + .map_err(|e| format!("failed to serialize examples: {e}"))?; + fs::write(EXAMPLES_JSON_OUTPUT, json) + .map_err(|e| format!("failed to write {EXAMPLES_JSON_OUTPUT}: {e}"))?; + + Ok(vec![format!( + "synced: {} examples -> {EXAMPLES_JSON_OUTPUT}", + examples.len() + )]) +} + +/// One entry of `docs/example-pages/examples.json`. +#[derive(Serialize)] +struct ExampleMeta { + id: String, + name: String, + icon: String, + category: String, + desc: String, + tags: Vec, + files: Vec, +} + +/// Files written by `example-refresh`. +#[derive(Grouped)] +pub struct ResultExampleRefresh { + pub written: Vec, +} + +#[derive(Grouped, Default)] +pub struct ErrorExampleRefresh(pub String); + +#[renderer(buffer)] +pub fn render_example_refresh(r: ResultExampleRefresh) { + for item in r.written { + r_println!("{item}"); + } +} + +#[renderer] +pub fn render_error_example_refresh(e: ErrorExampleRefresh, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![e.0]); + render_result +} diff --git a/dev/ci/src/tools/features_refresh.rs b/dev/ci/src/tools/features_refresh.rs new file mode 100644 index 0000000..6776fb0 --- /dev/null +++ b/dev/ci/src/tools/features_refresh.rs @@ -0,0 +1,96 @@ +//! Regenerates `mingling/src/features.rs` from the `[features]` section of +//! `mingling/Cargo.toml`. + +use std::collections::HashMap; +use std::fs; + +use just_fmt::snake_case; +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; + +use crate::Next; +use crate::res::{CargoError, MessagePrinter}; + +const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml"; +const OUTPUT_PATH: &str = "./mingling/src/features.rs"; +const TEMPLATE_CONTENT: &str = include_str!("../../../../mingling/src/features.rs.tmpl"); + +#[command(node = "features-refresh")] +pub fn features_refresh() -> Next { + match gen_feature_module() { + Ok(written) => ResultFeaturesRefresh { written }.to_chain(), + Err(e) => ErrorFeaturesRefresh(e).to_chain(), + } +} + +fn gen_feature_module() -> Result, String> { + let features = parse_features()?; + + let mut template = Template::from(TEMPLATE_CONTENT); + let mut written = Vec::new(); + for feat_name in &features { + let feat_const_name = snake_case!(feat_name).to_uppercase(); + template + .add_impl("features".to_string()) + .push(HashMap::from([ + ("feat_name".to_string(), feat_name.clone()), + ("feat_const_name".to_string(), feat_const_name), + ])); + written.push(format!("feature: {feat_name}")); + } + + let template_str = template.to_string(); + let template_str = template_str + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + + "\n"; + fs::write(OUTPUT_PATH, template_str) + .map_err(|e| format!("failed to write {OUTPUT_PATH}: {e}"))?; + written.push(format!("written: {OUTPUT_PATH}")); + Ok(written) +} + +/// All feature names from the `[features]` section, sorted. +fn parse_features() -> Result, String> { + let content = fs::read_to_string(CARGO_TOML_PATH) + .map_err(|e| format!("failed to read {CARGO_TOML_PATH}: {e}"))?; + let table: toml::Value = content + .parse() + .map_err(|e| format!("failed to parse {CARGO_TOML_PATH}: {e}"))?; + let features = table + .get("features") + .and_then(|v| v.as_table()) + .ok_or_else(|| format!("no [features] section in {CARGO_TOML_PATH}"))?; + + let mut names: Vec = features.keys().cloned().collect(); + names.sort(); + Ok(names) +} + +/// Feature names written by `features-refresh`. +#[derive(Grouped)] +pub struct ResultFeaturesRefresh { + pub written: Vec, +} + +#[derive(Grouped, Default)] +pub struct ErrorFeaturesRefresh(pub String); + +#[renderer(buffer)] +pub fn render_features_refresh(r: ResultFeaturesRefresh) { + for item in r.written { + r_println!("{item}"); + } +} + +#[renderer] +pub fn render_error_features_refresh(e: ErrorFeaturesRefresh, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![e.0]); + render_result +} diff --git a/dev/ci/tmpls/report.md b/dev/ci/tmpls/report.md new file mode 100644 index 0000000..020fda2 --- /dev/null +++ b/dev/ci/tmpls/report.md @@ -0,0 +1,9 @@ +

Mingling CI Results

+ +

<<>> - <<>>

+ +>>>>>>>>>> task_sections +@@@ >>> task_sections +<<
>> + +@@@ <<< diff --git a/dev/ci/tmpls/task_section.md b/dev/ci/tmpls/task_section.md new file mode 100644 index 0000000..78c9801 --- /dev/null +++ b/dev/ci/tmpls/task_section.md @@ -0,0 +1,18 @@ +## Task: <<>> + +| Item-Name | Location | PASS (Windows) | PASS (Linux) | PASS (Mac OS) | +| ----------- | -------- | -------------- | ------------ | ------------- | +>>>>>>>>>> rows +@@@ >>> rows +| <<>> | <<>> | <<>> | <<>> | <<>> | +@@@ <<< + +>>>>>>>>>> fails +@@@ >>> fails +### Fail: <<>> + +```stdout +<<>> +``` + +@@@ <<< diff --git a/dev/configs/ci-ignored-dirs.txt b/dev/configs/ci-ignored-dirs.txt new file mode 100644 index 0000000..60b9e6e --- /dev/null +++ b/dev/configs/ci-ignored-dirs.txt @@ -0,0 +1,5 @@ +# Temp +./.temp/ + +# Self +./dev/ci/ diff --git a/dev/configs/docs-lang.txt b/dev/configs/docs-lang.txt new file mode 100644 index 0000000..96d4f3c --- /dev/null +++ b/dev/configs/docs-lang.txt @@ -0,0 +1,2 @@ +./pages/ +./_zh_CN/pages/ diff --git a/dev/configs/rust-analyzer.json b/dev/configs/rust-analyzer.json new file mode 100644 index 0000000..ab97bf7 --- /dev/null +++ b/dev/configs/rust-analyzer.json @@ -0,0 +1,16 @@ +{ + "rust-analyzer.check.command": "clippy", + "rust-analyzer.checkOnSave": true, + "rust-analyzer.files.exclude": ["**/target/**", "**/.temp/**"], + "rust-analyzer.linkedProjects": [ + ".run/Cargo.toml", + "dev/ci/Cargo.toml", + "mingling_pathf/test/Cargo.toml", + "arg_picker/Cargo.toml", + "arg_picker/test/Cargo.toml", + "mingling_cli/Cargo.toml" + ], + "rust-analyzer.cargo.features": [], + "rust-analyzer.procMacro.enable": true, + "rust-analyzer.procMacro.attributes.enable": true +} diff --git a/dev/configs/verified-docs.toml b/dev/configs/verified-docs.toml new file mode 100644 index 0000000..df2469b --- /dev/null +++ b/dev/configs/verified-docs.toml @@ -0,0 +1,8 @@ +# Files marked in the following document, +# all rust code blocks inside will be verified in CI to ensure they can compile + +[verified] +readme = "./README.md" +getting_started = "./GETTING-STARTED.md" +documents_en_us = "./docs/pages/**" +documents_zh_cn = "./docs/_zh_CN/pages/**" diff --git a/dev/configs/version-files.toml b/dev/configs/version-files.toml new file mode 100644 index 0000000..30fda5e --- /dev/null +++ b/dev/configs/version-files.toml @@ -0,0 +1,39 @@ +[[file]] +file = "./Cargo.toml" +pattern = "version = \"{VER}\"" + +[[file]] +file = "./mingling_cli/Cargo.toml" +pattern = "version = \"{VER}\"" + +[[file]] +file = "./README.md" +pattern = "version = \"{VER}\"" + +[[file]] +file = "./docs/_zh_CN/pages/1-getting-started.md" +pattern = "version = \"{VER}\"" + +[[file]] +file = "./docs/pages/1-getting-started.md" +pattern = "version = \"{VER}\"" + +[[file]] +file = "./docs/res/guide.txt" +pattern = "mingling = \"{VER}\"" + +[[file]] +file = "./index.html" +pattern = "cargo add mingling@{VER}" + +[[file]] +file = "./index.html" +pattern = "version = \"{VER}\"" + +[[file]] +file = "./index.html" +pattern = "mling proj-init {VER}@basic" + +[[file]] +file = "./dist/index.html" +pattern = "mling proj-init {VER}@basic" diff --git a/dev/run/.gitignore b/dev/run/.gitignore new file mode 100644 index 0000000..40f407e --- /dev/null +++ b/dev/run/.gitignore @@ -0,0 +1,5 @@ +# All temp build artifacts will be stored in this dir +/target + +# If you want to add some Rust crates? Remove this line +# /Cargo.* diff --git a/dev/run/Cargo.lock b/dev/run/Cargo.lock new file mode 100644 index 0000000..3117d0b --- /dev/null +++ b/dev/run/Cargo.lock @@ -0,0 +1,572 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arg-picker" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd62c395708a956e98e06b6b18200b2a65a5ae63448ed7d59cca32833aaf7265" +dependencies = [ + "arg-picker-macros", + "just_fmt 0.2.0", +] + +[[package]] +name = "arg-picker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04fddbb5c1f26450cfc6aded0559b759b292d400dba1ff4e45128f3e0e1ffe6b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "just_fmt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5454cda0d57db59778608d7a47bff5b16c6705598265869fb052b657f66cf05e" + +[[package]] +name = "just_fmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6170dccbc3ea15dfb7f2da964097f814aba1dd8f746d4ffc56f33245c38e6d96" + +[[package]] +name = "just_template" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3edb658c34b10b69c4b3b58f7ba989cd09c82c0621dee1eef51843c2327225" +dependencies = [ + "just_fmt 0.1.2", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tools" +version = "0.1.0" +dependencies = [ + "arg-picker", + "colored", + "flate2", + "indicatif", + "just_fmt 0.1.2", + "just_template", + "serde", + "serde_json", + "tar", + "tokio", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/dev/run/Cargo.toml b/dev/run/Cargo.toml new file mode 100644 index 0000000..4935f2d --- /dev/null +++ b/dev/run/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tools" +version = "0.1.0" +edition = "2024" +authors = ["Weicao-CatilGrass"] +description = "Development tools for mingling" +license = "MIT OR Apache-2.0" +repository = "https://github.com/catilgrass/mingling" +readme = "../README.md" +keywords = ["cli", "development", "tools"] +categories = ["command-line-interface", "development-tools"] + +[dependencies] +just_template = "0.1.3" +just_fmt = "0.1.2" +colored = "3.1.1" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +indicatif = "0.18.4" +flate2 = "1" +tar = "0.4" +arg-picker = "0.1.0" + +[workspace] diff --git a/dev/run/src/bin/ci.py b/dev/run/src/bin/ci.py new file mode 100644 index 0000000..6234a19 --- /dev/null +++ b/dev/run/src/bin/ci.py @@ -0,0 +1,75 @@ +"""Full CI orchestration for the mingling project. + +Runs every `cargo ci` step in order: lock the workspace, run all checks, +refresh the generated artifacts, then unlock. The final `git-unlock` doubles +as the idempotency check: it fails with a non-zero exit code when the run +left the working tree dirty. + +The script locates the git repository root and runs with it as the working +directory, so it can be invoked from anywhere inside the repo. +""" + +import os +import subprocess +import sys +from pathlib import Path + +# The pipeline steps, in execution order, as (command, args) pairs. +STEPS: list[tuple[str, list[str]]] = [ + ("git-lock", []), + ("report-clean", []), + ("build-check", []), + ("clippy-check", []), + ("test-all", []), + ("example-check", []), + ("docs-check", []), + ("example-refresh", []), + ("docsify-refresh", []), + ("features-refresh", []), + # Idempotency check: exits non-zero if CI contaminated the workspace, and + # prints the diff of the contamination before restoring. + ("git-unlock", ["--show-diff"]), +] + + +def find_repo_root() -> Path: + """Return the nearest ancestor directory containing `.git`.""" + current = Path.cwd() + for directory in (current, *current.parents): + if (directory / ".git").is_dir(): + return directory + raise SystemExit("error: not inside a git repository") + + +def main() -> int: + root = find_repo_root() + os.chdir(root) + + # Signature banner: docs/res/ci_banner.txt, relative to this script + # (.run/src/bin -> four levels up is the repo root). + banner = ( + Path(__file__).resolve().parent.parent.parent.parent + / "docs" + / "res" + / "ci_banner.txt" + ) + try: + print(banner.read_text(encoding="utf-8"), end="") + except OSError: + pass + + for command, args in STEPS: + print(f"==> cargo ci {' '.join([command, *args])}") + result = subprocess.run(["cargo", "ci", command, *args], check=False) + if result.returncode != 0: + print( + f"error: step `{command}` failed with exit code {result.returncode}", + file=sys.stderr, + ) + return result.returncode + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dev/run/src/bin/clippy-fix.ps1 b/dev/run/src/bin/clippy-fix.ps1 new file mode 100644 index 0000000..1d24f92 --- /dev/null +++ b/dev/run/src/bin/clippy-fix.ps1 @@ -0,0 +1,8 @@ +$starting_dir = Get-Location +Get-ChildItem -Recurse -Filter "Cargo.toml" | ForEach-Object { + $project_dir = $_.DirectoryName + Push-Location $project_dir + cargo clippy --fix --allow-dirty --allow-no-vcs --quiet + Pop-Location +} +Set-Location $starting_dir diff --git a/dev/run/src/bin/clippy-fix.sh b/dev/run/src/bin/clippy-fix.sh new file mode 100755 index 0000000..9771ad4 --- /dev/null +++ b/dev/run/src/bin/clippy-fix.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +find . -name "Cargo.toml" -type f | while read -r cargo_file; do + project_dir=$(dirname "$cargo_file") + (cd "$project_dir" && cargo clippy --fix --allow-dirty --allow-no-vcs --quiet) +done diff --git a/dev/run/src/bin/cov-test.rs b/dev/run/src/bin/cov-test.rs new file mode 100644 index 0000000..f62ff01 --- /dev/null +++ b/dev/run/src/bin/cov-test.rs @@ -0,0 +1,571 @@ +//! Coverage test generator for mingling. +//! +//! This script requires the **fork** of cargo-llvm-cov: +//! +//! +//! The upstream `report` command cannot include binaries of non-workspace +//! crates (examples and test crates) and unconditionally filters +//! `tests`/`examples` source files. The fork adds two flags to fix this: +//! +//! - `--object `: include arbitrary binaries in the report +//! (upstream issue taiki-e/cargo-llvm-cov#367) +//! - `--include-examples`: stop filtering source files under the +//! `examples` directory (upstream issue taiki-e/cargo-llvm-cov#503) +//! +//! The script itself does not use `--include-examples`; it passes +//! `--no-default-ignore-filename-regex` and supplies its own filter so that +//! `tests`/`benches` directories stay in the report too. +//! +//! Install it with: +//! +//! ```bash +//! cargo install --git https://github.com/Weicao-CatilGrass/cargo-llvm-cov cargo-llvm-cov +//! ``` + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use tools::{eprintln_cargo_style, println_cargo_style, run_cmd}; + +const OUTPUT_DIR: &str = "docs/cov-test"; + +/// Shared target directory for all `cargo llvm-cov` runs. +/// +/// Pointing every run at the same target dir makes all of them share the +/// instrumented build cache and, more importantly, accumulate profraw files +/// in one place so the final `report` can merge everything. +const COV_TARGET_DIR: &str = ".temp/cov-llvm"; + +/// An example's `test.toml` (`[[runs]]` entries). +#[derive(Deserialize)] +struct TestConfig { + runs: Vec, +} + +/// One `[[runs]]` entry of an example's `test.toml`. +#[derive(Deserialize)] +struct TestCase { + input: Vec, +} + +fn main() { + let repo_root = find_git_repo().expect("Failed to find git repository root"); + let output_path = repo_root.join(OUTPUT_DIR); + let cov_target = repo_root.join(COV_TARGET_DIR); + + // Read features from [package.metadata.docs.rs] + let features = tools::read_features().unwrap_or_else(|e| { + eprintln!("Error: {}", e); + std::process::exit(1); + }); + let features_arg = features.join(","); + + // Ensure output directory exists + std::fs::create_dir_all(&output_path).expect("Failed to create output directory"); + std::fs::create_dir_all(&cov_target).expect("Failed to create cov target directory"); + + // All `cargo llvm-cov` invocations below share one target dir, so profraw + // files accumulate and are merged by the final `report` command. + // SAFETY: set before any thread is spawned; this process only shells out + // to subcommands via std::process. + unsafe { + std::env::set_var("CARGO_LLVM_COV_TARGET_DIR", &cov_target); + } + + // Drop stale profraw from previous runs (keep the instrumented build cache). + clean_old_profraw(&cov_target); + + println_cargo_style!("Features: {}", features_arg); + println_cargo_style!("Target: {}", cov_target.display()); + + // 1. Workspace tests + println_cargo_style!("Running: cargo llvm-cov test --workspace"); + run_cmd!(format!( + "cargo llvm-cov test --no-report --workspace --features \"{}\" --color always", + features_arg + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!("workspace tests failed with exit code {}", code); + std::process::exit(code); + }); + + // 2. Integration test crates under mingling_core/tests (excluded from the + // workspace, so they need their own `--manifest-path` runs) + for manifest in find_test_crate_manifests(&repo_root) { + println_cargo_style!( + "Running: cargo llvm-cov test {}", + manifest.file_name().unwrap_or_default().to_string_lossy() + ); + run_cmd!(format!( + "cargo llvm-cov test --no-report --manifest-path \"{}\" --color always", + manifest.display() + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!( + "test crate {} failed with exit code {}", + manifest.display(), + code + ); + std::process::exit(code); + }); + } + + // 3. Examples: build each example with explicit RUSTFLAGS, then execute + // every command declared in the example's test.toml directly. + // + // NOTE: `cargo llvm-cov run` cannot be used here. Its rustc wrapper + // only instruments the crates of the *current* cargo project (with + // `--manifest-path` that is the example itself), so the mingling + // libraries — being dependencies — would not be instrumented and their + // coverage would silently be lost (once_exec.rs showed 0%). Building + // with plain RUSTFLAGS instruments the whole dependency graph. + // + // RUSTFLAGS/CARGO_TARGET_DIR are set process-wide here because only the + // `report` step (which does not compile) follows. Non-zero exit codes + // are expected for some examples (e.g. `--help` exits with 2); profraw + // is still written. + unsafe { + std::env::set_var("RUSTFLAGS", "-Cinstrument-coverage"); + std::env::set_var("CARGO_TARGET_DIR", &cov_target); + } + let examples = load_example_commands(&repo_root); + let mut built = std::collections::HashSet::new(); + for (example, input) in &examples { + if built.insert(example.clone()) { + println_cargo_style!("Building: {}", example); + run_cmd!(format!( + "cargo build --manifest-path examples/{}/Cargo.toml --color always", + example + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!( + "build of example {} failed with exit code {}", + example, + code + ); + std::process::exit(code); + }); + } + let binary = cov_target.join("debug").join(get_binary_name(example)); + let profraw = format!( + "{}/example-{}.%p.profraw", + cov_target.to_string_lossy(), + example + ); + match std::process::Command::new(&binary) + .args(input) + .env("LLVM_PROFILE_FILE", &profraw) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => println_cargo_style!( + "Warning: example {} exited with {:?}, profraw still recorded", + example, + status.code() + ), + Err(e) => eprintln_cargo_style!("Failed to run example {}: {}", example, e), + } + } + + // 4. Collect the binaries of non-workspace crates (examples + test crates). + // The automatic object-file detection only knows workspace members, so + // these must be passed explicitly via --object. + let member_names = workspace_member_names(&repo_root); + let object_args = collect_object_args(&cov_target, &member_names); + + // 5. Generate the merged HTML report. + // + // --no-default-ignore-filename-regex: the default regex unconditionally + // excludes `examples`/`tests` directories, which is exactly what we want + // to include here, so we take over the filter ourselves. + let ignore_re = build_ignore_regex(&cov_target); + println_cargo_style!("Running: cargo llvm-cov report --html"); + run_cmd!(format!( + "cargo llvm-cov report --html --output-dir \"{}\" --no-default-ignore-filename-regex --ignore-filename-regex \"{}\" {} --color always", + output_path.to_string_lossy(), + ignore_re, + object_args + )) + .unwrap_or_else(|code| { + eprintln_cargo_style!("cargo llvm-cov report failed with exit code {}", code); + std::process::exit(code); + }); + + // Move files from /html/ to + let html_dir = output_path.join("html"); + if html_dir.exists() && html_dir.is_dir() { + println_cargo_style!("Moving files from {}/html/ to {}/", OUTPUT_DIR, OUTPUT_DIR); + + for entry in fs::read_dir(&html_dir).expect("Failed to read html directory") { + let entry = entry.expect("Failed to read entry"); + let entry_path = entry.path(); + let file_name = entry + .file_name() + .to_str() + .expect("Invalid filename") + .to_owned(); + + let dest_path = output_path.join(&file_name); + if dest_path.exists() { + if dest_path.is_dir() { + fs::remove_dir_all(&dest_path).unwrap_or_else(|e| { + eprintln!( + "Warning: could not remove directory {}: {}", + dest_path.display(), + e + ); + }); + } else { + fs::remove_file(&dest_path).unwrap_or_else(|e| { + eprintln!( + "Warning: could not remove file {}: {}", + dest_path.display(), + e + ); + }); + } + } + fs::rename(&entry_path, &dest_path).unwrap_or_else(|e| { + eprintln!("Warning: could not move {}: {}", entry_path.display(), e); + }); + } + + fs::remove_dir(&html_dir).unwrap_or_else(|e| { + eprintln!("Warning: could not remove html directory: {}", e); + }); + + println_cargo_style!("Files moved successfully."); + } + + // 6. Recolor the per-file coverage summary with project-specific + // thresholds: 0-50% red, 51-80% yellow, 81-100% green. llvm-cov's + // built-in thresholds differ, and the color is assigned when the HTML + // is generated, so the summary table is rewritten here. + let index_path = output_path.join("index.html"); + if let Err(e) = recolor_report_index(&index_path) { + eprintln_cargo_style!("Warning: failed to recolor {}: {}", index_path.display(), e); + } + + println_cargo_style!( + "Done: coverage report generated at {}/index.html", + OUTPUT_DIR + ); +} + +/// Remove `*.profraw` from the shared target dir so stale data from previous +/// runs does not pollute the merged report. The instrumented build cache +/// (everything else) is kept. +fn clean_old_profraw(cov_target: &Path) { + if let Ok(entries) = fs::read_dir(cov_target) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "profraw") { + let _ = fs::remove_file(&path); + } + } + } +} + +/// All `mingling_core/tests//Cargo.toml` manifests. +fn find_test_crate_manifests(repo_root: &Path) -> Vec { + let tests_dir = repo_root.join("mingling_core/tests"); + let mut manifests = Vec::new(); + if let Ok(entries) = fs::read_dir(&tests_dir) { + for entry in entries.flatten() { + let manifest = entry.path().join("Cargo.toml"); + if manifest.is_file() { + manifests.push(manifest); + } + } + } + manifests.sort(); + manifests +} + +/// Parse every `examples//test.toml` into `(example_name, input)` pairs. +fn load_example_commands(repo_root: &Path) -> Vec<(String, Vec)> { + let examples_dir = repo_root.join("examples"); + let mut entries: Vec<_> = std::fs::read_dir(&examples_dir) + .unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read {}: {}", examples_dir.display(), e); + std::process::exit(1); + }) + .flatten() + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let mut pairs = Vec::new(); + for entry in entries { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let test_toml = path.join("test.toml"); + if !test_toml.is_file() { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let content = fs::read_to_string(&test_toml).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to read {}: {}", test_toml.display(), e); + std::process::exit(1); + }); + let config: TestConfig = toml::from_str(&content).unwrap_or_else(|e| { + eprintln_cargo_style!("Failed to parse {}: {}", test_toml.display(), e); + std::process::exit(1); + }); + for case in config.runs { + pairs.push((name.clone(), case.input)); + } + } + pairs +} + +/// Names of all workspace members, from `cargo metadata --no-deps`. +fn workspace_member_names(repo_root: &Path) -> Vec { + let Ok(output) = tools::run_cmd_capture_with_dir( + "cargo metadata --no-deps --format-version 1".to_string(), + repo_root, + ) else { + return Vec::new(); + }; + let Ok(json) = serde_json::from_str::(&output) else { + return Vec::new(); + }; + json["packages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|p| p["name"].as_str().map(str::to_owned)) + .collect() +} + +/// Collect the binaries of non-workspace crates (examples and test crates) +/// from the shared target dir, as `--object ` arguments. +/// +/// - `debug/` root: example binaries (built via `cargo llvm-cov run`). +/// - `debug/deps/`: test crate binaries (e.g. `integration-`); their +/// names do not follow a single pattern, so anything that is not a +/// workspace-member binary and not a proc-macro `.so` is collected. +/// +/// Workspace member binaries are detected automatically by `report` and must +/// NOT be passed again (duplicate `-object` entries produce duplicated +/// output). Hard links to the same file are deduplicated by inode. +fn collect_object_args(cov_target: &Path, member_names: &[String]) -> String { + let debug_dir = cov_target.join("debug"); + let mut objects = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for dir in [debug_dir.clone(), debug_dir.join("deps")] { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || !is_executable(&path) { + continue; + } + if !seen.insert(file_id(&path)) { + continue; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + // Proc-macro shared objects are either workspace members (picked + // up automatically) or external deps (excluded from the report + // by the ignore regex), so never pass them explicitly. + if name.starts_with("lib") && name.ends_with(".so") { + continue; + } + if is_workspace_member_binary(name, member_names) { + continue; + } + objects.push(path); + } + } + + objects.sort(); + objects + .iter() + .map(|p| format!("--object \"{}\"", p.to_string_lossy())) + .collect::>() + .join(" ") +} + +/// True if the binary name (e.g. `mingling_core-fea14a01b88afcaa`) belongs to +/// a workspace member. +fn is_workspace_member_binary(name: &str, member_names: &[String]) -> bool { + let stem = strip_cargo_hash(name); + member_names.iter().any(|m| stem == m) +} + +/// Strip the cargo-generated hash suffix: `mingling_core-fea14a01b88afcaa` -> +/// `mingling_core`. Returns the input unchanged if there is no such suffix. +fn strip_cargo_hash(name: &str) -> &str { + let Some(idx) = name.rfind('-') else { + return name; + }; + let (head, tail) = name.split_at(idx); + let hash = &tail[1..]; + if hash.len() == 16 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + head + } else { + name + } +} + +/// A stable identity for deduplicating hard links: device+inode on Unix, +/// canonicalized path elsewhere. +fn file_id(path: &Path) -> String { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if let Ok(metadata) = fs::metadata(path) { + return format!("{}:{}", metadata.dev(), metadata.ino()); + } + } + fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +/// Resolve binary filename for the given example. +/// +/// The binary name matches the package name. On Windows, the `.exe` suffix is +/// required. +fn get_binary_name(example_name: &str) -> String { + let base = example_name; + if cfg!(target_os = "windows") { + format!("{base}.exe") + } else { + base.to_string() + } +} + +/// Rewrite the per-file coverage colors in `index.html` with project-specific +/// thresholds: 0-50% red, 51-80% yellow, 81-100% green. +fn recolor_report_index(index_path: &Path) -> std::io::Result<()> { + let content = fs::read_to_string(index_path)?; + fs::write(index_path, recolor_coverage_table(&content)) +} + +/// Recolor every `
XX% ...
` cell +/// in the coverage summary table according to the new thresholds. Cells with +/// no data (e.g. branch coverage `- (0/0)`, class `gray`) are left as-is. +fn recolor_coverage_table(input: &str) -> String { + const TD: &str = "
") else {
+            out.push_str(rest);
+            return out;
+        };
+        let color = &rest[..pre_end];
+        let tail = &rest[pre_end + "'>
".len()..];
+        let pct: String = tail
+            .trim_start()
+            .chars()
+            .take_while(|c| c.is_ascii_digit() || *c == '.')
+            .collect();
+        let new_color = match pct.parse::() {
+            Ok(v) if v <= 50.0 => "red",
+            Ok(v) if v <= 80.0 => "yellow",
+            Ok(_) => "green",
+            Err(_) => color, // no data (e.g. gray branch column)
+        };
+        out.push_str(new_color);
+        out.push_str("'>
");
+        rest = tail;
+    }
+    out.push_str(rest);
+    out
+}
+
+/// True if the file is executable: mode bits on Unix, `.exe` on Windows.
+fn is_executable(path: &Path) -> bool {
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt as _;
+        let Ok(metadata) = std::fs::metadata(path) else {
+            return false;
+        };
+        metadata.permissions().mode() & 0o111 != 0
+    }
+    #[cfg(not(unix))]
+    {
+        path.extension()
+            .is_some_and(|e| e.eq_ignore_ascii_case("exe"))
+    }
+}
+
+/// Regex that keeps only the project's own sources in the report:
+/// excludes the shared llvm-cov target dir, the standard library, and
+/// external dependencies.
+fn build_ignore_regex(cov_target: &Path) -> String {
+    let target = regex_escape_path(cov_target);
+    format!(
+        "^{target}($|/)|/rustc/([0-9a-f]+|[0-9]+\\.[0-9]+\\.[0-9]+)/|/\\.cargo/(registry|git)/|/\\.rustup/toolchains($|/)"
+    )
+}
+
+/// Escape a path for use inside a regular expression (as a literal prefix).
+fn regex_escape_path(path: &Path) -> String {
+    let s = path.to_string_lossy().replace('\\', "/");
+    let mut escaped = String::with_capacity(s.len());
+    for ch in s.chars() {
+        if ch == '.' || ch == '-' {
+            escaped.push('\\');
+        }
+        escaped.push(ch);
+    }
+    escaped
+}
+
+fn find_git_repo() -> Option {
+    let mut current_dir = std::env::current_dir().ok()?;
+
+    loop {
+        let git_dir = current_dir.join(".git");
+        if git_dir.exists() && git_dir.is_dir() {
+            return Some(current_dir);
+        }
+
+        if !current_dir.pop() {
+            break;
+        }
+    }
+
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use super::recolor_coverage_table;
+
+    #[test]
+    fn recolor_thresholds() {
+        let input = concat!(
+            "
  50.00% (2/4)
", + "
  51.23% (32/52)
", + "
  80.00% (48/89)
", + "
  81.00% (1/1)
", + "
  90.00% (6/7)
", + "
- (0/0)
", + ); + let out = recolor_coverage_table(input); + assert!(out.contains("class='column-entry-red'>
  50.00%"));
+        assert!(out.contains("class='column-entry-yellow'>
  51.23%"));
+        assert!(out.contains("class='column-entry-yellow'>
  80.00%"));
+        assert!(out.contains("class='column-entry-green'>
  81.00%"));
+        assert!(out.contains("class='column-entry-green'>
  90.00%"));
+        assert!(out.contains("class='column-entry-gray'>
- (0/0)"));
+    }
+}
diff --git a/dev/run/src/bin/deploy-api-docs.rs b/dev/run/src/bin/deploy-api-docs.rs
new file mode 100644
index 0000000..961eb04
--- /dev/null
+++ b/dev/run/src/bin/deploy-api-docs.rs
@@ -0,0 +1,118 @@
+use std::path::Path;
+
+use arg_picker::{Picker, macros::arg};
+use tools::{println_cargo_style, run_cmd};
+
+const OUTPUT_DIR: &str = "docs/api-docs";
+
+fn main() {
+    let using_docsrs = Picker::from_args()
+        .pick_or_default(&arg![docsrs: bool])
+        .unwrap();
+
+    let repo_root = find_git_repo().expect("Failed to find git repository root");
+
+    // Read features from [package.metadata.docs.rs]
+    let features = tools::read_features().unwrap_or_else(|e| {
+        eprintln!("Error: {}", e);
+        std::process::exit(1);
+    });
+
+    let features_arg = features.join(",");
+
+    // Ensure output directory exists
+    let output_path = repo_root.join(OUTPUT_DIR);
+    std::fs::create_dir_all(&output_path).expect("Failed to create output directory");
+
+    // Build cargo doc command
+    let cmd = if using_docsrs {
+        format!(
+            "cargo +nightly rustdoc --features \"{}\" -p mingling --target-dir \"{}\" --color always -- --cfg docsrs",
+            features_arg,
+            output_path.join("target").to_string_lossy()
+        )
+    } else {
+        format!(
+            "cargo doc --no-deps --features \"{}\" -p mingling --target-dir \"{}\" --color always",
+            features_arg,
+            output_path.join("target").to_string_lossy()
+        )
+    };
+
+    println_cargo_style!("Features: {}", features_arg);
+    println_cargo_style!("Output: {}", output_path.display());
+
+    // Run cargo doc, then copy generated docs to output directory
+    println_cargo_style!("Building: docs (cargo doc --no-deps)");
+    run_cmd!(&cmd).unwrap_or_else(|code| {
+        eprintln!("Error: cargo doc failed with exit code {}", code);
+        std::process::exit(code);
+    });
+
+    // Copy generated docs from target/doc to OUTPUT_DIR (top level)
+    let doc_source = output_path.join("target").join("doc");
+    let doc_dest = &output_path;
+
+    if doc_source.exists() {
+        println_cargo_style!("Copying: docs to output directory");
+        // Remove old docs in destination (except target/)
+        if let Ok(entries) = std::fs::read_dir(doc_dest) {
+            for entry in entries.flatten() {
+                let path = entry.path();
+                if path.file_name().and_then(|n| n.to_str()) == Some("target") {
+                    continue;
+                }
+                if path.is_dir() {
+                    std::fs::remove_dir_all(&path).ok();
+                } else {
+                    std::fs::remove_file(&path).ok();
+                }
+            }
+        }
+        copy_dir_recursively(&doc_source, doc_dest).expect("Failed to copy documentation");
+    }
+
+    // Clean up the intermediate target directory to save space
+    std::fs::remove_dir_all(output_path.join("target")).ok();
+
+    println_cargo_style!("Done: API docs deployed to {}", output_path.display());
+}
+
+fn copy_dir_recursively(src: &Path, dst: &Path) -> std::io::Result<()> {
+    if !dst.exists() {
+        std::fs::create_dir_all(dst)?;
+    }
+
+    for entry in std::fs::read_dir(src)? {
+        let entry = entry?;
+        let file_type = entry.file_type()?;
+        let src_path = entry.path();
+        let file_name = src_path.file_name().expect("Failed to get file name");
+        let dst_path = dst.join(file_name);
+
+        if file_type.is_dir() {
+            copy_dir_recursively(&src_path, &dst_path)?;
+        } else {
+            std::fs::copy(&src_path, &dst_path)?;
+        }
+    }
+
+    Ok(())
+}
+
+fn find_git_repo() -> Option {
+    let mut current_dir = std::env::current_dir().ok()?;
+
+    loop {
+        let git_dir = current_dir.join(".git");
+        if git_dir.exists() && git_dir.is_dir() {
+            return Some(current_dir);
+        }
+
+        if !current_dir.pop() {
+            break;
+        }
+    }
+
+    None
+}
diff --git a/dev/run/src/bin/display-dependency-order.rs b/dev/run/src/bin/display-dependency-order.rs
new file mode 100644
index 0000000..a31c67a
--- /dev/null
+++ b/dev/run/src/bin/display-dependency-order.rs
@@ -0,0 +1,12 @@
+use tools::{dependency_order::display_dependency_order, eprintln_cargo_style};
+
+fn main() {
+    let order = display_dependency_order();
+    if order.is_empty() {
+        eprintln_cargo_style!("could not find workspace root or mingling crates");
+        std::process::exit(1);
+    }
+    for path in order {
+        println!("{}", path.display());
+    }
+}
diff --git a/dev/run/src/bin/http-page-preview.ps1 b/dev/run/src/bin/http-page-preview.ps1
new file mode 100644
index 0000000..8cc3579
--- /dev/null
+++ b/dev/run/src/bin/http-page-preview.ps1
@@ -0,0 +1,3 @@
+$starting_dir = Get-Location
+python -m http.server 3000
+Set-Location $starting_dir
diff --git a/dev/run/src/bin/http-page-preview.sh b/dev/run/src/bin/http-page-preview.sh
new file mode 100755
index 0000000..bed4b1c
--- /dev/null
+++ b/dev/run/src/bin/http-page-preview.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+python3 -m http.server 3000
diff --git a/dev/run/src/bin/install-mling.ps1 b/dev/run/src/bin/install-mling.ps1
new file mode 100644
index 0000000..2b55a09
--- /dev/null
+++ b/dev/run/src/bin/install-mling.ps1
@@ -0,0 +1,10 @@
+$ErrorActionPreference = "Stop"
+
+cargo build --release --manifest-path mingling_cli/Cargo.toml
+
+New-Item -ItemType Directory -Force -Path .temp/mling/bin, .temp/mling/scripts | Out-Null
+
+Copy-Item .temp/target/release/mling.exe .temp/mling/bin/
+Copy-Item .temp/target/release/mingling-cli.exe .temp/mling/bin/
+Copy-Item .temp/target/mingling/mling_comp.ps1 .temp/mling/scripts/mling_comp.ps1
+Copy-Item mingling_cli/scripts/load_mling.ps1 .temp/mling/
diff --git a/dev/run/src/bin/install-mling.sh b/dev/run/src/bin/install-mling.sh
new file mode 100755
index 0000000..e8cfa18
--- /dev/null
+++ b/dev/run/src/bin/install-mling.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -e
+
+cargo build --release --manifest-path mingling_cli/Cargo.toml
+
+mkdir -p .temp/mling/bin .temp/mling/scripts
+
+cp .temp/target/release/mling .temp/mling/bin/
+cp .temp/target/release/mingling-cli .temp/mling/bin/
+
+for comp in zsh sh fish; do
+    cp ".temp/target/mingling/mling_comp.$comp" ".temp/mling/scripts/mling_comp.$comp"
+done
+cp mingling_cli/scripts/load_mling.zsh .temp/mling/
+cp mingling_cli/scripts/load_mling.sh .temp/mling/
+cp mingling_cli/scripts/load_mling.fish .temp/mling/
diff --git a/dev/run/src/bin/package-all.rs b/dev/run/src/bin/package-all.rs
new file mode 100644
index 0000000..ecdd133
--- /dev/null
+++ b/dev/run/src/bin/package-all.rs
@@ -0,0 +1,736 @@
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use flate2::read::GzDecoder;
+use serde::Deserialize;
+use tar::Archive;
+use toml::Table as TomlTable;
+use tools::{
+    dependency_order::find_workspace_root, eprintln_cargo_style, println_cargo_style,
+    run_cmd_capture_with_dir, wprintln_cargo_style,
+};
+
+/// A single member from `cargo metadata` output.
+#[derive(Deserialize, Debug)]
+struct MetadataPackage {
+    name: String,
+    version: String,
+    manifest_path: String,
+}
+
+/// The top-level metadata structure.
+#[derive(Deserialize, Debug)]
+struct Metadata {
+    #[allow(dead_code)]
+    workspace_root: String,
+    packages: Vec,
+}
+
+fn main() {
+    // 1. Determine project root
+    let cwd = std::env::current_dir().expect("failed to get current working directory");
+    let workspace_root = find_workspace_root(&cwd).expect("not inside a Cargo workspace");
+    println_cargo_style!("Workspace: {}", workspace_root.display());
+
+    let pre_release_dir = workspace_root.join(".temp/pre-release");
+
+    // 2. Clean `.temp/pre-release/`
+    println_cargo_style!("Clean: .temp/pre-release/");
+    let _ = std::fs::remove_dir_all(&pre_release_dir);
+    std::fs::create_dir_all(&pre_release_dir).expect("failed to create .temp/pre-release/");
+
+    // 3. Run `cargo metadata` to get workspace members info
+    println_cargo_style!("Metadata: querying workspace members");
+    let metadata_json = run_cmd_capture_with_dir(
+        "cargo metadata --format-version 1 --no-deps",
+        &workspace_root,
+    )
+    .unwrap_or_else(|(code, _msg)| {
+        eprintln_cargo_style!(format!("cargo metadata failed (exit {code}):\n{{msg}}"));
+        std::process::exit(1);
+    });
+
+    let metadata: Metadata = serde_json::from_str(&metadata_json).unwrap_or_else(|e| {
+        eprintln_cargo_style!("failed to parse cargo metadata: {}", e);
+        std::process::exit(1);
+    });
+
+    // Filter workspace members: skip the root virtual manifest
+    let workspace_root_str = workspace_root.to_string_lossy().replace('\\', "/");
+    let members: Vec<&MetadataPackage> = metadata
+        .packages
+        .iter()
+        .filter(|p| {
+            let mp = p.manifest_path.replace('\\', "/");
+            mp.starts_with(&workspace_root_str)
+                && mp != format!("{}/Cargo.toml", workspace_root_str)
+        })
+        .collect();
+
+    if members.is_empty() {
+        eprintln_cargo_style!("No workspace members found!");
+        std::process::exit(1);
+    }
+
+    // Print member info
+    for m in &members {
+        println_cargo_style!("Member: {}@{}", m.name, m.version);
+    }
+
+    // Build version map: crate_name -> version
+    let mut version_map: HashMap = HashMap::new();
+    for m in &members {
+        version_map.insert(m.name.clone(), m.version.clone());
+    }
+
+    // Collect unique member directories that need to be copied
+    let mut member_dirs: Vec = Vec::new();
+    for m in &members {
+        let dir = Path::new(&m.manifest_path)
+            .parent()
+            .expect("manifest_path has no parent");
+        let canonical_dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
+        let canonical_root =
+            std::fs::canonicalize(&workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
+        let relative = canonical_dir
+            .strip_prefix(&canonical_root)
+            .map(|p| p.to_path_buf())
+            .unwrap_or_else(|_| PathBuf::from(dir.file_name().unwrap_or_default()));
+        if !member_dirs.contains(&relative) {
+            member_dirs.push(relative);
+        }
+    }
+
+    // 4. Copy files to the temp directory, preserving the workspace directory structure
+    println_cargo_style!("Copy: project structure to .temp/pre-release/");
+
+    copy_dir(
+        &workspace_root.join(".cargo"),
+        &pre_release_dir.join(".cargo"),
+    );
+
+    for dir in &member_dirs {
+        let src = workspace_root.join(dir);
+        let dst = pre_release_dir.join(dir);
+        copy_dir(&src, &dst);
+    }
+
+    copy_file(
+        &workspace_root.join("Cargo.toml"),
+        &pre_release_dir.join("Cargo.toml"),
+    );
+    copy_file(
+        &workspace_root.join("Cargo.lock"),
+        &pre_release_dir.join("Cargo.lock"),
+    );
+
+    // 5. Fully resolve ALL workspace inheritance in every member's Cargo.toml,
+    //    so each crate becomes monomorphic (no `workspace = true` references).
+    //    Then strip `[workspace.dependencies]` and `[workspace.package]` from the
+    //    root Cargo.toml, since they are no longer needed.
+    println_cargo_style!("Resolve: inline all workspace inheritance");
+
+    // Parse workspace config from the COPIED root Cargo.toml
+    let root_cargo_path = pre_release_dir.join("Cargo.toml");
+    let root_content = std::fs::read_to_string(&root_cargo_path)
+        .unwrap_or_else(|e| panic!("failed to read {}: {e}", root_cargo_path.display()));
+
+    let (ws_package, ws_deps) = parse_workspace_config(&root_content);
+
+    // Resolve each member's Cargo.toml
+    for dir in &member_dirs {
+        let member_cargo = pre_release_dir.join(dir).join("Cargo.toml");
+        if !member_cargo.exists() {
+            continue;
+        }
+        let member_content = std::fs::read_to_string(&member_cargo)
+            .unwrap_or_else(|e| panic!("failed to read {}: {e}", member_cargo.display()));
+        let resolved =
+            resolve_member_manifest(&member_content, dir, &ws_package, &ws_deps, &version_map);
+        std::fs::write(&member_cargo, &resolved)
+            .unwrap_or_else(|e| panic!("failed to write {}: {e}", member_cargo.display()));
+    }
+
+    // Strip [workspace.dependencies], [workspace.package], and root [package]
+    // from root Cargo.toml, making it a pure virtual manifest.
+    let stripped = strip_workspace_config(&root_content);
+    std::fs::write(&root_cargo_path, &stripped)
+        .unwrap_or_else(|e| panic!("failed to write {}: {e}", root_cargo_path.display()));
+
+    println_cargo_style!("Package: running cargo package --workspace --no-verify");
+
+    // 6. Run cargo package in the temp directory
+    let package_ok = run_cmd_capture_with_dir(
+        "cargo package --workspace --no-verify --color always",
+        &pre_release_dir,
+    );
+
+    match &package_ok {
+        Ok(out) => {
+            println!("{out}");
+        }
+        Err((code, msg)) => {
+            // Print output but don't fail yet
+            eprintln_cargo_style!(format!("cargo package exited with code {code}:"));
+            println!("{msg}");
+        }
+    }
+
+    // 7. Copy built packages back to .temp/target/package
+    let temp_package_dir = workspace_root.join(".temp/target/package");
+    std::fs::create_dir_all(&temp_package_dir)
+        .unwrap_or_else(|e| panic!("failed to create {}: {e}", temp_package_dir.display()));
+
+    // cargo package puts .crate files in target/package
+    let pre_release_target_package = pre_release_dir.join(".temp/target/package");
+    if pre_release_target_package.exists() {
+        println_cargo_style!("Copy: packages to .temp/target/package");
+        copy_dir_contents(&pre_release_target_package, &temp_package_dir);
+    } else {
+        wprintln_cargo_style!("No packages found in .temp/pre-release/.temp/target/package");
+    }
+
+    // 8. Export each crate as a standalone project from the .crate packages.
+    //    The .crate files contain the final publish-ready Cargo.toml with all
+    //    workspace/path deps already resolved by `cargo package`.
+    let release_dir = workspace_root.join(".temp/release");
+    println_cargo_style!("Export: standalone crates to .temp/release/");
+    let _ = std::fs::remove_dir_all(&release_dir);
+    std::fs::create_dir_all(&release_dir)
+        .unwrap_or_else(|e| panic!("failed to create {}: {e}", release_dir.display()));
+
+    for entry in std::fs::read_dir(&temp_package_dir).expect("failed to read target/package") {
+        let entry = entry.expect("failed to read entry");
+        let path = entry.path();
+        if path.extension().is_none_or(|e| e != "crate") {
+            continue;
+        }
+
+        // .crate files are gzipped tarballs. Extract to .temp/release//
+        // fname is like "mingling-0.3.0"
+        let fname = path.file_stem().unwrap().to_string_lossy().to_string();
+
+        // Derive crate directory name by stripping the version suffix
+        // mingling-0.3.0 -> mingling, arg-picker-0.1.0 -> arg-picker
+        let crate_dir_name = fname
+            .rfind('-')
+            .and_then(|dash| {
+                // Check if what follows looks like a semver
+                let ver_part = &fname[dash + 1..];
+                if ver_part.chars().next().is_some_and(|c| c.is_ascii_digit()) {
+                    Some(&fname[..dash])
+                } else {
+                    None
+                }
+            })
+            .unwrap_or(&fname)
+            .to_string();
+
+        let target_dir = release_dir.join(&crate_dir_name);
+        std::fs::create_dir_all(&target_dir)
+            .unwrap_or_else(|e| panic!("failed to create {}: {e}", target_dir.display()));
+
+        // Extract using flate2 + tar (cross-platform)
+        let file = match std::fs::File::open(&path) {
+            Ok(f) => f,
+            Err(e) => {
+                eprintln_cargo_style!("Failed to open {}: {e}", path.display());
+                continue;
+            }
+        };
+        let decoder = GzDecoder::new(file);
+        let mut archive = Archive::new(decoder);
+        if let Err(e) = archive.unpack(&target_dir) {
+            eprintln_cargo_style!("Failed to extract {}: {e}", fname);
+            continue;
+        }
+
+        // Move the contents from the inner dir up one level
+        // .crate contains a single top-level dir named after the package
+        let inner = target_dir.join(&fname);
+        if inner.exists() {
+            for inner_entry in std::fs::read_dir(&inner).expect("failed to read inner dir") {
+                let inner_entry = inner_entry.expect("failed to read entry");
+                let inner_path = inner_entry.path();
+                let dest = target_dir.join(inner_path.file_name().unwrap());
+                if dest.exists() {
+                    let _ = std::fs::remove_dir_all(&dest);
+                }
+                std::fs::rename(&inner_path, &dest).unwrap_or_else(|e| {
+                    panic!(
+                        "failed to rename {} -> {}: {e}",
+                        inner_path.display(),
+                        dest.display()
+                    )
+                });
+            }
+            let _ = std::fs::remove_dir_all(&inner);
+        }
+
+        // Clean up: remove .orig file (only the normalized Cargo.toml is needed)
+        let _ = std::fs::remove_file(target_dir.join("Cargo.toml.orig"));
+        // Also remove Cargo.lock — standalone crate doesn't need it for publish
+        let _ = std::fs::remove_file(target_dir.join("Cargo.lock"));
+
+        // Append an empty [workspace] section so each crate is a valid workspace root
+        let cargo_toml_path = target_dir.join("Cargo.toml");
+        if cargo_toml_path.exists() {
+            let mut cargo_content = std::fs::read_to_string(&cargo_toml_path)
+                .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display()));
+            // Only append if there isn't already a [workspace] section
+            if !cargo_content.contains("\n[workspace]\n")
+                && !cargo_content.ends_with("\n[workspace]\n")
+            {
+                cargo_content.push_str("\n[workspace]\n");
+                std::fs::write(&cargo_toml_path, &cargo_content).unwrap_or_else(|e| {
+                    panic!("failed to write {}: {e}", cargo_toml_path.display())
+                });
+            }
+        }
+
+        println_cargo_style!("Export: {}", crate_dir_name);
+    }
+
+    println_cargo_style!("Done: .temp/release/ is ready");
+
+    // If package failed, report it
+    if package_ok.is_err() {
+        eprintln_cargo_style!("cargo package reported errors above");
+        std::process::exit(1);
+    }
+}
+
+/// Parse `[workspace.package]` and `[workspace.dependencies]` from the root Cargo.toml.
+/// Returns (package_fields, dep_values).
+fn parse_workspace_config(
+    content: &str,
+) -> (HashMap, HashMap) {
+    let table: TomlTable = content.parse().expect("failed to parse root Cargo.toml");
+
+    let mut package_fields = HashMap::new();
+    if let Some(workspace) = table.get("workspace").and_then(|w| w.as_table())
+        && let Some(pkg_table) = workspace.get("package").and_then(|p| p.as_table())
+    {
+        for (k, v) in pkg_table {
+            if let Some(s) = v.as_str() {
+                package_fields.insert(k.clone(), s.to_string());
+            }
+        }
+    }
+
+    let mut dep_values = HashMap::new();
+    if let Some(workspace) = table.get("workspace").and_then(|w| w.as_table())
+        && let Some(deps_table) = workspace.get("dependencies").and_then(|d| d.as_table())
+    {
+        for (k, v) in deps_table {
+            dep_values.insert(k.clone(), v.clone());
+        }
+    }
+
+    (package_fields, dep_values)
+}
+
+/// Serialize a `toml::Value` into Cargo-toml-compatible inline representation.
+fn toml_value_str(v: &toml::Value) -> String {
+    match v {
+        toml::Value::String(s) => format!("\"{}\"", s),
+        toml::Value::Table(t) => {
+            let items: Vec = t
+                .iter()
+                .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
+                .collect();
+            format!("{{ {} }}", items.join(", "))
+        }
+        toml::Value::Array(a) => {
+            let items: Vec = a.iter().map(toml_value_str).collect();
+            format!("[{}]", items.join(", "))
+        }
+        toml::Value::Boolean(b) => b.to_string(),
+        toml::Value::Integer(i) => i.to_string(),
+        toml::Value::Float(f) => f.to_string(),
+        toml::Value::Datetime(dt) => format!("\"{}\"", dt),
+    }
+}
+
+/// Compute the relative path from `member_rel_dir` to `target_path`.
+/// Both are relative to workspace root.
+/// e.g. member_rel_dir="mingling", target_path="mingling_core" → "../mingling_core"
+fn make_path_relative_to_member(target_path: &str, member_rel_dir: &Path) -> String {
+    if member_rel_dir.as_os_str().is_empty() || member_rel_dir == Path::new(".") {
+        return target_path.to_string();
+    }
+    let depth = member_rel_dir.components().count();
+    let mut result = PathBuf::new();
+    for _ in 0..depth {
+        result.push("..");
+    }
+    result.push(target_path);
+    result.to_string_lossy().to_string()
+}
+
+/// Resolve a dependency definition from `[workspace.dependencies]` to an inline string.
+/// If the definition contains a path to a workspace member, add `version = "..."`
+/// and adjust the path to be relative to the member's directory.
+fn resolve_dep_def(
+    dep_name: &str,
+    dep_def: &toml::Value,
+    member_rel_dir: &Path,
+    version_map: &HashMap,
+) -> String {
+    match dep_def {
+        toml::Value::String(ver) => {
+            format!("\"{}\"", ver)
+        }
+        toml::Value::Table(t) => {
+            let mut resolved = t.clone();
+            let has_path = t.contains_key("path");
+            let is_ws_member = version_map.contains_key(dep_name);
+
+            // Fix path to be relative to member's directory
+            if has_path && let Some(path_val) = t.get("path").and_then(|v| v.as_str()) {
+                let rel = make_path_relative_to_member(path_val, member_rel_dir);
+                resolved.insert("path".to_string(), toml::Value::String(rel));
+            }
+
+            // Add version for workspace member path deps
+            if has_path
+                && is_ws_member
+                && let Some(version) = version_map.get(dep_name)
+            {
+                resolved.insert("version".to_string(), toml::Value::String(version.clone()));
+            }
+
+            let items: Vec = resolved
+                .iter()
+                .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
+                .collect();
+            format!("{{ {} }}", items.join(", "))
+        }
+        _ => toml_value_str(dep_def),
+    }
+}
+
+/// Merge an inline `{ workspace = true, optional = true, ... }` with the workspace definition.
+/// Returns the full resolved dependency value string (without the leading `dep_name = `).
+fn merge_inline_dep(
+    inline_rest: &str,
+    dep_name: &str,
+    dep_def: &toml::Value,
+    member_rel_dir: &Path,
+    version_map: &HashMap,
+) -> String {
+    // inline_rest is the part after `=`: `{ workspace = true, optional = true }`
+    let inner = inline_rest
+        .trim()
+        .strip_prefix('{')
+        .and_then(|s| s.strip_suffix('}'))
+        .unwrap_or("");
+
+    match dep_def {
+        toml::Value::String(ver) => {
+            // Workspace def is just a version string
+            // Collect extras: everything except `workspace = true`
+            let extras: Vec<&str> = inner
+                .split(',')
+                .map(|s| s.trim())
+                .filter(|s| !s.is_empty() && *s != "workspace = true")
+                .collect();
+
+            if extras.is_empty() {
+                format!("\"{}\"", ver)
+            } else {
+                // Serialize as inline table: version + extras
+                let mut parts = vec![format!("version = \"{}\"", ver)];
+                parts.extend(extras.iter().map(|s| s.to_string()));
+                format!("{{ {} }}", parts.join(", "))
+            }
+        }
+        toml::Value::Table(t) => {
+            // Start from workspace def
+            let mut merged = t.clone();
+
+            // Fix path to be relative to member's directory
+            if let Some(path_val) = t.get("path").and_then(|v| v.as_str()) {
+                let rel = make_path_relative_to_member(path_val, member_rel_dir);
+                merged.insert("path".to_string(), toml::Value::String(rel));
+            }
+
+            // If this dep is a workspace member with a path dep, add version
+            if t.contains_key("path")
+                && version_map.contains_key(dep_name)
+                && let Some(version) = version_map.get(dep_name)
+            {
+                merged.insert("version".to_string(), toml::Value::String(version.clone()));
+            }
+
+            // Apply extra fields from the inline
+            for piece in inner.split(',').map(|s| s.trim()) {
+                let piece = piece.trim();
+                if piece.is_empty() || piece == "workspace = true" {
+                    continue;
+                }
+                // Parse `key = value` pairs
+                if let Some((raw_key, raw_val)) = piece.split_once('=') {
+                    let k = raw_key.trim();
+                    let v = raw_val.trim();
+                    if k.is_empty() {
+                        continue;
+                    }
+                    // Try to infer the value type
+                    if v == "true" {
+                        merged.insert(k.to_string(), toml::Value::Boolean(true));
+                    } else if v == "false" {
+                        merged.insert(k.to_string(), toml::Value::Boolean(false));
+                    } else if v.starts_with('"') && v.ends_with('"') {
+                        merged.insert(
+                            k.to_string(),
+                            toml::Value::String(v[1..v.len() - 1].to_string()),
+                        );
+                    } else if v.starts_with('[') && v.ends_with(']') {
+                        // Simple array parsing: strings only
+                        let arr: Vec = v[1..v.len() - 1]
+                            .split(',')
+                            .map(|s| {
+                                let s = s.trim().trim_matches('"');
+                                toml::Value::String(s.to_string())
+                            })
+                            .collect();
+                        merged.insert(k.to_string(), toml::Value::Array(arr));
+                    } else if let Ok(n) = v.parse::() {
+                        merged.insert(k.to_string(), toml::Value::Integer(n));
+                    } else if let Ok(f) = v.parse::() {
+                        merged.insert(k.to_string(), toml::Value::Float(f));
+                    } else {
+                        // Treat as string
+                        merged.insert(k.to_string(), toml::Value::String(v.to_string()));
+                    }
+                }
+            }
+
+            let items: Vec = merged
+                .iter()
+                .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
+                .collect();
+            format!("{{ {} }}", items.join(", "))
+        }
+        _ => toml_value_str(dep_def),
+    }
+}
+
+/// Resolve ALL workspace inheritance in a single member crate's Cargo.toml:
+///   - `version.workspace = true` → `version = "0.3.0"`
+///   - `dep.workspace = true` → inline the full definition from ws_deps
+///   - `dep = { workspace = true, ... }` → merge with ws_deps definition
+fn resolve_member_manifest(
+    content: &str,
+    member_rel_dir: &Path,
+    ws_package: &HashMap,
+    ws_deps: &HashMap,
+    version_map: &HashMap,
+) -> String {
+    let mut result = String::new();
+    let mut in_package = false;
+    let mut in_section_with_deps = false;
+
+    for line in content.lines() {
+        let trimmed = line.trim();
+        let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
+
+        // Track sections
+        if trimmed.starts_with('[') {
+            in_package = trimmed == "[package]";
+            in_section_with_deps = trimmed.starts_with("[dependencies")
+                || trimmed.starts_with("[build-dependencies")
+                || trimmed.starts_with("[dev-dependencies");
+            result.push_str(line);
+            result.push('\n');
+            continue;
+        }
+
+        // [package] section: resolve `field.workspace = true`
+        if in_package && trimmed.ends_with(".workspace = true") {
+            let key = trimmed.strip_suffix(".workspace = true").unwrap().trim();
+            if let Some(value) = ws_package.get(key) {
+                result.push_str(&format!("{indent}{key} = \"{value}\"\n"));
+                continue;
+            }
+            // Also check workspace.dependencies (for fields like `version.workspace`)
+            // when the member has its own version field inherited from workspace.package
+        }
+
+        // Dependency sections
+        if in_section_with_deps {
+            // Shorthand: `foo.workspace = true`
+            if trimmed.ends_with(".workspace = true") {
+                let key = trimmed.strip_suffix(".workspace = true").unwrap().trim();
+                if let Some(dep_def) = ws_deps.get(key) {
+                    let resolved = resolve_dep_def(key, dep_def, member_rel_dir, version_map);
+                    result.push_str(&format!("{indent}{key} = {resolved}\n"));
+                    continue;
+                }
+            }
+
+            // Inline: `foo = { workspace = true, optional = true, ... }`
+            if let Some(eq_pos) = trimmed.find("= {")
+                && trimmed.contains("workspace = true")
+            {
+                let dep_name = trimmed[..eq_pos].trim();
+                if let Some(dep_def) = ws_deps.get(dep_name) {
+                    let after_eq = trimmed[eq_pos + 1..].trim();
+                    let merged =
+                        merge_inline_dep(after_eq, dep_name, dep_def, member_rel_dir, version_map);
+                    result.push_str(&format!("{indent}{dep_name} = {merged}\n"));
+                    continue;
+                }
+            }
+        }
+
+        result.push_str(line);
+        result.push('\n');
+    }
+
+    result
+}
+
+/// Remove `[workspace.dependencies]`, `[workspace.package]`, and the root `[package]`
+/// section from root Cargo.toml, making it a pure virtual manifest.
+/// Keeps `[workspace]` with `members`, `resolver`, `exclude` so packaging still works.
+fn strip_workspace_config(content: &str) -> String {
+    let mut result = String::new();
+    let mut in_ws_deps = false;
+    let mut in_ws_package = false;
+    let mut in_root_package = false;
+
+    for line in content.lines() {
+        let trimmed = line.trim();
+
+        if trimmed == "[workspace.dependencies]" {
+            in_ws_deps = true;
+            continue;
+        }
+        if trimmed == "[workspace.package]" {
+            in_ws_package = true;
+            continue;
+        }
+        if trimmed == "[package]" && !in_ws_deps && !in_ws_package {
+            // Remove the root [package] section entirely (virtual manifest)
+            in_root_package = true;
+            continue;
+        }
+
+        if in_ws_deps {
+            if trimmed.starts_with('[') {
+                in_ws_deps = false;
+            } else {
+                continue;
+            }
+        }
+
+        if in_ws_package {
+            if trimmed.starts_with('[') {
+                in_ws_package = false;
+            } else {
+                continue;
+            }
+        }
+
+        if in_root_package {
+            if trimmed.starts_with('[') {
+                in_root_package = false;
+            } else {
+                continue;
+            }
+        }
+
+        result.push_str(line);
+        result.push('\n');
+    }
+
+    result
+}
+
+/// Recursively copy a directory.
+fn copy_dir(src: &Path, dst: &Path) {
+    copy_dir_filtered(src, dst, &|_: &Path| true)
+}
+
+/// Recursively copy a directory with a filter function.
+/// The filter receives the source path and returns `true` if the entry should be copied.
+fn copy_dir_filtered(src: &Path, dst: &Path, filter: &dyn Fn(&Path) -> bool) {
+    if !src.exists() {
+        return;
+    }
+    if !filter(src) {
+        return;
+    }
+    std::fs::create_dir_all(dst)
+        .unwrap_or_else(|e| panic!("failed to create {}: {e}", dst.display()));
+
+    for entry in std::fs::read_dir(src).expect("failed to read directory") {
+        let entry = entry.expect("failed to read entry");
+        let entry_type = entry.file_type().expect("failed to get file type");
+        let src_path = entry.path();
+        let dst_path = dst.join(entry.file_name());
+
+        if !filter(&src_path) {
+            continue;
+        }
+
+        if entry_type.is_dir() {
+            copy_dir_filtered(&src_path, &dst_path, filter);
+        } else if entry_type.is_file() || entry_type.is_symlink() {
+            copy_file(&src_path, &dst_path);
+        }
+    }
+}
+
+/// Copy a file, creating parent directories as needed.
+/// If src is a symlink, copies the target content (follow symlinks).
+fn copy_file(src: &Path, dst: &Path) {
+    if let Some(parent) = dst.parent() {
+        std::fs::create_dir_all(parent)
+            .unwrap_or_else(|e| panic!("failed to create {}: {e}", parent.display()));
+    }
+
+    let resolved = if src.is_symlink() {
+        let target = std::fs::read_link(src)
+            .unwrap_or_else(|e| panic!("failed to read symlink {}: {e}", src.display()));
+        if target.is_relative() {
+            src.parent().unwrap().join(target)
+        } else {
+            target
+        }
+    } else {
+        src.to_path_buf()
+    };
+
+    std::fs::copy(&resolved, dst).unwrap_or_else(|e| {
+        panic!(
+            "failed to copy {} -> {}: {e}",
+            resolved.display(),
+            dst.display()
+        )
+    });
+}
+
+/// Copy all files/directories from one directory into another.
+fn copy_dir_contents(src: &Path, dst: &Path) {
+    if !src.exists() {
+        return;
+    }
+    std::fs::create_dir_all(dst)
+        .unwrap_or_else(|e| panic!("failed to create {}: {e}", dst.display()));
+
+    for entry in std::fs::read_dir(src).expect("failed to read directory") {
+        let entry = entry.expect("failed to read entry");
+        let entry_type = entry.file_type().expect("failed to get file type");
+        let src_path = entry.path();
+        let dst_path = dst.join(entry.file_name());
+
+        if entry_type.is_dir() {
+            copy_dir(&src_path, &dst_path);
+        } else if entry_type.is_file() || entry_type.is_symlink() {
+            copy_file(&src_path, &dst_path);
+        }
+    }
+}
diff --git a/dev/run/src/bin/update-version.rs b/dev/run/src/bin/update-version.rs
new file mode 100644
index 0000000..679f419
--- /dev/null
+++ b/dev/run/src/bin/update-version.rs
@@ -0,0 +1,104 @@
+use std::io::Write as _;
+use std::path::Path;
+
+use serde::Deserialize;
+use tools::println_cargo_style;
+
+#[derive(Deserialize)]
+struct VersionFile {
+    file: String,
+    pattern: String,
+}
+
+#[derive(Deserialize)]
+struct Config {
+    #[serde(rename = "file")]
+    files: Vec,
+}
+
+fn main() {
+    let args: Vec = std::env::args().collect();
+
+    // Get new version
+    let new_ver = if args.len() > 1 {
+        args[1].clone()
+    } else {
+        print!("Update version to: ");
+        std::io::stdout().flush().unwrap();
+        let mut input = String::new();
+        std::io::stdin().read_line(&mut input).unwrap();
+        input.trim().to_string()
+    };
+
+    if new_ver.is_empty() {
+        eprintln!("Error: Version cannot be empty.");
+        std::process::exit(1);
+    }
+
+    // Read current version from root Cargo.toml's workspace.package.version
+    let root_cargo_path = "Cargo.toml";
+    let root_cargo_content =
+        std::fs::read_to_string(root_cargo_path).expect("Failed to read Cargo.toml");
+    let cargo_value: toml::Value = root_cargo_content
+        .parse()
+        .expect("Failed to parse Cargo.toml");
+
+    let current_ver = cargo_value["workspace"]["package"]["version"]
+        .as_str()
+        .expect("workspace.package.version not found in Cargo.toml")
+        .to_string();
+
+    if new_ver == current_ver {
+        println!("Version is already {}. Nothing to do.", current_ver);
+        return;
+    }
+
+    println_cargo_style!("Version: {} -> {}", current_ver, new_ver);
+
+    // Read version-files.toml
+    let config_path = Path::new("dev/configs").join("version-files.toml");
+    let config_str = std::fs::read_to_string(&config_path)
+        .expect("Failed to read dev/configs/version-files.toml");
+    let config: Config =
+        toml::from_str(&config_str).expect("Failed to parse dev/configs/version-files.toml");
+
+    let mut updated_count = 0;
+    let mut skipped_count = 0;
+
+    for vf in &config.files {
+        let file_path = &vf.file;
+        let old_pattern = vf.pattern.replace("{VER}", ¤t_ver);
+        let new_pattern = vf.pattern.replace("{VER}", &new_ver);
+
+        let content = match std::fs::read_to_string(file_path) {
+            Ok(c) => c,
+            Err(e) => {
+                eprintln!("Warning: Could not read {}: {}", file_path, e);
+                skipped_count += 1;
+                continue;
+            }
+        };
+
+        let new_content = content.replace(&old_pattern, &new_pattern);
+
+        if new_content == content {
+            eprintln!(
+                "Warning: Pattern '{}' not found in {}",
+                old_pattern, file_path
+            );
+            skipped_count += 1;
+            continue;
+        }
+
+        std::fs::write(file_path, &new_content)
+            .unwrap_or_else(|e| panic!("Failed to write {}: {}", file_path, e));
+        println_cargo_style!("Updated: {}", file_path);
+        updated_count += 1;
+    }
+
+    println_cargo_style!(
+        "Done: {} file(s) updated, {} file(s) skipped",
+        updated_count,
+        skipped_count
+    );
+}
diff --git a/dev/run/src/bin/windows-folder-hide.ps1 b/dev/run/src/bin/windows-folder-hide.ps1
new file mode 100644
index 0000000..ff53202
--- /dev/null
+++ b/dev/run/src/bin/windows-folder-hide.ps1
@@ -0,0 +1,115 @@
+$skipDirs = @('.git', '.temp', 'target', 'node_modules', '.pnpm')
+$selfPath = (Get-Item -LiteralPath $MyInvocation.MyCommand.Path).Directory.FullName
+
+function Test-InSkipDir {
+    param(
+        [object]$Item
+    )
+    $path = if ($Item -is [string]) {
+        $Item
+    } elseif ($Item.PSPath) {
+        $Item.PSPath -replace '^.*::', ''
+    } else {
+        $Item.FullName
+    }
+
+    $parts = $path.Split([System.IO.Path]::DirectorySeparatorChar)
+    for ($i = 0; $i -lt $parts.Length - 1; $i++) {
+        if ($parts[$i] -in $skipDirs) {
+            return $true
+        }
+    }
+    return $false
+}
+
+function Invoke-UnhideRecursive {
+    param([string]$Path)
+    Get-ChildItem -LiteralPath $Path -Force | ForEach-Object {
+        if ($_.PSIsContainer) {
+            if ($_.Name -in $skipDirs) {
+                if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) {
+                    Write-Host "    -> unhiding skip directory (self only): `"$($_.FullName)`""
+                    $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden
+                }
+                return
+            }
+            Invoke-UnhideRecursive $_.FullName
+        } else {
+            if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) {
+                Write-Host "    -> unhiding: `"$($_.FullName)`""
+                $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden
+            }
+        }
+    }
+}
+
+function Test-GitPathSkippable {
+    param([string]$GitPath)
+    $parts = $GitPath.Split(@('/', '\'))
+    for ($i = 0; $i -lt $parts.Length - 1; $i++) {
+        if ($parts[$i] -in $skipDirs) {
+            return $true
+        }
+    }
+    return $false
+}
+
+Write-Host "Step 1: Unhiding all files and directories (skipping $($skipDirs -join ', '))..."
+
+Invoke-UnhideRecursive -Path (Get-Location).Path
+
+Write-Host "Step 2: Hiding git-ignored items..."
+
+git ls-files --others --ignored --exclude-standard | Where-Object {
+    -not (Test-GitPathSkippable $_)
+} | ForEach-Object {
+    $itemPath = $_
+    Write-Host "... checking: `"$itemPath`""
+    $item = Get-Item $_ -Force -ErrorAction SilentlyContinue
+    if (-not $item) { return }
+
+    if ($item.FullName -eq $selfPath) { return }
+
+    if (Test-InSkipDir $item) {
+        Write-Host "    -> skipping (inside skip directory)"
+        return
+    }
+
+    if ($item.PSIsContainer) {
+        if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) {
+            Write-Host "    -> hiding directory (non-recursive)"
+            $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden
+        }
+    } else {
+        if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) {
+            Write-Host "    -> hiding"
+            $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden
+        }
+    }
+}
+
+Write-Host "Step 3: Hiding dot-prefixed items..."
+Get-ChildItem -Path . -Force -Directory | Where-Object { $_.Name -match '^\.' } | ForEach-Object {
+    Write-Host "... checking: `"$($_.FullName)`""
+    if (Test-InSkipDir $_) {
+        Write-Host "    -> skipping (inside skip directory)"
+        return
+    }
+    if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) {
+        Write-Host "    -> hiding directory"
+        $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden
+    }
+}
+
+Get-ChildItem -Path . -Force -File | Where-Object { $_.Name -match '^\.' } | ForEach-Object {
+    if ($_.FullName -eq $selfPath) { return }
+    Write-Host "... checking: `"$($_.FullName)`""
+    if (Test-InSkipDir $_) {
+        Write-Host "    -> skipping (inside skip directory)"
+        return
+    }
+    if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) {
+        Write-Host "    -> hiding file"
+        $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden
+    }
+}
diff --git a/dev/run/src/dependency_order.rs b/dev/run/src/dependency_order.rs
new file mode 100644
index 0000000..145bdbd
--- /dev/null
+++ b/dev/run/src/dependency_order.rs
@@ -0,0 +1,196 @@
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+/// Parse Cargo.toml content and return dependency names that start with `prefix`.
+fn parse_mingling_deps(content: &str, prefix: &str) -> Vec {
+    let value: toml::Value = match content.parse() {
+        Ok(v) => v,
+        Err(_) => return Vec::new(),
+    };
+
+    let mut names = Vec::new();
+
+    // Check [dependencies]
+    if let Some(deps) = value.get("dependencies").and_then(|d| d.as_table()) {
+        for key in deps.keys() {
+            if key.starts_with(prefix) {
+                names.push(key.clone());
+            }
+        }
+    }
+
+    // Check [build-dependencies]
+    if let Some(deps) = value.get("build-dependencies").and_then(|d| d.as_table()) {
+        for key in deps.keys() {
+            if key.starts_with(prefix) {
+                names.push(key.clone());
+            }
+        }
+    }
+
+    names
+}
+
+/// Read workspace members from the root Cargo.toml.
+fn get_workspace_members(workspace_root: &std::path::Path) -> Vec {
+    let cargo_path = workspace_root.join("Cargo.toml");
+    let content = match std::fs::read_to_string(&cargo_path) {
+        Ok(c) => c,
+        Err(_) => return Vec::new(),
+    };
+
+    let value: toml::Value = match content.parse() {
+        Ok(v) => v,
+        Err(_) => return Vec::new(),
+    };
+
+    value
+        .get("workspace")
+        .and_then(|w| w.get("members"))
+        .and_then(|m| m.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|v| v.as_str().map(String::from))
+                .collect()
+        })
+        .unwrap_or_default()
+}
+
+/// Hierarchical topological sort (process layer by layer, sort siblings alphabetically).
+///
+/// `dep_map` maps each crate to the list of crates it depends on.
+/// Returns the dependency order (dependent crates come first, dependents come later),
+/// with crates at the same layer (which can be built in parallel) sorted alphabetically.
+fn topological_sort(
+    all_crates: &HashSet,
+    dep_map: &HashMap>,
+) -> Vec {
+    // in_degree[crate] = number of remaining mingling_* dependencies not yet processed
+    let mut in_degree: HashMap<&str, usize> = HashMap::new();
+    // reverse[dependency] = list of crates that depend on it
+    let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
+
+    for name in all_crates {
+        in_degree.entry(name.as_str()).or_insert(0);
+        reverse.entry(name.as_str()).or_default();
+    }
+
+    for (crate_name, deps) in dep_map {
+        for dep in deps {
+            if all_crates.contains(dep.as_str()) {
+                reverse
+                    .get_mut(dep.as_str())
+                    .unwrap()
+                    .push(crate_name.as_str());
+                *in_degree.get_mut(crate_name.as_str()).unwrap() += 1;
+            }
+        }
+    }
+
+    let mut result: Vec = Vec::new();
+
+    // Process layer by layer: all crates with in_degree == 0 in one batch form a layer
+    loop {
+        let mut current: Vec<&str> = all_crates
+            .iter()
+            .filter(|n| in_degree.get(n.as_str()).copied().unwrap_or(0) == 0)
+            .filter(|n| !result.iter().any(|r| r.as_str() == n.as_str()))
+            .map(|s| s.as_str())
+            .collect();
+
+        if current.is_empty() {
+            break;
+        }
+
+        current.sort();
+        result.extend(current.iter().map(|s| s.to_string()));
+
+        for &node in ¤t {
+            if let Some(dependents) = reverse.get(node) {
+                for &dependent in dependents {
+                    if let Some(degree) = in_degree.get_mut(dependent) {
+                        *degree -= 1;
+                    }
+                }
+            }
+        }
+    }
+
+    result
+}
+
+/// Strip the `\\?\` prefix that `std::fs::canonicalize` may add on Windows.
+fn strip_verbatim_prefix(p: &Path) -> PathBuf {
+    let s = p.to_string_lossy();
+    let s_ref: &str = &s;
+    if let Some(rest) = s_ref.strip_prefix("\\\\?\\") {
+        PathBuf::from(rest)
+    } else {
+        p.to_path_buf()
+    }
+}
+
+/// Find the workspace root by looking for a Cargo.toml with `[workspace]` members.
+/// Starts from `start` and walks up the directory tree.
+pub fn find_workspace_root(start: &std::path::Path) -> Option {
+    let mut current = Some(strip_verbatim_prefix(
+        &std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf()),
+    ));
+    while let Some(dir) = current {
+        let members = get_workspace_members(&dir);
+        if !members.is_empty() {
+            return Some(dir);
+        }
+        current = dir.parent().map(|p| p.to_path_buf());
+    }
+    None
+}
+
+/// Output all crate paths in dependency order
+#[allow(unused)]
+pub fn display_dependency_order() -> Vec {
+    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
+
+    let workspace_root = match find_workspace_root(&cwd) {
+        Some(root) => root,
+        None => return Vec::new(),
+    };
+
+    // Read workspace members from root Cargo.toml
+    let members = get_workspace_members(&workspace_root);
+
+    // Filter to crates starting with "mingling" or "arg"
+    let mingling_crates: HashSet = members
+        .into_iter()
+        .filter(|m| m.starts_with("mingling") || m.starts_with("arg"))
+        .collect();
+
+    if mingling_crates.is_empty() {
+        return Vec::new();
+    }
+
+    // Build dependency graph
+    let mut dep_map: HashMap> = HashMap::new();
+
+    for crate_name in &mingling_crates {
+        let cargo_path = workspace_root.join(crate_name).join("Cargo.toml");
+        let content = match std::fs::read_to_string(&cargo_path) {
+            Ok(c) => c,
+            Err(_) => {
+                dep_map.insert(crate_name.clone(), Vec::new());
+                continue;
+            }
+        };
+        let deps = parse_mingling_deps(&content, "mingling");
+        // Only keep deps that are actually in our set
+        let filtered: Vec = deps
+            .into_iter()
+            .filter(|d| mingling_crates.contains(d.as_str()))
+            .collect();
+        dep_map.insert(crate_name.clone(), filtered);
+    }
+
+    let sorted = topological_sort(&mingling_crates, &dep_map);
+
+    sorted.into_iter().map(PathBuf::from).collect()
+}
diff --git a/dev/run/src/lib.rs b/dev/run/src/lib.rs
new file mode 100644
index 0000000..b17a61f
--- /dev/null
+++ b/dev/run/src/lib.rs
@@ -0,0 +1,459 @@
+pub mod dependency_order;
+pub mod verify;
+
+use colored::Colorize;
+
+use std::io::IsTerminal as _;
+
+#[macro_export]
+macro_rules! run_cmd {
+    ($fmt:literal, $($arg:tt)*) => {
+        $crate::run_cmd(format!($fmt, $($arg)*))
+    };
+    ($cmd:expr) => {
+        $crate::run_cmd($cmd)
+    };
+}
+
+/// Run a shell command and capture its combined stdout+stderr output.
+/// Returns `Ok(output)` on success, `Err((exit_code, stderr))` on failure.
+#[macro_export]
+macro_rules! run_cmd_and_capture_stderr {
+    ($fmt:literal, $($arg:tt)*) => {
+        $crate::run_cmd_capture(format!($fmt, $($arg)*))
+    };
+    ($cmd:expr) => {
+        $crate::run_cmd_capture($cmd)
+    };
+}
+
+#[macro_export]
+macro_rules! println_cargo_style {
+    ($fmt:literal, $($arg:tt)*) => {
+        $crate::println_cargo_style(format!($fmt, $($arg)*))
+    };
+    ($cmd:expr) => {
+        $crate::println_cargo_style($cmd)
+    };
+}
+
+#[macro_export]
+macro_rules! eprintln_cargo_style {
+    ($fmt:literal, $($arg:tt)*) => {
+        $crate::eprintln_cargo_style(format!($fmt, $($arg)*))
+    };
+    ($cmd:expr) => {
+        $crate::eprintln_cargo_style($cmd)
+    };
+}
+
+#[macro_export]
+macro_rules! wprintln_cargo_style {
+    ($fmt:literal, $($arg:tt)*) => {
+        $crate::wprintln_cargo_style(format!($fmt, $($arg)*))
+    };
+    ($cmd:expr) => {
+        $crate::wprintln_cargo_style($cmd)
+    };
+}
+
+/// Print a message in cargo style format, with bold green prefix.
+///
+/// # Panics
+///
+/// Panics if the prefix (text before the first `:`) exceeds 12 characters.
+pub fn println_cargo_style(str: impl Into) {
+    let s = str.into();
+    let (prefix, content) = if let Some(pos) = s.find(':') {
+        (
+            s[..pos].trim().to_string(),
+            s[pos + 1..].trim_start().to_string(),
+        )
+    } else {
+        (String::new(), s.trim().to_string())
+    };
+
+    assert!(
+        prefix.len() <= 12,
+        "prefix length exceeds 12: '{}' has length {}",
+        prefix,
+        prefix.len()
+    );
+
+    let padding = " ".repeat(12 - prefix.len());
+
+    println!(
+        "{}{} {}",
+        padding,
+        prefix.bold().bright_green(),
+        content.trim()
+    );
+}
+
+pub fn eprintln_cargo_style(str: impl Into) {
+    println!("{}: {}", "error".bold().bright_red(), str.into());
+}
+
+/// Print a message in cargo style format, with bold yellow prefix (warning style).
+///
+/// # Panics
+///
+/// Panics if the prefix (text before the first `:`) exceeds 12 characters.
+pub fn wprintln_cargo_style(str: impl Into) {
+    let s = str.into();
+    let (prefix, content) = if let Some(pos) = s.find(':') {
+        (
+            s[..pos].trim().to_string(),
+            s[pos + 1..].trim_start().to_string(),
+        )
+    } else {
+        (String::new(), s.trim().to_string())
+    };
+
+    assert!(
+        prefix.len() <= 12,
+        "prefix length exceeds 12: '{}' has length {}",
+        prefix,
+        prefix.len()
+    );
+
+    let padding = " ".repeat(12 - prefix.len());
+
+    println!(
+        "{}{} {}",
+        padding,
+        prefix.bold().bright_yellow(),
+        content.trim()
+    );
+}
+
+/// Run a shell command in the current directory and return its exit status.
+///
+/// # Panics
+///
+/// Panics if the shell command cannot be spawned (e.g. the shell binary is not found).
+///
+/// # Errors
+///
+/// Returns `Err` with the exit code if the command finishes with a non-zero exit code.
+pub fn run_cmd(cmd: impl Into) -> Result<(), i32> {
+    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
+    run_cmd_with_dir(cmd.into(), &cwd)
+}
+
+/// Run a shell command in the specified directory and return its exit status.
+///
+/// # Panics
+///
+/// Panics if the shell command cannot be spawned (e.g. the shell binary is not found).
+///
+/// # Errors
+///
+/// Returns `Err` with the exit code if the command finishes with a non-zero exit code.
+pub fn run_cmd_with_dir(cmd: impl Into, dir: &std::path::Path) -> Result<(), i32> {
+    let shell = if cfg!(target_os = "windows") {
+        "powershell"
+    } else {
+        "sh"
+    };
+    let status = std::process::Command::new(shell)
+        .arg("-c")
+        .arg(cmd.into())
+        .current_dir(dir)
+        .status()
+        .expect("failed to execute command");
+
+    let exit_code = status.code().unwrap_or(1);
+    if exit_code == 0 {
+        Ok(())
+    } else {
+        Err(exit_code)
+    }
+}
+
+/// Run a shell command and capture its combined stdout+stderr output.
+///
+/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`.
+/// Stderr falls back to stdout if stderr is empty.
+pub fn run_cmd_capture(cmd: impl Into) -> Result {
+    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
+    run_cmd_capture_with_dir(cmd.into(), &cwd)
+}
+
+/// Run a shell command in the specified directory and capture its combined stdout+stderr output.
+///
+/// On success returns `Ok(combined_output)`. On failure returns `Err((exit_code, stderr))`.
+/// Stderr falls back to stdout if stderr is empty.
+pub fn run_cmd_capture_with_dir(
+    cmd: impl Into,
+    dir: &std::path::Path,
+) -> Result {
+    let shell = if cfg!(target_os = "windows") {
+        "powershell"
+    } else {
+        "sh"
+    };
+    let output = std::process::Command::new(shell)
+        .arg("-c")
+        .arg(cmd.into())
+        .current_dir(dir)
+        .output()
+        .expect("failed to execute command");
+
+    let exit_code = output.status.code().unwrap_or(1);
+    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+    // Keep both streams so a failure is never hidden: when stderr carries
+    // warnings, the real failure details (e.g. the failing test name and
+    // assertion diff) usually live on stdout and must not be dropped.
+    let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) {
+        (false, false) => format!("{stdout}\n{stderr}"),
+        (false, true) => stdout,
+        (true, false) => stderr,
+        (true, true) => stdout,
+    };
+
+    if exit_code == 0 {
+        Ok(combined)
+    } else {
+        Err((exit_code, combined))
+    }
+}
+
+/// Extract a crate-style name from a `Cargo.toml` path.
+///
+/// Examples:
+/// - `mingling_core/Cargo.toml` → `mingling_core`
+/// - `.` → `(root)`
+pub fn crate_name_from(path: &std::path::Path) -> String {
+    path.parent()
+        .and_then(|p| p.file_name())
+        .and_then(|n| n.to_str())
+        .unwrap_or("(root)")
+        .to_string()
+}
+
+/// Run a list of `(label_for_errors, crate_name_for_bar, shell_command)` tuples
+/// in parallel with a progress bar.
+///
+/// - Success: silent, the bar tracks progress:
+///   `  Building [============================] 32/32: mingling_core`
+/// - Failure: `pb.println()` prints the error immediately above the bar.
+pub fn run_parallel(phase: &str, tasks: Vec<(String, String, String)>) -> Result<(), i32> {
+    let n = tasks.len();
+    if n == 0 {
+        return Ok(());
+    }
+
+    // Cargo-style prefix: right-aligned to 12 chars, bold bright cyan
+    let padding = " ".repeat(12 - phase.len());
+    let styled_prefix = format!("{}{}", padding, phase.bold().bright_cyan());
+
+    let pb = indicatif::ProgressBar::new(n as u64);
+    pb.set_style(
+        indicatif::ProgressStyle::default_bar()
+            .template(&format!(
+                "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}",
+                styled_prefix
+            ))
+            .unwrap()
+            .progress_chars("=> "),
+    );
+    pb.set_position(0);
+
+    // Pre-extract labels for error messages
+    let labels: Vec = tasks.iter().map(|(l, _, _)| l.clone()).collect();
+
+    let (tx, rx) = std::sync::mpsc::channel::<(usize, String, Result)>();
+
+    for (i, (_label, crate_name, cmd)) in tasks.into_iter().enumerate() {
+        let tx = tx.clone();
+        std::thread::spawn(move || {
+            let result = run_cmd_capture(&cmd);
+            let _ = tx.send((i, crate_name, result));
+        });
+    }
+    drop(tx);
+
+    let mut first_exit_code = 0;
+
+    while let Ok((i, crate_name, result)) = rx.recv() {
+        pb.inc(1);
+        pb.set_message(crate_name);
+
+        if let Err((code, output)) = result {
+            if first_exit_code == 0 {
+                first_exit_code = code;
+            }
+            let msg = format!(
+                "{}: {} failed (exit code {})",
+                "error".bright_red().bold(),
+                labels[i],
+                code,
+            );
+            let mut lines = Vec::new();
+            if !output.is_empty() {
+                lines.extend(output.lines().map(|l| format!("  {l}")));
+            }
+            if std::io::stdout().is_terminal() {
+                // On a TTY, render errors through the progress bar so they
+                // appear above it.
+                pb.println(&msg);
+                for line in &lines {
+                    pb.println(line);
+                }
+            } else {
+                // On a non-TTY (CI, piped output), `ProgressBar::println` can
+                // be swallowed, hiding the failure. Emit to plain stdout so the
+                // failure is always visible.
+                println!("{msg}");
+                for line in &lines {
+                    println!("{line}");
+                }
+            }
+        }
+    }
+
+    pb.finish_and_clear();
+
+    if first_exit_code != 0 {
+        Err(first_exit_code)
+    } else {
+        Ok(())
+    }
+}
+
+/// Run a single shell command with a progress bar, capturing its output.
+///
+/// - Success: bar clears silently.
+/// - Failure: error is printed above the bar, then the bar clears.
+pub fn run_cmd_with_progress(phase: &str, label: &str, cmd: String) -> Result<(), i32> {
+    let padding = " ".repeat(12 - phase.len());
+    let styled_prefix = format!("{}{}", padding, phase.bold().bright_cyan());
+
+    let pb = indicatif::ProgressBar::new(1);
+    pb.set_style(
+        indicatif::ProgressStyle::default_bar()
+            .template(&format!(
+                "{} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}",
+                styled_prefix
+            ))
+            .unwrap()
+            .progress_chars("=> "),
+    );
+    pb.set_message(label.to_owned());
+
+    let result = run_cmd_capture(&cmd);
+    pb.inc(1);
+    pb.finish_and_clear();
+
+    match result {
+        Ok(_) => Ok(()),
+        Err((code, output)) => {
+            eprintln_cargo_style(format!("{} failed (exit code {})", label, code));
+            if !output.is_empty() {
+                println!("{}", output.trim_end());
+            }
+            Err(code)
+        }
+    }
+}
+
+/// Read `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`.
+///
+/// Finds the git repository root, reads `mingling/Cargo.toml`, parses it as TOML,
+/// and extracts the feature list under `[package.metadata.docs.rs].features`.
+///
+/// # Errors
+///
+/// Returns `std::io::Error` if:
+/// - The git repository root cannot be found.
+/// - The manifest file cannot be read.
+/// - The TOML cannot be parsed.
+/// - The `[package.metadata.docs.rs].features` key is missing or empty.
+pub fn read_features() -> Result, std::io::Error> {
+    // Find git repo root
+    let mut current_dir = std::env::current_dir()?;
+    let repo_root = loop {
+        let git_dir = current_dir.join(".git");
+        if git_dir.exists() && git_dir.is_dir() {
+            break Some(current_dir);
+        }
+        if !current_dir.pop() {
+            break None;
+        }
+    };
+    let repo_root = repo_root.ok_or_else(|| {
+        std::io::Error::new(
+            std::io::ErrorKind::NotFound,
+            "Failed to find git repository root",
+        )
+    })?;
+
+    let manifest_path = repo_root.join("mingling/Cargo.toml");
+    if !manifest_path.exists() {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::NotFound,
+            format!("Manifest not found at {}", manifest_path.display()),
+        ));
+    }
+
+    let manifest_content = std::fs::read_to_string(&manifest_path)?;
+    let cargo_toml: toml::Value = manifest_content.parse().map_err(|e| {
+        std::io::Error::new(
+            std::io::ErrorKind::InvalidData,
+            format!("Failed to parse Cargo.toml: {}", e),
+        )
+    })?;
+
+    let doc_features = cargo_toml
+        .get("package")
+        .and_then(|p| p.get("metadata"))
+        .and_then(|m| m.get("docs"))
+        .and_then(|d| d.get("rs"))
+        .and_then(|rs| rs.get("features"))
+        .and_then(|f| f.as_array())
+        .ok_or_else(|| {
+            std::io::Error::new(
+                std::io::ErrorKind::NotFound,
+                "[package.metadata.docs.rs] or its 'features' key not found in mingling/Cargo.toml",
+            )
+        })?;
+
+    let features: Vec = doc_features
+        .iter()
+        .filter_map(|v| v.as_str().map(String::from))
+        .collect();
+
+    if features.is_empty() {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::InvalidData,
+            "No features defined in [package.metadata.docs.rs]",
+        ));
+    }
+
+    Ok(features)
+}
+
+#[must_use]
+pub fn cargo_tomls() -> Vec {
+    let mut cargo_tomls = Vec::new();
+    let mut dirs = vec![std::path::PathBuf::from(".")];
+    while let Some(dir) = dirs.pop() {
+        if let Ok(entries) = std::fs::read_dir(&dir) {
+            for entry in entries.flatten() {
+                let path = entry.path();
+                if path.is_dir() {
+                    // Skip the .run directory
+                    if path.file_name().and_then(|n| n.to_str()) == Some(".run") {
+                        continue;
+                    }
+                    dirs.push(path);
+                } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
+                    cargo_tomls.push(path);
+                }
+            }
+        }
+    }
+    cargo_tomls
+}
diff --git a/dev/run/src/verify.rs b/dev/run/src/verify.rs
new file mode 100644
index 0000000..b79bb73
--- /dev/null
+++ b/dev/run/src/verify.rs
@@ -0,0 +1,506 @@
+use std::path::Path;
+
+use crate::println_cargo_style;
+
+/// Represents a parsed code block from a markdown file
+#[derive(Debug, Clone)]
+pub struct CodeBlock {
+    /// Source file path (for reporting)
+    pub source_file: String,
+    /// The line number in source file where this block starts
+    pub line: usize,
+    /// The raw Rust source code
+    pub code: String,
+    /// Feature flags extracted from `// Features: [...]` comment
+    pub features: Vec,
+    /// Whether the block had an explicit `// Features:` header
+    pub has_features_header: bool,
+    /// Whether the block has `// NOT VERIFIED` to opt out of testing
+    pub not_verified: bool,
+    /// External dependencies extracted from `// Dependencies:` comments
+    pub external_deps: Vec<(String, String)>,
+    /// Whether this block has a `fn main` entry point
+    pub has_main: bool,
+    /// Whether this block has `gen_program!()` call
+    pub has_gen_program: bool,
+    /// Whether this block has `// BUILD TIME` annotation (write to build.rs, not main.rs)
+    pub is_build_time: bool,
+}
+
+/// Parse all ```rust code blocks from markdown content
+pub fn parse_code_blocks(content: &str, source_file: &str) -> Vec {
+    let mut blocks = Vec::new();
+    let lines: Vec<&str> = content.lines().collect();
+    let mut i = 0;
+
+    while i < lines.len() {
+        if lines[i].trim() == "```rust" {
+            if let Some(block) = parse_single_block(&lines, i, source_file) {
+                blocks.push(block);
+            }
+            i += 1;
+            while i < lines.len() && lines[i].trim() != "```" {
+                i += 1;
+            }
+        }
+        i += 1;
+    }
+
+    blocks
+}
+
+/// Parse a single code block starting at the ```rust line
+fn parse_single_block(lines: &[&str], start: usize, source_file: &str) -> Option {
+    let line_num = start + 1; // 1-based line number
+
+    let mut code_lines: Vec = Vec::new();
+    let mut features: Vec = Vec::new();
+    let mut has_features_header = false;
+    let mut not_verified = false;
+    let mut external_deps: Vec<(String, String)> = Vec::new();
+    let mut has_main = false;
+    let mut has_gen_program = false;
+    let mut is_build_time = false;
+
+    let mut idx = start + 1;
+    let mut in_header = true;
+
+    while idx < lines.len() {
+        let raw_line = lines[idx];
+        let trimmed = raw_line.trim();
+
+        if trimmed == "```" {
+            break;
+        }
+
+        // @@@ lines: strip the prefix and treat as regular Rust code
+        // These lines are hidden in the rendered docs (filtered by a docsify plugin)
+        // but must still compile.
+        if let Some(stripped) = trimmed.strip_prefix("@@@") {
+            in_header = false;
+            // Strip @@@ and optionally one following space
+            let code = stripped.trim_start();
+            if code.contains("fn main") {
+                has_main = true;
+            }
+            if code.contains("gen_program!") {
+                has_gen_program = true;
+            }
+            code_lines.push(code.to_string());
+            idx += 1;
+            continue;
+        }
+
+        // Parse header comments
+        // Check for NOT VERIFIED marker
+        if in_header && trimmed == "// NOT VERIFIED" {
+            not_verified = true;
+            idx += 1;
+            continue;
+        }
+
+        if in_header && trimmed == "// BUILD TIME" {
+            is_build_time = true;
+            idx += 1;
+            continue;
+        }
+
+        if in_header && trimmed.starts_with("// ") {
+            if trimmed.starts_with("// Features:") {
+                has_features_header = true;
+                let feat_str = trimmed.trim_start_matches("// Features:").trim();
+                if feat_str.starts_with('[') && feat_str.ends_with(']') {
+                    let inner = &feat_str[1..feat_str.len() - 1];
+                    if !inner.is_empty() {
+                        features = inner
+                            .split(',')
+                            .map(|s| s.trim().trim_matches('"').to_string())
+                            .filter(|s| !s.is_empty())
+                            .collect();
+                    }
+                }
+                idx += 1;
+                continue;
+            }
+            if trimmed == "// Dependencies:" {
+                idx += 1;
+                // Collect subsequent `// crate = "version"` lines
+                while idx < lines.len() {
+                    let next = lines[idx].trim();
+                    if next == "```" {
+                        break;
+                    }
+                    if next.starts_with("// ") {
+                        let dep_line = next.trim_start_matches("// ").trim();
+                        if let Some((name, ver)) = dep_line.split_once(" = ") {
+                            external_deps.push((
+                                name.trim().to_string(),
+                                ver.trim().trim_matches('"').to_string(),
+                            ));
+                        }
+                        idx += 1;
+                    } else {
+                        break;
+                    }
+                }
+                continue;
+            }
+        }
+
+        in_header = false;
+
+        if raw_line.contains("fn main") {
+            has_main = true;
+        }
+        if raw_line.contains("gen_program!") {
+            has_gen_program = true;
+        }
+
+        code_lines.push(raw_line.to_string());
+        idx += 1;
+    }
+
+    if code_lines.is_empty() {
+        return None;
+    }
+
+    Some(CodeBlock {
+        source_file: source_file.to_string(),
+        line: line_num,
+        code: code_lines.join("\n"),
+        features,
+        has_features_header,
+        not_verified,
+        external_deps,
+        has_main,
+        has_gen_program,
+        is_build_time,
+    })
+}
+
+/// Generate a Cargo.toml for a block
+///
+/// `manifest_path` is the full path to the Cargo.toml file being written; it is used to
+/// compute the relative path to the `mingling` crate.
+pub fn generate_cargo_toml(block: &CodeBlock, package_name: &str, manifest_path: &Path) -> String {
+    let features_str = if !block.features.is_empty() {
+        let feats: Vec = block.features.iter().map(|f| format!("\"{f}\"")).collect();
+        format!("features = [{}]", feats.join(", "))
+    } else {
+        String::new()
+    };
+
+    let mut extra_deps = String::new();
+    for (name, version) in &block.external_deps {
+        if !version.starts_with('{') {
+            if name == "serde" || name == "clap" {
+                extra_deps.push_str(&format!(
+                    "{name} = {{ version = \"{version}\", features = [\"derive\"] }}\n"
+                ));
+            } else {
+                extra_deps.push_str(&format!("{name} = \"{version}\"\n"));
+            }
+        } else {
+            extra_deps.push_str(&format!("{name} = {version}\n"));
+        }
+    }
+
+    let mingling_path = find_mingling_relative_path(manifest_path);
+
+    let deps_section = if features_str.is_empty() {
+        format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}",)
+    } else {
+        format!(
+            "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}",
+        )
+    };
+
+    // Build-time blocks: mirror the declared features into [build-dependencies]
+    // so that build.rs can use the same feature set as the crate itself.
+    let build_deps_section = if block.is_build_time {
+        let feats_str: Vec = block.features.iter().map(|f| format!("\"{f}\"")).collect();
+        let build_feats = if feats_str.is_empty() {
+            String::new()
+        } else {
+            format!("features = [{}]", feats_str.join(", "))
+        };
+        format!(
+            "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n"
+        )
+    } else {
+        String::new()
+    };
+
+    format!(
+        r#"[package]
+	name = "{package_name}"
+	version = "0.0.0"
+	edition = "2024"
+
+{deps_section}{build_deps_section}
+[workspace]
+"#
+    )
+}
+
+/// Compute the relative path from a Cargo.toml's parent directory to the `mingling` crate.
+///
+/// The process current directory is expected to be the project root (where `mingling/` lives).
+/// Returns a forward-slash path safe for embedding in TOML strings.
+fn find_mingling_relative_path(manifest_path: &Path) -> String {
+    let manifest_dir = manifest_path
+        .parent()
+        .expect("manifest_path has no parent directory");
+    let cwd = std::env::current_dir().expect("failed to get current directory");
+
+    // Strip cwd prefix to get the relative components of the manifest directory
+    let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir);
+    let depth = relative_to_root.components().count();
+
+    let mut result = String::new();
+    for _ in 0..depth {
+        result.push_str("../");
+    }
+    result.push_str("mingling");
+    result
+}
+
+/// Generate main.rs for a block
+///
+/// Automatically prepends `use mingling::prelude::*;` if the block doesn't already have it.
+pub fn generate_main_rs(block: &CodeBlock) -> String {
+    let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
+
+    if !block.code.contains("use mingling::prelude::*;") {
+        output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n");
+    }
+
+    output.push_str(&block.code);
+    output.push('\n');
+
+    if !block.has_main {
+        output.push_str("\nfn main() {}\n");
+    }
+
+    if !block.has_gen_program {
+        output.push_str("\nmingling::macros::gen_program!();\n");
+    }
+
+    output
+}
+
+/// Generate build.rs for a build-time block
+///
+/// Default: code wrapped in `fn main() { }`.
+pub fn generate_build_rs(block: &CodeBlock) -> String {
+    let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n");
+
+    if block.has_main {
+        output.push_str(&block.code);
+    } else {
+        output.push_str("fn main() {\n");
+        for line in block.code.lines() {
+            output.push_str("    ");
+            output.push_str(line);
+            output.push('\n');
+        }
+        output.push_str("}\n");
+    }
+
+    output
+}
+
+/// Build a single code block as a Cargo project.
+///
+/// When `is_build_time` is true, `src_content` is written to `build.rs` instead of `src/main.rs`,
+/// and a minimal `src/main.rs` stub (`fn main() {}`) is created.
+pub fn build_block(
+    src_dir: &Path,
+    manifest_path: &Path,
+    cargo_toml: &str,
+    src_content: &str,
+    is_build_time: bool,
+) -> (bool, String) {
+    if let Err(e) = std::fs::create_dir_all(src_dir) {
+        return (false, format!("mkdir: {e}"));
+    }
+
+    // Write Cargo.toml
+    if let Err(e) = std::fs::write(manifest_path, cargo_toml) {
+        return (false, format!("write Cargo.toml: {e}"));
+    }
+
+    if is_build_time {
+        // Write build.rs and a stub main.rs
+        let crate_dir = manifest_path.parent().unwrap();
+        if let Err(e) = std::fs::write(crate_dir.join("build.rs"), src_content) {
+            return (false, format!("write build.rs: {e}"));
+        }
+        if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") {
+            return (false, format!("write main.rs: {e}"));
+        }
+    } else {
+        // Normal: write src/main.rs
+        if let Err(e) = std::fs::write(src_dir.join("main.rs"), src_content) {
+            return (false, format!("write main.rs: {e}"));
+        }
+    }
+
+    // Check code — inherit stderr so cargo output is real-time and colored
+    let shell = if cfg!(target_os = "windows") {
+        "powershell"
+    } else {
+        "sh"
+    };
+    let cmd = format!(
+        "cargo check --color=always --manifest-path {}",
+        manifest_path.to_string_lossy()
+    );
+
+    let mut child = match std::process::Command::new(shell)
+        .arg("-c")
+        .arg(&cmd)
+        .stdout(std::process::Stdio::inherit())
+        .stderr(std::process::Stdio::piped())
+        .current_dir(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))
+        .spawn()
+    {
+        Ok(c) => c,
+        Err(e) => return (false, format!("spawn: {e}")),
+    };
+
+    // Read stderr (buffered, not forwarded — groups print their own output contiguously)
+    use std::io::BufRead;
+    let stderr_handle = child.stderr.take().unwrap();
+    let reader = std::io::BufReader::new(stderr_handle);
+    let mut captured = String::new();
+    for line in reader.lines() {
+        match line {
+            Ok(l) => {
+                captured.push_str(&l);
+                captured.push('\n');
+            }
+            Err(_) => break,
+        }
+    }
+
+    let status = child.wait().unwrap_or_else(|_| std::process::exit(1));
+    let exit_code = status.code().unwrap_or(1);
+
+    if exit_code == 0 {
+        (true, String::new())
+    } else {
+        let mut last_lines: Vec<&str> = captured.lines().rev().take(20).collect();
+        last_lines.reverse();
+        let detail = last_lines.join("\n");
+        (false, format!("exit code {exit_code}\n{detail}"))
+    }
+}
+
+/// Compute a stable hash for a code block based on its dependency configuration.
+///
+/// Blocks with the same features and external dependencies produce the same hash,
+/// allowing them to share a compiled crate and avoid redundant recompilation.
+///
+/// Hash input (all sorted for stability):
+/// - Sorted mingling feature strings
+/// - Sorted external dependency names
+/// - Sorted external dependency versions
+/// - Sorted external deps as `name=version` pairs
+pub fn compute_block_hash(block: &CodeBlock) -> String {
+    let mut features: Vec<&str> = block.features.iter().map(|s| s.as_str()).collect();
+    features.sort();
+    let features_str = features.join(",");
+
+    let mut dep_names: Vec<&str> = block
+        .external_deps
+        .iter()
+        .map(|(n, _)| n.as_str())
+        .collect();
+    dep_names.sort();
+    let dep_names_str = dep_names.join(",");
+
+    let mut dep_versions: Vec<&str> = block
+        .external_deps
+        .iter()
+        .map(|(_, v)| v.as_str())
+        .collect();
+    dep_versions.sort();
+    let dep_versions_str = dep_versions.join(",");
+
+    let mut deps: Vec = block
+        .external_deps
+        .iter()
+        .map(|(n, v)| format!("{n}={v}"))
+        .collect();
+    deps.sort();
+    let deps_str = deps.join(",");
+
+    let canonical = format!("{features_str}\n{dep_names_str}\n{dep_versions_str}\n{deps_str}");
+
+    // FNV-1a 64-bit hash — stable across runs (no random seed)
+    let mut hash: u64 = 0xcbf29ce484222325;
+    for &byte in canonical.as_bytes() {
+        hash ^= byte as u64;
+        hash = hash.wrapping_mul(0x100000001b3);
+    }
+
+    format!("{:016x}", hash)
+}
+
+/// Determine if a block should be treated as a test candidate.
+/// A block is NOT testable only if it has `// NOT VERIFIED` marker.
+pub fn is_block_testable(block: &CodeBlock) -> bool {
+    !block.not_verified
+}
+
+/// Write a summary report
+pub fn write_summary_report(
+    path: &Path,
+    title: &str,
+    results: &[(String, usize, bool, String)],
+    total: usize,
+    passed: usize,
+    failed: usize,
+) {
+    let mut content = String::new();
+    content.push_str(&format!("# {title}\n\n"));
+    content.push_str(&format!(
+        "Tested **{total}** code blocks: **{passed}** passed, **{failed}** failed.\n\n"
+    ));
+    content.push_str("## Results\n\n");
+    content.push_str("| Block | File | Line | Status |\n");
+    content.push_str("|-------|------|------|--------|\n");
+
+    for (i, (file, line, ok, _)) in results.iter().enumerate() {
+        let status = if *ok { "PASS" } else { "FAIL" };
+        let short_file = file.rsplit('/').next().unwrap_or(file);
+        content.push_str(&format!(
+            "| {} | {} | {} | {status} |\n",
+            i + 1,
+            short_file,
+            line
+        ));
+    }
+
+    let has_failures = results.iter().any(|(_, _, ok, _)| !ok);
+    if has_failures {
+        content.push_str("\n## Failed Blocks\n\n");
+        for (i, (file, line, ok, err)) in results.iter().enumerate() {
+            if !ok {
+                content.push_str(&format!(
+                    "### Block {} (`{}`, line {})\n\n```\n{err}\n```\n\n",
+                    i + 1,
+                    file,
+                    line
+                ));
+            }
+        }
+    }
+
+    std::fs::write(path, &content).unwrap_or_else(|e| {
+        eprintln!("Warning: failed to write {path:?}: {e}");
+    });
+
+    println_cargo_style!("Report: written to {}", path.display());
+}
diff --git a/dist/index.html b/dist/index.html
index 905e90a..116211f 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -908,7 +908,7 @@ mling update
>GitHub · - Docs + Docs diff --git a/docs/LICENSE b/docs/LICENSE deleted file mode 100644 index bec4d76..0000000 --- a/docs/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 docsifyjs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/docs/_zh_CN/index.html b/docs/_zh_CN/index.html index ad21062..d0b9234 100644 --- a/docs/_zh_CN/index.html +++ b/docs/_zh_CN/index.html @@ -41,7 +41,7 @@ " >🌗 主题 - English Docs + English Docs
diff --git a/docs/dev/README.md b/docs/dev/README.md index 9289fcf..29b7568 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -4,7 +4,7 @@ Internal development documentation for the Mingling codebase — design notes, issue discussions, and architectural decisions.

-This site is separate from the [Helpdoc](https://mingling-rs.github.io/mingling/docs/doc.html). +This site is separate from the [Helpdoc](https://mingling-rs.github.io/mingling/docs/index.html). The helpdoc is user-facing: `tutorials`, `feature guides`, and `how-to content` for developers _using_ Mingling to build CLI applications. diff --git a/docs/dev/index.html b/docs/dev/index.html index 1bfb5c5..327e397 100644 --- a/docs/dev/index.html +++ b/docs/dev/index.html @@ -125,7 +125,7 @@ " >🌓 Theme - 📖 Helpdoc + 📖 Helpdoc
diff --git a/docs/dev/pages/abouts/ci.md b/docs/dev/pages/abouts/ci.md index f9a58be..37015eb 100644 --- a/docs/dev/pages/abouts/ci.md +++ b/docs/dev/pages/abouts/ci.md @@ -3,7 +3,7 @@ CI workflow and local execution guide for Mingling

-Mingling's CI process is built into the project itself: the execution logic lives in `mingling_ci/`, a separate crate **built on the Mingling framework** — it dogfoods the very library it validates. You can run it locally via the `cargo ci` command, which produces the same results as the `CI` workflow in GitHub Actions. +Mingling's CI process is built into the project itself: the execution logic lives in `dev/ci/`, a separate crate **built on the Mingling framework** — it dogfoods the very library it validates. You can run it locally via the `cargo ci` command, which produces the same results as the `CI` workflow in GitHub Actions. During development, you can run `cargo ci ` at any time to verify that your code hasn't introduced regressions. @@ -13,7 +13,7 @@ An alias is defined in `.cargo/config.toml` at the project root: ```toml [alias] -ci = "run --manifest-path mingling_ci/Cargo.toml --bin ci --quiet --" +ci = "run --manifest-path dev/ci/Cargo.toml --bin ci --quiet --" ``` Run a single step: @@ -40,38 +40,38 @@ Every CI step is one subcommand. `cargo ci` with no subcommand prints the help p ### UTILS -| Command | What it does | -| --------------- | ----------------------------------------------------------------------- | +| Command | What it does | +| ---------------- | --------------------------------------------------------------------------------------- | | `report-collect` | Assembles the collected logs in `.temp/reports/collect/` into `.temp/reports/result.md` | -| `report-clean` | Deletes all collected logs and the generated report | -| `git-lock` | Locks the workspace for a CI run (temporary commit, see below) | -| `git-unlock` | Restores the workspace and checks idempotency (see below) | -| `show-manifests` | Prints every crate path that CI will check | -| `show-features` | Prints the `docs.rs` feature list of `mingling` | +| `report-clean` | Deletes all collected logs and the generated report | +| `git-lock` | Locks the workspace for a CI run (temporary commit, see below) | +| `git-unlock` | Restores the workspace and checks idempotency (see below) | +| `show-manifests` | Prints every crate path that CI will check | +| `show-features` | Prints the `docs.rs` feature list of `mingling` | ### TOOLS (refresh) -| Command | What it does | -| ------------------- | --------------------------------------------------------------------------------- | -| `example-refresh` | Regenerates `mingling/src/example_docs.rs` and `docs/example-pages/examples.json` | -| `docsify-refresh` | Fixes docsify code-box blank lines and regenerates `_sidebar.md` files | -| `features-refresh` | Regenerates `mingling/src/features.rs` from `mingling/Cargo.toml` | +| Command | What it does | +| ------------------ | ---------------------------------------------------------------------- | +| `example-refresh` | Regenerates `mingling/src/example_docs.rs` and `docs/examples.json` | +| `docsify-refresh` | Fixes docsify code-box blank lines and regenerates `_sidebar.md` files | +| `features-refresh` | Regenerates `mingling/src/features.rs` from `mingling/Cargo.toml` | These tools **write files**. Running them inside a `git-lock` / `git-unlock` pair turns them into an up-to-date check: if the generated files are stale, the tree becomes dirty and `git-unlock` fails. ### TASKS (checks) -| Command | What it does | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `build-check` | Finds all `Cargo.toml` files (minus `.config/ci-ignored-dirs.txt`) and runs `cargo build` per crate in parallel. | -| `clippy-check` | Runs `cargo clippy ... -- -D warnings` for every crate in parallel; any warning fails the check. | -| `test-all` | Runs `cargo test` for every crate in parallel. Each base crate can override its command in its `mingling-ci.toml` (`[test].command`, with `<<>>` expanded from the docs.rs feature list); `arg-picker` uses this to run `cargo test -p arg-picker`. | -| `example-check` | Builds every example and runs the expected-output tests declared in `examples//test.toml`. | -| `docs-check` | Builds the `mingling` API docs with the `[package.metadata.docs.rs]` features and `-D warnings`. | -| `markdown-check ` | Verifies the rust code blocks of a single markdown file compile. See [ABOUT_CODE_VERIFY](docs/_ABOUT_CODE_VERIFY.md). | -| `markdown-check-all` | Verifies all markdown files declared in `.config/verified-docs.toml`. | -| `markdown-compare ` | Compares the *structure* of two markdown files or directories. | -| `markdown-compare-all` | Checks every translated docs directory mirrors the reference `./docs/pages/` (per `.config/docs-lang.txt`). | +| Command | What it does | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build-check` | Finds all `Cargo.toml` files (minus `dev/configs/ci-ignored-dirs.txt`) and runs `cargo build` per crate in parallel. | +| `clippy-check` | Runs `cargo clippy ... -- -D warnings` for every crate in parallel; any warning fails the check. | +| `test-all` | Runs `cargo test` for every crate in parallel. Each base crate can override its command in its `mingling-ci.toml` (`[test].command`, with `<<>>` expanded from the docs.rs feature list); `arg-picker` uses this to run `cargo test -p arg-picker`. | +| `example-check` | Builds every example and runs the expected-output tests declared in `examples//test.toml`. | +| `docs-check` | Builds the `mingling` API docs with the `[package.metadata.docs.rs]` features and `-D warnings`. | +| `markdown-check ` | Verifies the rust code blocks of a single markdown file compile. See [ABOUT_CODE_VERIFY](docs/_ABOUT_CODE_VERIFY.md). | +| `markdown-check-all` | Verifies all markdown files declared in `dev/configs/verified-docs.toml`. | +| `markdown-compare ` | Compares the _structure_ of two markdown files or directories. | +| `markdown-compare-all` | Checks every translated docs directory mirrors the reference `./docs/pages/` (per `dev/configs/docs-lang.txt`). | ## Reports @@ -122,7 +122,7 @@ cargo ci git-unlock --show-diff This is what the CI workflow uses: an idempotency failure shows exactly what contaminated the workspace in the job logs. -> **Warning**: when unlocking a `true` lock, changes made *during* CI are discarded. Anything you had before locking comes back. +> **Warning**: when unlocking a `true` lock, changes made _during_ CI are discarded. Anything you had before locking comes back. ## GitHub Actions Workflow @@ -131,16 +131,16 @@ This is what the CI workflow uses: an idempotency failure shows exactly what con - Triggered on `push` to the `main` branch. - A `Check` job runs in a **item × platform** matrix (`ubuntu-latest`, `windows-latest`, `macos-latest`), each combination being `cargo ci ` inside a `git-lock` / `git-unlock` pair: -| Matrix item | Command | -| -------------- | -------------------------------------------------------------- | -| `build` | `cargo ci build-check` | -| `clippy` | `cargo ci clippy-check` | -| `test` | `cargo ci test-all` | -| `arg-picker` | `cargo ci test-all` (covered via its `mingling-ci.toml` override) | -| `markdown-code` | `cargo ci markdown-check-all && cargo ci markdown-compare-all` | -| `examples` | `cargo ci example-check` | -| `docs-refresh` | `cargo ci example-refresh` + `docsify-refresh` + `features-refresh` | -| `api-docs` | `cargo ci docs-check` | +| Matrix item | Command | +| --------------- | ------------------------------------------------------------------- | +| `build` | `cargo ci build-check` | +| `clippy` | `cargo ci clippy-check` | +| `test` | `cargo ci test-all` | +| `arg-picker` | `cargo ci test-all` (covered via its `mingling-ci.toml` override) | +| `markdown-code` | `cargo ci markdown-check-all && cargo ci markdown-compare-all` | +| `examples` | `cargo ci example-check` | +| `docs-refresh` | `cargo ci example-refresh` + `docsify-refresh` + `features-refresh` | +| `api-docs` | `cargo ci docs-check` | - Every matrix job uploads its `.temp/reports/collect/` as an artifact — **even on failure**, so failures are always collected. - A `Report` job (runs even when some checks failed) downloads all collect artifacts, runs `cargo ci report-collect`, and publishes `result.md` to the job summary via `$GITHUB_STEP_SUMMARY`. diff --git a/docs/dev/pages/abouts/code-verify-system.md b/docs/dev/pages/abouts/code-verify-system.md index c2a9215..f52052f 100644 --- a/docs/dev/pages/abouts/code-verify-system.md +++ b/docs/dev/pages/abouts/code-verify-system.md @@ -7,7 +7,7 @@ This system automatically extracts and compiles Rust code blocks from docs, ensu ## Config -Specify which Markdown files to verify via `.config/verified-docs.toml`: +Specify which Markdown files to verify via `dev/configs/verified-docs.toml`: ```toml [verified] @@ -210,18 +210,18 @@ Use `@@@` for: ## Structure Overview -| Module | Responsibility | -| --------------------------------------------- | ----------------------------------------------------------------------------------- | -| `mingling_ci/src/markdown/project.rs` | Block parsing, Cargo.toml/main.rs generation, FNV-1a dep hash | -| `mingling_ci/src/markdown/test.rs` | Grouping by dep hash, parallel `cargo check` execution | -| `mingling_ci/src/task/cmd_markdown_check.rs` | `markdown-check` / `markdown-check-all` commands: read config, collect files, report | -| `mingling_ci/src/markdown/compare.rs` | Structural signature comparison (for `markdown-compare`) | -| `mingling_ci/src/task/cmd_markdown_compare.rs`| `markdown-compare` / `markdown-compare-all` commands | -| `.config/verified-docs.toml` | Specifies which doc files to verify | +| Module | Responsibility | +| ----------------------------------------- | ------------------------------------------------------------------------------------ | +| `dev/ci/src/markdown/project.rs` | Block parsing, Cargo.toml/main.rs generation, FNV-1a dep hash | +| `dev/ci/src/markdown/test.rs` | Grouping by dep hash, parallel `cargo check` execution | +| `dev/ci/src/task/cmd_markdown_check.rs` | `markdown-check` / `markdown-check-all` commands: read config, collect files, report | +| `dev/ci/src/markdown/compare.rs` | Structural signature comparison (for `markdown-compare`) | +| `dev/ci/src/task/cmd_markdown_compare.rs` | `markdown-compare` / `markdown-compare-all` commands | +| `dev/configs/verified-docs.toml` | Specifies which doc files to verify | ### Structure Comparison -`markdown-compare` (two files or directories) and `markdown-compare-all` (all languages from `.config/docs-lang.txt`, whose first line is the reference directory) check that every translated docs directory **mirrors the structure** of the reference docs exactly: one token per line classifying headings, fenced code blocks (with language tag), `@@@` lines, blank lines, blockquotes, lists and plain text. Translated text may differ; the structure may not. +`markdown-compare` (two files or directories) and `markdown-compare-all` (all languages from `dev/configs/docs-lang.txt`, whose first line is the reference directory) check that every translated docs directory **mirrors the structure** of the reference docs exactly: one token per line classifying headings, fenced code blocks (with language tag), `@@@` lines, blank lines, blockquotes, lists and plain text. Translated text may differ; the structure may not. ## Full Example diff --git a/docs/doc.html b/docs/doc.html deleted file mode 100644 index a63fdd3..0000000 --- a/docs/doc.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - Mingling Helpdoc - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - diff --git a/docs/example-pages/examples.json b/docs/example-pages/examples.json deleted file mode 100644 index dd5b65b..0000000 --- a/docs/example-pages/examples.json +++ /dev/null @@ -1,393 +0,0 @@ -[ - { - "id": "example-basic", - "name": "Basic", - "icon": "🚀", - "category": "core", - "desc": "Demonstrates the basic usage of Mingling with a simple `greet` subcommand that takes a name and prints a greeting.\n", - "tags": [ - "dispatcher!", - "#[chain]", - "#[renderer]" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-argument-picker", - "name": "Argument Picker", - "icon": "📋", - "category": "parsing", - "desc": "Demonstrates how to use Mingling's `picker` feature and `Picker` to extract typed arguments from the command line.\n", - "tags": [ - "arg-picker", - "SinglePickable" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-async-support", - "name": "Async Support", - "icon": "⚡", - "category": "runtime", - "desc": "Shows how to drive an async runtime with Mingling using the `async` feature, enabling `async fn` in `#[chain]` with tokio.\n", - "tags": [ - "async", - "await", - "#[chain]" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-clap-binding", - "name": "Clap Binding", - "icon": "🔗", - "category": "parsing", - "desc": "Demonstrates how to bind a `clap::Parser` derive struct to Mingling using `#[dispatcher_clap]` for advanced argument parsing.\n", - "tags": [ - "clap" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-combine-pathf-dispatch-tree", - "name": "Pathfinder + Dispatch Tree", - "icon": "🧭", - "category": "advanced", - "desc": "Demonstrates combining the `pathf` and `dispatch_tree` features. Types are defined in submodules and automatically resolved. Requires `dispatch_tree` in both `[dependencies]` and `[build-dependencies]`.\n", - "tags": [ - "pathf", - "dispatch_tree", - "extras" - ], - "files": [ - "src/main.rs", - "src/sub/mod.rs", - "Cargo.toml" - ] - }, - { - "id": "example-combine-pathf-metadata", - "name": "Pathfinder + Metadata", - "icon": "🧭", - "category": "advanced", - "desc": "Combines the `pathf` feature with entry metadata. The metadata `DataType` and the entry `BindType` are defined inside a submodule, and `pathf` resolves them for `gen_program!()` at build time.\n", - "tags": [ - "pathf", - "metadata" - ], - "files": [ - "src/main.rs", - "src/sub/mod.rs", - "Cargo.toml" - ] - }, - { - "id": "example-command-macro", - "name": "Command Macro", - "icon": "🚀", - "category": "advanced", - "desc": "Introduced how to use the `#[command]` macro to generate commands with minimal boilerplate\n", - "tags": [ - "#[command]", - "dispatcher!", - "#[chain]" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-completion", - "name": "Completion", - "icon": "🔄", - "category": "ux", - "desc": "Demonstrates how to implement dynamic shell completion with `#[completion]` and generate scripts for bash, zsh, fish, and pwsh.\n", - "tags": [ - "comp" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-dispatch-tree", - "name": "Dispatch Tree", - "icon": "🌳", - "category": "dispatch", - "desc": "Introduces the `dispatch_tree` feature that converts the subcommand list into a compile-time prefix trie for O(n) command lookup.\n", - "tags": [ - "dispatch_tree" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-enum-tag", - "name": "Enum Tag", - "icon": "🏷️", - "category": "parsing", - "desc": "Shows how to derive `EnumTag` on enums for parsing variants from CLI strings with renames and descriptions.\n", - "tags": [ - "enum_tag", - "Pickable" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-error-handling", - "name": "Error Handling", - "icon": "⚠️", - "category": "runtime", - "desc": "Demonstrates how to define custom error types with `pack!` and route them to dedicated `#[renderer]` functions for user-friendly output.\n", - "tags": [ - "Result" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-exitcode", - "name": "Exitcode", - "icon": "🚪", - "category": "runtime", - "desc": "Shows how to set custom exit codes using `ExitCodeSetup` and the `finish` hook to signal success or failure to the shell.\n", - "tags": [ - "ExitCode" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-help", - "name": "Help", - "icon": "💡", - "category": "ux", - "desc": "Shows how to use the `#[help]` attribute to provide custom per-command help text that activates when the user passes `--help`.\n", - "tags": [ - "#[help]" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-hook", - "name": "Hook", - "icon": "🪝", - "category": "runtime", - "desc": "Demonstrates how to use Mingling's `ProgramHook` system to observe and debug every stage of the execution pipeline.\n", - "tags": [ - "ProgramHook" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-implicit-dispatcher", - "name": "Implicit Dispatcher", - "icon": "🫥", - "category": "dispatch", - "desc": "Shows the abbreviated `dispatcher!(\"cmd.path\")` syntax from `extras` that auto-derives struct names from the command path.\n", - "tags": [ - "implicit" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-lazy-resources", - "name": "Lazy Resources", - "icon": "💤️", - "category": "advanced", - "desc": "Demonstrates how to use `LazyRes` for lazily initialized resources that only allocate when first accessed.\n", - "tags": [ - "LazyRes" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-metadata", - "name": "Entry Metadata", - "icon": "🏷️", - "category": "advanced", - "desc": "Demonstrates attaching arbitrary, compile-time-typed metadata to an entry via `#[metadata(Entry)]` and retrieving it at runtime with `ProgramCollect::get_metadata`. No `pathf` needed here — everything lives in a single module.\n", - "tags": [ - "metadata", - "get_metadata", - "extras" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-outside-type", - "name": "Outside Type", - "icon": "🆕", - "category": "advanced", - "desc": "Demonstrates how to use the `group!()` macro to convert an external type into a type recognizable by Mingling\n", - "tags": [ - "group!", - "extras" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-panic-unwind", - "name": "Panic Unwind", - "icon": "💥", - "category": "runtime", - "desc": "Shows how to catch panics during program execution and display friendly error messages instead of a raw panic trace.\n", - "tags": [ - "panic_unwind" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-pathfinder", - "name": "Module Pathfinder", - "icon": "🧭", - "category": "advanced", - "desc": "Demonstrates the `pathf` feature, which automatically resolves type module paths at build time. Types can be defined in submodules without explicit `use` in the main module.\n", - "tags": [ - "pathf", - "architecture" - ], - "files": [ - "Cargo.toml", - "src/main.rs", - "src/sub/mod.rs" - ] - }, - { - "id": "example-repl-basic", - "name": "REPL Basic", - "icon": "🔁", - "category": "repl", - "desc": "Demonstrates how to build an interactive REPL shell with Mingling using `exec_repl()`, custom prompts, and built-in setups.\n", - "tags": [ - "repl" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-resources", - "name": "Resources", - "icon": "📦", - "category": "advanced", - "desc": "Shows how to share global state across commands using `with_resource()` and inject `&T` or `&mut T` into chain and renderer functions.\n", - "tags": [ - "Resources", - "injection" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-setup", - "name": "Setup", - "icon": "🏗️", - "category": "core", - "desc": "Demonstrates how to build a custom `ProgramSetup` with `#[program_setup]` for modular configuration of program behaviour.\n", - "tags": [ - "#[setup]", - "extras" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-structural-renderer", - "name": "structural renderer", - "icon": "📤", - "category": "output", - "desc": "Demonstrates how to render structured output in JSON or YAML using `StructuralRendererSetup` and the `structural_renderer` feature.\n", - "tags": [ - "structural_renderer", - "--json", - "--yaml" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "example-unit-test", - "name": "Unit Test", - "icon": "🧪", - "category": "testing", - "desc": "Shows how to write unit tests for Chain and Renderer functions using the `entry!` macro and assertion helpers.\n", - "tags": [ - "testing", - "extras" - ], - "files": [ - "src/main.rs", - "Cargo.toml" - ] - }, - { - "id": "full-todolist", - "name": "Todo List", - "icon": "📝", - "category": "full", - "desc": "This is a complete example project that demonstrates how to develop a todo list application using Mingling\n", - "tags": [ - "todolist", - "CRUD", - "cliché example" - ], - "files": [ - "src/main.rs", - "src/todolist.rs", - "src/help.rs", - "Cargo.toml" - ] - } -] \ No newline at end of file diff --git a/docs/example-viewer.html b/docs/example-viewer.html index 0417c30..77232ec 100644 --- a/docs/example-viewer.html +++ b/docs/example-viewer.html @@ -450,7 +450,7 @@ Mìng Lìng

Examples

- Help Doc + Help Doc

You know the reason! ... the **grandfather paradox** -mingling = { version = "0.4.0", features = [ - "async", - "dispatch_tree", - "extras", - "pathf", - "picker", -] } - -just_progress = "0.1.3" -colored = "3.1.1" -indicatif = "0.18.4" -tokio = { version = "1.53.1", features = [ - "rt", - "rt-multi-thread", - "macros", - "process", -] } - -prettytable-rs = "0.10.0" -toml = "0.8" -just_template = "0.2.1" -just_fmt = "0.2.1" -serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" - -[build-dependencies] -mingling = { version = "0.4.0", features = [ - "build", - "dispatch_tree", - "pathf" -] } diff --git a/mingling_ci/build.rs b/mingling_ci/build.rs deleted file mode 100644 index e0bcc0c..0000000 --- a/mingling_ci/build.rs +++ /dev/null @@ -1,6 +0,0 @@ -use mingling::build::analyze_and_build_type_mapping; - -fn main() { - analyze_and_build_type_mapping().unwrap(); -} - diff --git a/mingling_ci/help.txt b/mingling_ci/help.txt deleted file mode 100644 index de006bb..0000000 --- a/mingling_ci/help.txt +++ /dev/null @@ -1,34 +0,0 @@ -This program is used to check the code quality of the Mingling project itself. - -USAGE: cargo ci [SUBCOMMAND] - -FLAGS: - -h, --help Print this help page - -q, --quiet Quiet output - -COMMANDS: - UTILS: - report-collect Collect and organize all inspection reports - report-clean Clean up all reports - - git-lock Temporarily commit the workspace for CI - git-unlock Restore the workspace after CI - - show-features Print the docs.rs feature list of mingling - show-manifests Print all crate paths that need to be checked - - TOOLS: - example-refresh Regenerate example docs module and examples index - docsify-refresh Fix docsify code boxes and regenerate sidebars - features-refresh Regenerate the features module - - TASKS: - markdown-check Verify rust code blocks in one markdown file - markdown-check-all Verify rust code blocks in all configured markdown files - markdown-compare Compare the structure of two markdown files/dirs - markdown-compare-all Compare all translated docs against the reference - build-check Build all crates - clippy-check Run clippy with -D warnings on all crates - test-all Test all crates - example-check Build examples and run their test.toml cases - docs-check Build mingling docs with -D warnings diff --git a/mingling_ci/src/bin/ci.rs b/mingling_ci/src/bin/ci.rs deleted file mode 100644 index 5b1d748..0000000 --- a/mingling_ci/src/bin/ci.rs +++ /dev/null @@ -1,30 +0,0 @@ -use mingling::setup::{ - ConfirmSetup, DirectoryEnvironmentSetup, ExitCodeSetup, - picker::{ConfirmFlagSetup, HelpFlagSetup, QuietFlagSetup}, -}; - -use mingling_ci_system::ThisProgram; -use mingling_ci_system::res::*; - -#[tokio::main] -async fn main() { - let mut program = ThisProgram::new(); - - // Plugins - program.with_setup(ExitCodeSetup::default()); - program.with_setup(DirectoryEnvironmentSetup::default()); - - program.with_setup(HelpFlagSetup::default()); - program.with_setup(ConfirmFlagSetup::default()); - program.with_setup(QuietFlagSetup::default()); - - program.with_setup(ConfirmSetup); - - // CI Plugins - program.with_setup(ManifestsSetup); - program.with_setup(FeaturesSetup); - program.with_setup(CrateConfigSetup); - program.with_setup(ReportSetup); - - program.exec_and_exit().await; -} diff --git a/mingling_ci/src/cmd.rs b/mingling_ci/src/cmd.rs deleted file mode 100644 index b9a02dc..0000000 --- a/mingling_ci/src/cmd.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub(crate) mod cmd_git_lock; -pub(crate) mod cmd_git_unlock; -pub(crate) mod cmd_report_clean; -pub(crate) mod cmd_report_collect; -pub(crate) mod cmd_show_features; -pub(crate) mod cmd_show_manifests; diff --git a/mingling_ci/src/cmd/cmd_git_lock.rs b/mingling_ci/src/cmd/cmd_git_lock.rs deleted file mode 100644 index 0e9bf22..0000000 --- a/mingling_ci/src/cmd/cmd_git_lock.rs +++ /dev/null @@ -1,77 +0,0 @@ -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, r_println, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::git::{CI_TEMP_COMMIT_MESSAGE, LOCK_FILE, TEMP_COMMIT_MESSAGE, run_git, worktree_clean}; -use crate::res::{CargoError, MessagePrinter}; - -/// Temporarily commits the workspace so CI can run on a stable tree. -/// -/// First pins the current HEAD to the `mingling/bkup` backup branch (created -/// or force-reset). When the tree is dirty, all changes are packed into a -/// plain `TEMP` commit first so they can be restored later; the `CI TEMP` -/// commit then carries only the `MINGLING-CI-CHECKING` marker file, whose -/// content (`true`/`false`) tells `git-unlock` which restore path to take. -#[command(node = "git-lock")] -pub fn git_lock() -> Next { - if let Err(e) = run_git(["branch", "-f", "mingling/bkup", "HEAD"]) { - return ErrorGitLock(e).to_chain(); - } - - let dirty = !worktree_clean(); - if dirty { - if let Err(e) = run_git(["add", "."]) { - return ErrorGitLock(e).to_chain(); - } - if let Err(e) = run_git(["commit", "-m", TEMP_COMMIT_MESSAGE]) { - return ErrorGitLock(e).to_chain(); - } - } - - let marker = if dirty { "true" } else { "false" }; - if let Err(e) = std::fs::write(LOCK_FILE, marker) { - return ErrorGitLock(format!("failed to create {LOCK_FILE}: {e}")).to_chain(); - } - - if let Err(e) = run_git(["add", "."]) { - return ErrorGitLock(e).to_chain(); - } - if let Err(e) = run_git(["commit", "-m", CI_TEMP_COMMIT_MESSAGE]) { - return ErrorGitLock(e).to_chain(); - } - - ResultGitLock { dirty }.to_chain() -} - -/// Whether the tree was dirty (a base `TEMP` commit exists) when locking. -#[derive(Grouped)] -pub struct ResultGitLock { - dirty: bool, -} - -#[derive(Grouped, Default)] -pub struct ErrorGitLock(pub String); - -#[renderer(buffer)] -pub fn render_git_lock(r: ResultGitLock) { - if r.dirty { - r_println!("Locked: dirty workspace committed for CI"); - } else { - r_println!("Locked: clean workspace marked for CI"); - } -} - -#[renderer] -pub fn render_error_git_lock( - e: ErrorGitLock, - error: &CargoError, - exit_code: &mut ResExitCode, -) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![format!("Git-Lock: {}", e.0)]); - exit_code.exit_code = 1; - render_result -} diff --git a/mingling_ci/src/cmd/cmd_git_unlock.rs b/mingling_ci/src/cmd/cmd_git_unlock.rs deleted file mode 100644 index 41efefc..0000000 --- a/mingling_ci/src/cmd/cmd_git_unlock.rs +++ /dev/null @@ -1,116 +0,0 @@ -use mingling::{ - Grouped, RenderResult, Routable, - macros::{arg, buffer, command, r_println, renderer}, - picker::{EntryPicker, value::Flag}, - res::ResExitCode, -}; - -use crate::git::{LOCK_FILE, TEMP_COMMIT_MARK, head_message, run_git, worktree_clean}; -use crate::res::{CargoError, MessagePrinter}; -use crate::{Entry, Next}; - -/// Undoes a CI temporary commit created by [`crate::cmd::cmd_git_lock`]. -/// -/// Only acts when the HEAD commit message contains `CI TEMP` (case-sensitive). -/// The restore path is picked by the marker file content: -/// -/// - `true`: a base `TEMP` commit with the dirty changes sits below; restore -/// by hard-resetting past the marker commit, then soft-resetting and -/// unstaging to put the user's changes back into the working tree. -/// - `false`: the tree was clean; a single hard reset back to the original -/// HEAD is enough. -/// -/// When the working tree is dirty (e.g. CI left tracked changes behind) the -/// restore still runs, but the command reports a non-zero exit code so the -/// caller knows the CI phase contaminated the repository. With `--show-diff` -/// the diff of those changes is printed before they are discarded. -#[command(node = "git-unlock")] -// `#[command]` rewrites an owned first param into the entry type, so the args -// must be passed by value even though the body only reads them. -#[allow(clippy::needless_pass_by_value)] -pub fn git_unlock(args: Entry) -> Next { - let head = head_message().unwrap_or_default(); - if !head.contains(TEMP_COMMIT_MARK) { - return ErrorGitUnlock(format!("HEAD is not a CI temporary commit: `{head}`")).to_chain(); - } - - // Record dirtiness before restoring: the restore discards those changes. - let dirty = !worktree_clean(); - - // The marker file lives in the HEAD (CI TEMP) commit, so it is readable - // from the working tree; a missing marker falls back to the clean path. - let based_on_dirty = - std::fs::read_to_string(LOCK_FILE).is_ok_and(|content| content.trim() == "true"); - - if dirty && *args.pick(&arg![show_diff: Flag]).unwrap() { - show_diff(); - } - - if let Err(e) = undo_ci_phase(based_on_dirty) { - return ErrorGitUnlock(e).to_chain(); - } - - ResultGitUnlock { dirty }.to_chain() -} - -/// Prints the tracked changes the CI run left behind, before the restore -/// discards them. Untracked files are not shown (they are removed by clean). -fn show_diff() { - let Ok(diff) = run_git(["diff", "HEAD"]) else { - return; - }; - if diff.is_empty() { - return; - } - println!("{diff}"); -} - -/// Restores the workspace, keeping the user's pre-lock changes. -/// -/// With a base `TEMP` commit (`true`) the marker commit is dropped by a hard -/// reset to `HEAD~1`, the `TEMP` commit is unwrapped into the staging area by -/// a soft reset, and a plain reset unstages it back into the working tree. -/// Without one (`false`) a single hard reset to `HEAD~1` removes the marker -/// commit and lands on the original HEAD. -fn undo_ci_phase(based_on_dirty: bool) -> Result<(), String> { - run_git(["reset", "--hard", "HEAD~1"])?; - if based_on_dirty { - // Unwrap the `TEMP` commit into the staging area, then unstage it - // back into the working tree. - run_git(["reset", "--soft", "HEAD~1"])?; - run_git(["reset"])?; - } - std::fs::remove_file(LOCK_FILE).ok(); - Ok(()) -} - -/// Whether the working tree was dirty when the unlock started. -#[derive(Grouped)] -pub struct ResultGitUnlock { - dirty: bool, -} - -#[derive(Grouped, Default)] -pub struct ErrorGitUnlock(pub String); - -#[renderer(buffer)] -pub fn render_git_unlock(r: ResultGitUnlock, exit_code: &mut ResExitCode) { - if r.dirty { - r_println!("Unlocked: workspace restored (working tree was dirty)"); - exit_code.exit_code = 1; - } else { - r_println!("Unlocked: workspace restored"); - } -} - -#[renderer] -pub fn render_error_git_unlock( - e: ErrorGitUnlock, - error: &CargoError, - exit_code: &mut ResExitCode, -) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![format!("Git-Unlock: {}", e.0)]); - exit_code.exit_code = 1; - render_result -} diff --git a/mingling_ci/src/cmd/cmd_report_clean.rs b/mingling_ci/src/cmd/cmd_report_clean.rs deleted file mode 100644 index 976851e..0000000 --- a/mingling_ci/src/cmd/cmd_report_clean.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::path::PathBuf; - -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, r_println, renderer}, -}; - -use crate::Next; -use crate::reporter::{COLLECT_DIR, REPORT_PATH}; -use crate::res::{CargoError, MessagePrinter}; - -/// Removes collected logs and the generated report. -#[command(node = "report-clean")] -pub fn report_clean() -> Next { - let mut removed = Vec::new(); - for path in [PathBuf::from(COLLECT_DIR), PathBuf::from(REPORT_PATH)] { - match std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path)) { - Ok(()) => removed.push(path), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return ErrorReportClean(format!("failed to remove {}: {e}", path.display())) - .to_chain(); - } - } - } - ResultReportClean { removed }.to_chain() -} - -/// Paths removed by `report-clean`. -#[derive(Grouped)] -pub struct ResultReportClean { - pub removed: Vec, -} - -#[derive(Grouped, Default)] -pub struct ErrorReportClean(pub String); - -#[renderer(buffer)] -pub fn render_report_clean(r: ResultReportClean) { - if r.removed.is_empty() { - r_println!("Report data already clean"); - } else { - for path in r.removed { - r_println!("Removed {}", path.display()); - } - } -} - -#[renderer] -pub fn render_error_report_clean(e: ErrorReportClean, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![format!("Report: {}", e.0)]); - render_result -} diff --git a/mingling_ci/src/cmd/cmd_report_collect.rs b/mingling_ci/src/cmd/cmd_report_collect.rs deleted file mode 100644 index 2eff074..0000000 --- a/mingling_ci/src/cmd/cmd_report_collect.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; - -use just_template::Template; -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, r_println, renderer}, -}; - -use crate::Next; -use crate::reporter::{COLLECT_DIR, REPORT_PATH}; -use crate::res::{CargoError, MessagePrinter, ResCollectLogs}; - -const REPORT_TEMPLATE: &str = include_str!("../../tmpls/report.md"); -const TASK_SECTION_TEMPLATE: &str = include_str!("../../tmpls/task_section.md"); - -/// Maps a package to its per-OS pass/fail status. -type OsStatuses = BTreeMap; - -/// A row in a task section: item name and its per-OS statuses. -type TaskRow<'a> = (&'a String, &'a OsStatuses); - -/// Rows grouped by task name. -type RowsByTask<'a> = BTreeMap<&'a String, Vec>>; - -#[command(node = "report-collect")] -pub fn report_collect(logs: &ResCollectLogs) -> Next { - if !PathBuf::from(COLLECT_DIR).is_dir() { - return ErrorNoCollectDir.to_chain(); - } - - // Group rows by task: task -> [(item, os_statuses)]. - let by_task: RowsByTask = - logs.statuses - .iter() - .fold(BTreeMap::new(), |mut acc, ((task, item), os_statuses)| { - acc.entry(task).or_default().push((item, os_statuses)); - acc - }); - - // Render one section per task (table rows + this task's failures). - let mut fail_count = 0; - let mut sections: Vec> = Vec::new(); - for (task, rows) in by_task { - let mut row_arms = Vec::new(); - let mut fail_arms = Vec::new(); - for (item, os_statuses) in rows { - let location = logs - .locations - .get(&(task.clone(), item.clone())) - .cloned() - .unwrap_or_default(); - row_arms.push(HashMap::from([ - ("item_name".to_string(), item.clone()), - ("location".to_string(), location), - ( - "pass_win".to_string(), - pass_cell(os_statuses.get("Windows")), - ), - ( - "pass_linux".to_string(), - pass_cell(os_statuses.get("Linux")), - ), - ("pass_mac".to_string(), pass_cell(os_statuses.get("MacOS"))), - ])); - - for (os, ok) in os_statuses { - if !ok { - let stdout = logs - .err_outputs - .get(&(task.clone(), os.clone(), item.clone())) - .cloned() - .unwrap_or_default(); - fail_arms.push(HashMap::from([ - ("item_name".to_string(), item.clone()), - ("stdout".to_string(), stdout), - ])); - fail_count += 1; - } - } - } - - let mut section = Template::from(TASK_SECTION_TEMPLATE); - section.insert_param("task_name".to_string(), task.clone()); - *section.add_impl("rows".to_string()) = row_arms; - *section.add_impl("fails".to_string()) = fail_arms; - sections.push(HashMap::from([( - "section".to_string(), - section.expand().unwrap_or_default(), - )])); - } - - let mut template = Template::from(REPORT_TEMPLATE); - - template.insert_param("date".to_string(), logs.git.date.clone()); - template.insert_param("commit_hash".to_string(), logs.git.commit_hash.clone()); - *template.add_impl("task_sections".to_string()) = sections; - - let expanded = template.expand().unwrap_or_default(); - let output = PathBuf::from(REPORT_PATH); - let parent = output.parent().expect("output path has a parent"); - - if let Err(e) = std::fs::create_dir_all(parent).and_then(|()| std::fs::write(&output, expanded)) - { - return ErrorReportWrite(format!("failed to write {}: {e}", output.display())).to_chain(); - } - - ResultCollectResults { output, fail_count }.to_chain() -} - -fn pass_cell(status: Option<&bool>) -> String { - match status { - Some(true) => "✅".to_string(), - Some(false) => "❌".to_string(), - None => "—".to_string(), - } -} - -/// The generated report. -#[derive(Grouped)] -pub struct ResultCollectResults { - pub output: PathBuf, - pub fail_count: usize, -} - -#[derive(Grouped, Default)] -pub struct ErrorNoCollectDir; - -#[derive(Grouped, Default)] -pub struct ErrorReportWrite(pub String); - -#[renderer(buffer)] -pub fn render_collect_results(r: ResultCollectResults) { - r_println!("Collected {} failing logs", r.fail_count); - r_println!("Report generated at {}", r.output.display()); -} - -#[renderer] -pub fn render_error_no_collect_dir(_: ErrorNoCollectDir, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![format!("No collect directory: {COLLECT_DIR}")]); - render_result -} - -#[renderer] -pub fn render_error_report_write(e: ErrorReportWrite, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![format!("Report: {}", e.0)]); - render_result -} diff --git a/mingling_ci/src/cmd/cmd_show_features.rs b/mingling_ci/src/cmd/cmd_show_features.rs deleted file mode 100644 index 5fff0c5..0000000 --- a/mingling_ci/src/cmd/cmd_show_features.rs +++ /dev/null @@ -1,26 +0,0 @@ -use mingling::{ - Grouped, - macros::{buffer, command, r_println, renderer}, -}; - -use crate::res::ResFeatureList; - -#[command(node = "show-features")] -pub fn show_features(features: &ResFeatureList) -> ResultShowFeatures { - ResultShowFeatures { - features: features.list.clone(), - } -} - -/// The docs.rs feature list of `mingling`. -#[derive(Grouped)] -pub struct ResultShowFeatures { - pub features: Vec, -} - -#[renderer(buffer)] -pub fn render_show_features(r: ResultShowFeatures) { - for feature in r.features { - r_println!("{feature}"); - } -} diff --git a/mingling_ci/src/cmd/cmd_show_manifests.rs b/mingling_ci/src/cmd/cmd_show_manifests.rs deleted file mode 100644 index 2be82d2..0000000 --- a/mingling_ci/src/cmd/cmd_show_manifests.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::path::PathBuf; - -use mingling::{ - Grouped, - macros::{buffer, command, r_println, renderer}, -}; - -use prettytable::{ - Cell, Row, Table, - format::{FormatBuilder, LinePosition, LineSeparator}, -}; - -use crate::res::Manifests; - -#[command(node = "show-manifests")] -pub fn show_manifests(manifests: &Manifests) -> ResultPrintManifests { - let mut entries: Vec = manifests - .package_dirs - .iter() - .map(|(name, path)| ManifestEntry { - name: name.clone(), - path: path.clone(), - }) - .collect(); - entries.sort_by(|a, b| a.path.cmp(&b.path)); - ResultPrintManifests { entries } -} - -/// All manifests the CI will check, sorted by path. -#[derive(Grouped)] -pub struct ResultPrintManifests { - pub entries: Vec, -} - -#[derive(Debug, Clone)] -pub struct ManifestEntry { - pub name: String, - pub path: PathBuf, -} - -#[renderer(buffer)] -pub fn render_print_manifests(r: ResultPrintManifests) { - let mut table = Table::new(); - - table.set_format( - FormatBuilder::new() - .column_separator('│') - .borders('│') - .separator(LinePosition::Top, LineSeparator::new('─', '┬', '┌', '┐')) - .separator(LinePosition::Title, LineSeparator::new('─', '┼', '├', '┤')) - .separator(LinePosition::Bottom, LineSeparator::new('─', '┴', '└', '┘')) - .padding(1, 1) - .build(), - ); - - table.set_titles(Row::new(vec![ - Cell::new("#"), - Cell::new("Package-Name"), - Cell::new("Package-Path"), - ])); - - for (index, entry) in r.entries.iter().enumerate() { - table.add_row(Row::new(vec![ - Cell::new(&(index + 1).to_string()), - Cell::new(&entry.name), - Cell::new(&entry.path.to_string_lossy()), - ])); - } - - r_println!("{table}"); -} diff --git a/mingling_ci/src/examples.rs b/mingling_ci/src/examples.rs deleted file mode 100644 index 92d3475..0000000 --- a/mingling_ci/src/examples.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Example binary testing: build each example and run its `test.toml` cases. - -use std::process::Output; - -/// A single `[[runs]]` entry of an example's `test.toml`. -pub(crate) struct TestCase { - input: Vec, - expect: Expect, -} - -struct Expect { - exit_code: i32, - result: String, -} - -/// One example and its test cases. -pub(crate) struct ExampleCase { - name: String, - cases: Vec, -} - -/// Outcome of checking one example. -pub(crate) struct ExampleOutcome { - pub name: String, - pub location: String, - pub ok: bool, - pub output: String, -} - -/// Loads `examples//test.toml` for every example that has one, in -/// alphabetical order of the example directory name. -pub(crate) fn load_test_configs() -> Vec { - let mut configs = Vec::new(); - if let Ok(entries) = std::fs::read_dir("examples") { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let test_toml = path.join("test.toml"); - if !test_toml.is_file() { - continue; - } - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default() - .to_string(); - let Ok(content) = std::fs::read_to_string(&test_toml) else { - continue; - }; - let Ok(table) = content.parse::() else { - continue; - }; - let Some(cases) = parse_cases(&table) else { - continue; - }; - configs.push(ExampleCase { name, cases }); - } - } - configs.sort_by(|a, b| a.name.cmp(&b.name)); - configs -} - -fn parse_cases(table: &toml::Value) -> Option> { - let runs = table.get("runs")?.as_array()?; - let mut cases = Vec::new(); - for run in runs { - let input: Vec = run - .get("input")? - .as_array()? - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); - let expect = run.get("expect")?; - let exit_code = expect - .get("exit-code")? - .as_integer() - .and_then(|e| i32::try_from(e).ok()) - .unwrap_or(-1); - let result = expect - .get("result") - .and_then(|r| r.as_str()) - .unwrap_or_default() - .to_string(); - cases.push(TestCase { - input, - expect: Expect { exit_code, result }, - }); - } - Some(cases) -} - -/// Builds the example, then runs all of its test cases. -pub(crate) fn check_example(example: ExampleCase) -> ExampleOutcome { - let location = format!("./examples/{}", example.name); - - // Phase 1: build. - let manifest = format!("examples/{}/Cargo.toml", example.name); - let build = std::process::Command::new("cargo") - .args(["build", "--manifest-path", &manifest]) - .output(); - match build { - Ok(output) if !output.status.success() => ExampleOutcome { - name: example.name, - location, - ok: false, - output: build_error(&output), - }, - Err(e) => ExampleOutcome { - name: example.name, - location, - ok: false, - output: format!("failed to run cargo: {e}"), - }, - Ok(_) => { - // Phase 2: run the test cases against the built binary. - let mut failures = Vec::new(); - for case in &example.cases { - if let Err(detail) = run_case(&example.name, case) { - failures.push(detail); - } - } - ExampleOutcome { - name: example.name, - location, - ok: failures.is_empty(), - output: failures.join("\n\n"), - } - } - } -} - -/// Runs a single test case against the built binary. -fn run_case(name: &str, case: &TestCase) -> Result<(), String> { - let exe = if cfg!(target_os = "windows") { - ".exe" - } else { - "" - }; - let binary = format!(".temp/target/debug/{name}{exe}"); - - let output = std::process::Command::new(&binary) - .args(&case.input) - .output(); - let Ok(output) = output else { - return Err(format!("failed to run {binary}")); - }; - - let actual_exit_code = output.status.code().unwrap_or(-1); - let actual_stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let actual_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - - let exit_ok = actual_exit_code == case.expect.exit_code; - let result_ok = - actual_stdout == case.expect.result || actual_stdout.contains(&case.expect.result); - - if exit_ok && result_ok { - return Ok(()); - } - - let mut details = vec![format!("input: {}", case.input.join(" "))]; - if !exit_ok { - details.push(format!( - "expected exit code {}, actual {actual_exit_code}", - case.expect.exit_code - )); - } - if !result_ok { - details.push(format!("expected output {:?}", case.expect.result)); - details.push(format!("actual stdout {actual_stdout:?}")); - if !actual_stderr.is_empty() { - details.push(format!("actual stderr {actual_stderr:?}")); - } - } - Err(details.join("\n")) -} - -/// Tail of a failed build's combined output. -fn build_error(output: &Output) -> String { - let mut log = String::from_utf8_lossy(&output.stdout).into_owned(); - log.push_str(&String::from_utf8_lossy(&output.stderr)); - let lines: Vec<&str> = log.lines().collect(); - let tail = &lines[lines.len().saturating_sub(20)..]; - format!("build failed\n{}", tail.join("\n")) -} diff --git a/mingling_ci/src/git.rs b/mingling_ci/src/git.rs deleted file mode 100644 index a6fab2c..0000000 --- a/mingling_ci/src/git.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Thin wrappers around the `git` CLI used by the CI phase lock/unlock pair. - -use std::ffi::OsStr; -use std::process::Command; - -/// Marker file created by `git-lock` in the CI temporary commit; its content -/// is `true` when the tree was dirty (a base TEMP commit exists below) or -/// `false` when it was clean. `git-unlock` reads it to pick the restore path. -pub(crate) const LOCK_FILE: &str = "MINGLING-CI-CHECKING"; - -/// First temporary commit: packs the dirty workspace changes so they can be -/// restored later. Only created when the tree is dirty. -pub(crate) const TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] TEMP [DO NOT PUSH]"; - -/// Second temporary commit: carries the marker file, and its message is what -/// `git-unlock` matches to confirm the CI phase. -pub(crate) const CI_TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] CI TEMP [DO NOT PUSH]"; - -/// Case-sensitive substring that identifies a CI temporary commit in the HEAD -/// commit message. -pub(crate) const TEMP_COMMIT_MARK: &str = "CI TEMP"; - -/// Runs `git `, returning stdout on success. -/// -/// # Errors -/// -/// Returns the git error message (stderr) when the command exits non-zero, or -/// when git itself cannot be spawned. -pub(crate) fn run_git(args: I) -> Result -where - I: IntoIterator, - S: AsRef, -{ - let output = Command::new("git") - .args(args) - .output() - .map_err(|e| format!("failed to run git: {e}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } -} - -/// Returns `true` when the working tree has no tracked changes relative to -/// HEAD. Git failures count as "not clean" so the caller falls back to the -/// marker-file path. -/// -/// Uses the porcelain `git diff --quiet HEAD` rather than the plumbing -/// `git diff-index --quiet HEAD`: after a full compile the source files' -/// mtimes can be newer than the index stat records even though their content -/// is unchanged, and `diff-index` reports that stale stat as a change. The -/// porcelain diff refreshes the index first (via `diff.autoRefreshIndex`), -/// so it only reports real content differences. -pub(crate) fn worktree_clean() -> bool { - Command::new("git") - .args(["diff", "--quiet", "HEAD", "--"]) - .status() - .is_ok_and(|status| status.success()) -} - -/// The subject line of the HEAD commit. -/// -/// # Errors -/// -/// Returns the git error message when the log command fails. -pub(crate) fn head_message() -> Result { - run_git(["log", "-1", "--pretty=%s"]).map(|subject| subject.trim().to_string()) -} diff --git a/mingling_ci/src/lib.rs b/mingling_ci/src/lib.rs deleted file mode 100644 index 32a0cbd..0000000 --- a/mingling_ci/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -#![deny(clippy::pedantic)] -#![deny(clippy::nursery)] -#![allow(clippy::redundant_pub_crate)] -#![allow(clippy::missing_const_for_fn)] - -use mingling::macros::{gen_program, help}; - -pub(crate) mod cmd; -pub(crate) mod git; -pub(crate) mod task; - -/// Mingling CI's Resources -pub mod res; - -/// Log exporter for CI reports -pub mod reporter; - -pub(crate) mod examples; -pub(crate) mod markdown; -pub(crate) mod progress; -pub(crate) mod tools; - -#[help] -pub fn render_fallback(_: EntryFallback) -> String { - include_str!("../help.txt").to_string() -} - -gen_program!(); diff --git a/mingling_ci/src/markdown.rs b/mingling_ci/src/markdown.rs deleted file mode 100644 index 75f2cbe..0000000 --- a/mingling_ci/src/markdown.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod compare; -pub(crate) mod project; -pub(crate) mod test; diff --git a/mingling_ci/src/markdown/compare.rs b/mingling_ci/src/markdown/compare.rs deleted file mode 100644 index 1bf3c57..0000000 --- a/mingling_ci/src/markdown/compare.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Structural comparison of markdown docs (reference vs translation). -//! -//! For each file pair the comparison uses a *structural signature*: one token -//! per line, classifying headings (both Markdown `#` and HTML ``), fenced -//! code blocks (including their language tag), `@@@` hidden-compilation lines, -//! blank lines, blockquotes, lists and plain text. Translated text is allowed -//! to differ; the structure is not. - -use std::path::{Path, PathBuf}; - -/// Collects all `.md` files under `dir`, returned relative to it. -pub(crate) fn collect_md_files(dir: &Path) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - let Ok(entries) = std::fs::read_dir(¤t) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.extension().is_some_and(|e| e == "md") { - out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf()); - } - } - } - out.sort(); - out -} - -/// Compares the structural signatures of two markdown files. -/// -/// Returns the human-readable diff lines (up to a small window) on the first -/// structural difference. -pub(crate) fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec> { - let ref_content = std::fs::read_to_string(ref_path).unwrap_or_default(); - let lang_content = std::fs::read_to_string(lang_path).unwrap_or_default(); - - let ref_sig = signature_of(&ref_content); - let lang_sig = signature_of(&lang_content); - - if ref_sig == lang_sig { - return Ok(()); - } - - let ref_lines: Vec<&str> = ref_content.lines().collect(); - let lang_lines: Vec<&str> = lang_content.lines().collect(); - - let mut diffs = Vec::new(); - let mut window = 0; - let max = ref_sig.len().max(lang_sig.len()); - for i in 0..max { - let ref_tok = ref_sig.get(i); - let lang_tok = lang_sig.get(i); - if ref_tok == lang_tok { - continue; - } - if window >= 5 { - diffs.push(format!("... ({}-line window truncated)", max - i)); - break; - } - window += 1; - let ref_line = ref_lines.get(i).copied().unwrap_or(""); - let lang_line = lang_lines.get(i).copied().unwrap_or(""); - diffs.push(format!("line {}", i + 1)); - diffs.push(format!( - "expect `{}` {}", - token_label(ref_tok.map_or("", String::as_str)), - display_line(ref_line) - )); - diffs.push(format!( - "found `{}` {}", - token_label(lang_tok.map_or("", String::as_str)), - display_line(lang_line) - )); - if ref_sig.len() != lang_sig.len() && window >= 5 { - diffs.push(format!( - "note: reference has {} lines, translation has {} lines", - ref_sig.len(), - lang_sig.len() - )); - break; - } - } - if diffs.is_empty() { - diffs.push("signatures differ in length (see line count note)".to_string()); - } - Err(diffs) -} - -/// Builds the structural signature of a markdown file. -fn signature_of(content: &str) -> Vec { - let mut sig = Vec::new(); - let mut in_fence = false; - let mut fence_lang = String::new(); - - for raw_line in content.lines() { - let line = raw_line.trim(); - - if in_fence { - if line.starts_with("```") { - in_fence = false; - sig.push(format!("F:{fence_lang}")); - } else if line.starts_with("@@@") { - sig.push("A".to_string()); - } else if line.is_empty() { - sig.push("B".to_string()); - } else { - sig.push("P".to_string()); - } - continue; - } - - if line.starts_with("```") { - in_fence = true; - fence_lang = line.trim_start_matches("```").trim().to_string(); - sig.push(format!("F:{fence_lang}")); - } else if line.starts_with('#') { - let level = line.chars().take_while(|c| *c == '#').count(); - sig.push(format!("H{level}")); - } else if line.starts_with("` / ``) - let level = line - .trim_start_matches(['<', '/']) - .chars() - .next() - .and_then(|c| c.to_digit(10)) - .unwrap_or(1); - sig.push(format!("H{level}")); - } else if line.starts_with("@@@") { - sig.push("A".to_string()); - } else if line.is_empty() { - sig.push("B".to_string()); - } else if line.starts_with('>') { - sig.push("Q".to_string()); - } else if is_list_line(line) { - sig.push("L".to_string()); - } else { - sig.push("P".to_string()); - } - } - sig -} - -/// Human-readable label for a structural token. -fn token_label(token: &str) -> String { - match token { - "B" => "blank".to_string(), - "A" => "@@@".to_string(), - "Q" => "quote".to_string(), - "L" => "list".to_string(), - "P" => "text".to_string(), - t if t.starts_with('H') => format!("heading-{}", &t[1..]), - t if t.starts_with("F:") => { - let lang = &t[2..]; - if lang.is_empty() { - "fence".to_string() - } else { - format!("fence:{lang}") - } - } - _ => token.to_string(), - } -} - -/// Renders a source line for display: blank lines become ``. -fn display_line(line: &str) -> String { - if line.trim().is_empty() { - "".to_string() - } else { - truncate(line) - } -} - -fn truncate(line: &str) -> String { - const MAX: usize = 60; - if line.chars().count() <= MAX { - line.to_string() - } else { - let cut: String = line.chars().take(MAX).collect(); - format!("{cut}...") - } -} - -fn is_list_line(line: &str) -> bool { - let trimmed = line.trim_start(); - trimmed.starts_with("- ") - || trimmed.starts_with("* ") - || trimmed.starts_with("+ ") - || is_numbered_list(trimmed) -} - -/// A numbered list item: `1. text`, `1) text`, `10. text`, ... -fn is_numbered_list(line: &str) -> bool { - let digit_count = line.chars().take_while(char::is_ascii_digit).count(); - if digit_count == 0 { - return false; - } - let rest = &line[digit_count..]; - (rest.starts_with(". ") || rest.starts_with(") ")) - && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t') -} diff --git a/mingling_ci/src/markdown/project.rs b/mingling_ci/src/markdown/project.rs deleted file mode 100644 index d781b7e..0000000 --- a/mingling_ci/src/markdown/project.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! Model of a testable rust code block extracted from markdown: its dependency -//! configuration (features + deps) and the code itself. - -use std::fmt::Write as _; -use std::path::Path; - -/// A single testable `rust` code block, modeled as a test project. -pub(crate) struct MarkdownTestProject { - pub features: Vec, - pub deps: Vec<(String, String)>, - pub code: String, - pub is_build_time: bool, - pub has_main: bool, - pub has_gen_program: bool, - pub source_file: String, - pub line: usize, -} - -impl MarkdownTestProject { - /// FNV-1a 64-bit hash over the dependency configuration (features + deps). - /// - /// Blocks with the same hash share one temporary crate and avoid redundant - /// recompilation. The input is sorted so the hash is stable. - #[must_use] - pub fn compute_hash(&self) -> String { - let mut features: Vec<&str> = self.features.iter().map(String::as_str).collect(); - features.sort_unstable(); - let mut dep_names: Vec<&str> = self.deps.iter().map(|(n, _)| n.as_str()).collect(); - dep_names.sort_unstable(); - let mut dep_versions: Vec<&str> = self.deps.iter().map(|(_, v)| v.as_str()).collect(); - dep_versions.sort_unstable(); - let mut deps: Vec = self.deps.iter().map(|(n, v)| format!("{n}={v}")).collect(); - deps.sort(); - - let canonical = format!( - "{}\n{}\n{}\n{}", - features.join(","), - dep_names.join(","), - dep_versions.join(","), - deps.join(",") - ); - - // FNV-1a 64-bit — stable across runs (no random seed). - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for &byte in canonical.as_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - format!("{hash:016x}") - } -} - -/// Parses all fenced `rust` blocks from markdown content. -/// -/// Blocks marked `// NOT VERIFIED` are skipped. -pub(crate) fn parse_markdown(content: &str, source_file: &str) -> Vec { - let mut projects = Vec::new(); - let lines: Vec<&str> = content.lines().collect(); - let mut i = 0; - while i < lines.len() { - if lines[i].trim() == "```rust" { - if let Some(proj) = parse_block(&lines, i, source_file) { - projects.push(proj); - } - while i < lines.len() && lines[i].trim() != "```" { - i += 1; - } - } - i += 1; - } - projects -} - -/// Parses a single code block starting at a `rust` fence line. -fn parse_block(lines: &[&str], start: usize, source_file: &str) -> Option { - let mut code_lines: Vec = Vec::new(); - let mut features: Vec = Vec::new(); - let mut not_verified = false; - let mut deps: Vec<(String, String)> = Vec::new(); - let mut has_main = false; - let mut has_gen_program = false; - let mut is_build_time = false; - - let mut idx = start + 1; - let mut in_header = true; - - while idx < lines.len() { - let raw_line = lines[idx]; - let trimmed = raw_line.trim(); - - if trimmed == "```" { - break; - } - - // `@@@` lines: hidden in the rendered docs (filtered by a docsify - // plugin) but must still compile. - if let Some(stripped) = trimmed.strip_prefix("@@@") { - in_header = false; - let code = stripped.trim_start(); - if code.contains("fn main") { - has_main = true; - } - if code.contains("gen_program!") { - has_gen_program = true; - } - code_lines.push(code.to_string()); - idx += 1; - continue; - } - - if in_header && trimmed == "// NOT VERIFIED" { - not_verified = true; - idx += 1; - continue; - } - if in_header && trimmed == "// BUILD TIME" { - is_build_time = true; - idx += 1; - continue; - } - if in_header && trimmed.starts_with("// ") { - if let Some(feat_str) = trimmed.strip_prefix("// Features:") { - let feat_str = feat_str.trim(); - if feat_str.starts_with('[') && feat_str.ends_with(']') { - let inner = &feat_str[1..feat_str.len() - 1]; - if !inner.is_empty() { - features = inner - .split(',') - .map(|s| s.trim().trim_matches('"').to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - } - idx += 1; - continue; - } - if trimmed == "// Dependencies:" { - idx += 1; - while idx < lines.len() { - let next = lines[idx].trim(); - if next == "```" { - break; - } - if let Some(dep_line) = next.strip_prefix("// ") { - if let Some((name, ver)) = dep_line.split_once(" = ") { - deps.push(( - name.trim().to_string(), - ver.trim().trim_matches('"').to_string(), - )); - } - idx += 1; - } else { - break; - } - } - continue; - } - } - - in_header = false; - if raw_line.contains("fn main") { - has_main = true; - } - if raw_line.contains("gen_program!") { - has_gen_program = true; - } - code_lines.push(raw_line.to_string()); - idx += 1; - } - - if code_lines.is_empty() || not_verified { - return None; - } - - Some(MarkdownTestProject { - features, - deps, - code: code_lines.join("\n"), - is_build_time, - has_main, - has_gen_program, - source_file: source_file.to_string(), - line: start + 1, - }) -} - -/// Builds the extra `[dependencies]` entries declared by a block's -/// `// Dependencies:` header comments. -/// -/// Markdown blocks declare companion crates like this: -/// -/// ```text -/// // Dependencies: -/// // serde = "1" -/// // clap = "4" -/// // tokio = { version = "1", features = ["full"] } -/// ``` -/// -/// Each `name = value` pair becomes one dependency of the generated test -/// crate (in addition to `mingling` itself), so doc blocks can freely use -/// external crates without repeating the whole manifest. -/// -/// # Special case: serde / clap -/// -/// Doc blocks pervasively derive serialization and argument parsing: -/// structural-renderer examples use `#[derive(Serialize)]`, the clap examples -/// use `#[derive(Parser)]` — and those derives live behind the `derive` -/// feature of `serde` / `clap`. Requiring every block to spell out -/// `// serde = { version = "1", features = ["derive"] }` would be -/// boilerplate repeated dozens of times, so the two crates automatically get -/// `features = ["derive"]` appended. -/// -/// Version values starting with `{` are inline tables (e.g. `tokio` with a -/// `features` list above) and are passed through verbatim — they already -/// carry their own features and must not be rewritten. -fn build_extra_deps(proj: &MarkdownTestProject) -> String { - let mut extra_deps = String::new(); - for (name, version) in &proj.deps { - if version.starts_with('{') { - // Inline table (path/features/…): the block already expressed its - // full dependency, so emit it unchanged. - let _ = writeln!(extra_deps, "{name} = {version}"); - } else if name == "serde" || name == "clap" { - // serde/clap derive: `#[derive(Serialize, Deserialize)]` and - // `#[derive(Parser)]` are used everywhere in the docs; auto-enable - // the `derive` feature to keep blocks terse. - let _ = writeln!( - extra_deps, - "{name} = {{ version = \"{version}\", features = [\"derive\"] }}" - ); - } else { - // Plain `name = "version"`. - let _ = writeln!(extra_deps, "{name} = \"{version}\""); - } - } - extra_deps -} - -/// Generates the `Cargo.toml` for a project. -/// -/// `manifest_path` is used to compute the relative path to the `mingling` crate. -pub(crate) fn generate_cargo_toml(proj: &MarkdownTestProject, manifest_path: &Path) -> String { - let features_str = if proj.features.is_empty() { - String::new() - } else { - let feats: Vec = proj.features.iter().map(|f| format!("\"{f}\"")).collect(); - format!("features = [{}]", feats.join(", ")) - }; - - let extra_deps = build_extra_deps(proj); - - let mingling_path = find_mingling_relative_path(manifest_path); - let deps_section = if proj.features.is_empty() { - format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}") - } else { - format!( - "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}" - ) - }; - - // Build-time projects mirror the features into [build-dependencies] so - // build.rs sees the same feature set. - let build_deps_section = if proj.is_build_time { - let feats: Vec = proj.features.iter().map(|f| format!("\"{f}\"")).collect(); - let build_feats = if feats.is_empty() { - String::new() - } else { - format!("features = [{}]", feats.join(", ")) - }; - format!( - "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n" - ) - } else { - String::new() - }; - - format!( - r#"[package] - name = "test-doc" - version = "0.0.0" - edition = "2024" - -{deps_section}{build_deps_section} -[workspace] -"# - ) -} - -/// Computes the relative path from a manifest's parent directory to `mingling`. -/// -/// The process current directory is expected to be the project root. -fn find_mingling_relative_path(manifest_path: &Path) -> String { - let manifest_dir = manifest_path - .parent() - .expect("manifest path has no parent directory"); - let cwd = std::env::current_dir().expect("failed to get current directory"); - - let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir); - let depth = relative_to_root.components().count(); - - let mut result = String::new(); - for _ in 0..depth { - result.push_str("../"); - } - result.push_str("mingling"); - result -} - -/// Generates `main.rs` for a project. -/// -/// Automatically prepends `use mingling::prelude::*;` and appends `fn main() {}` -/// and `gen_program!()` when the block does not provide them. -pub(crate) fn generate_main_rs(proj: &MarkdownTestProject) -> String { - let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); - - if !proj.code.contains("use mingling::prelude::*;") { - output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n"); - } - output.push_str(&proj.code); - output.push('\n'); - - if !proj.has_main { - output.push_str("\nfn main() {}\n"); - } - if !proj.has_gen_program { - output.push_str("\nmingling::macros::gen_program!();\n"); - } - output -} - -/// Generates `build.rs` for a build-time project: the code wrapped in -/// `fn main() { }` unless the block already provides one. -pub(crate) fn generate_build_rs(proj: &MarkdownTestProject) -> String { - let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); - if proj.has_main { - output.push_str(&proj.code); - } else { - output.push_str("fn main() {\n"); - for line in proj.code.lines() { - output.push_str(" "); - output.push_str(line); - output.push('\n'); - } - output.push_str("}\n"); - } - output -} diff --git a/mingling_ci/src/markdown/test.rs b/mingling_ci/src/markdown/test.rs deleted file mode 100644 index 8ecf18d..0000000 --- a/mingling_ci/src/markdown/test.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Parallel execution of markdown test projects. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use colored::Colorize; - -use crate::progress::task_progress_bar; - -use super::project::{ - MarkdownTestProject, generate_build_rs, generate_cargo_toml, generate_main_rs, -}; - -/// Temporary root for the generated test crates. -const TEMP_BASE: &str = ".temp/doc-test"; - -/// Outcome of testing one code block. -pub(crate) struct MarkdownBlockOutcome { - pub source_file: String, - pub line: usize, - pub ok: bool, - /// Failure detail; empty when `ok`. - pub output: String, -} - -/// Runs the given projects in parallel. -/// -/// Projects sharing a dependency hash share one temporary crate (written -/// serially within the group); groups run in parallel. Progress is shown on -/// stderr; failures print there too. Returns one outcome per block. -pub(crate) async fn try_test_markdown_project( - projs: Vec, -) -> Vec { - // Group by dependency hash for crate sharing. - let mut groups: BTreeMap> = BTreeMap::new(); - for proj in projs { - groups.entry(proj.compute_hash()).or_default().push(proj); - } - - let total: usize = groups.values().map(Vec::len).sum(); - let pb = task_progress_bar(total, "Testing"); - pb.set_message("blocks"); - - // One blocking task per group; blocks within a group are serial because - // they share the same crate directory. - let mut handles = Vec::new(); - for (hash, blocks) in groups { - let pb = pb.clone(); - handles.push(tokio::task::spawn_blocking(move || { - let crate_dir = PathBuf::from(TEMP_BASE).join(&hash); - let src_dir = crate_dir.join("src"); - let manifest_path = crate_dir.join("Cargo.toml"); - let cargo_toml = generate_cargo_toml(&blocks[0], &manifest_path); - - let mut group_outcomes = Vec::new(); - for proj in &blocks { - let label = format!("{}:{}", proj.source_file, proj.line); - pb.set_message(label.clone()); - - let main_rs = if proj.is_build_time { - generate_build_rs(proj) - } else { - generate_main_rs(proj) - }; - let (ok, err) = build_block( - &src_dir, - &manifest_path, - &cargo_toml, - &main_rs, - proj.is_build_time, - ); - pb.inc(1); - - if !ok { - // Plain stderr: `pb.println` is swallowed on non-TTY (CI). - eprintln!(" {} {label}", "failed".bold().bright_red()); - eprintln!(" {label} FAILED:\n{err}"); - } - group_outcomes.push(MarkdownBlockOutcome { - source_file: proj.source_file.clone(), - line: proj.line, - ok, - output: err, - }); - } - group_outcomes - })); - } - - let mut all_outcomes = Vec::new(); - for handle in handles { - if let Ok(group_outcomes) = handle.await { - all_outcomes.extend(group_outcomes); - } - } - - pb.finish_and_clear(); - all_outcomes -} - -/// Writes the temporary crate files and runs `cargo check`. -/// -/// When `is_build_time` is true, the content goes to `build.rs` with a stub -/// `main.rs`; otherwise it goes to `src/main.rs`. -fn build_block( - src_dir: &Path, - manifest_path: &Path, - cargo_toml: &str, - content: &str, - is_build_time: bool, -) -> (bool, String) { - if let Err(e) = std::fs::create_dir_all(src_dir) { - return (false, format!("mkdir: {e}")); - } - if let Err(e) = std::fs::write(manifest_path, cargo_toml) { - return (false, format!("write Cargo.toml: {e}")); - } - - if is_build_time { - let crate_dir = manifest_path - .parent() - .expect("manifest path has a parent directory"); - if let Err(e) = std::fs::write(crate_dir.join("build.rs"), content) { - return (false, format!("write build.rs: {e}")); - } - if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") { - return (false, format!("write main.rs: {e}")); - } - } else if let Err(e) = std::fs::write(src_dir.join("main.rs"), content) { - return (false, format!("write main.rs: {e}")); - } - - let output = std::process::Command::new("cargo") - .args(["check", "--color=always", "--manifest-path"]) - .arg(manifest_path) - .output(); - match output { - Ok(output) if output.status.success() => (true, String::new()), - Ok(output) => { - let mut log = String::from_utf8_lossy(&output.stdout).into_owned(); - log.push_str(&String::from_utf8_lossy(&output.stderr)); - let lines: Vec<&str> = log.lines().collect(); - let tail = &lines[lines.len().saturating_sub(20)..]; - let exit = output - .status - .code() - .map_or_else(|| "?".to_string(), |c| c.to_string()); - (false, format!("exit code {exit}\n{}", tail.join("\n"))) - } - Err(e) => (false, format!("failed to run cargo: {e}")), - } -} diff --git a/mingling_ci/src/progress.rs b/mingling_ci/src/progress.rs deleted file mode 100644 index bd62ae1..0000000 --- a/mingling_ci/src/progress.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Shared task progress bar. - -use colored::Colorize; -use indicatif::{ProgressBar, ProgressStyle}; - -/// Creates a task progress bar with the CI's standard style. -/// -/// `prefix` is the phase label shown before the bar, right-aligned to 12 -/// columns (e.g. `Building`, `Clippy`, `Testing`). The caller sets the -/// initial message and drives the position. -pub(crate) fn task_progress_bar(len: usize, prefix: &str) -> ProgressBar { - let padding = " ".repeat(12usize.saturating_sub(prefix.len())); - let styled_prefix = format!("{padding}{}", prefix.bold().bright_cyan()); - let pb = ProgressBar::new(len as u64); - pb.set_style( - ProgressStyle::default_bar() - .template(&format!( - "{styled_prefix} [{{bar:28}}] {{pos}}/{{len}}: {{msg}}" - )) - .unwrap() - .progress_chars("=> "), - ); - pb -} diff --git a/mingling_ci/src/reporter.rs b/mingling_ci/src/reporter.rs deleted file mode 100644 index 1a1ac08..0000000 --- a/mingling_ci/src/reporter.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Minimal log exporter for CI reports. -//! -//! Writes per-package results into `collect/{task}/{platform}/{package}.{ok|err}` -//! so that the [`crate::cmd::collect_results`] command can assemble the final -//! report. The task name is set once per CI phase via [`set_task`]. - -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use std::sync::{LazyLock, Mutex}; - -/// Root of the collected CI logs (relative to the repo root). -pub const COLLECT_DIR: &str = "./.temp/reports/collect"; - -/// Generated report output (relative to the repo root). -pub const REPORT_PATH: &str = "./.temp/reports/result.md"; - -/// The platform a package check ran on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] -pub enum ReportPlatform { - Windows, - Linux, - MacOS, -} - -impl ReportPlatform { - /// Directory name used under the task folder. - const fn dir_name(self) -> &'static str { - match self { - Self::Windows => "Windows", - Self::Linux => "Linux", - Self::MacOS => "MacOS", - } - } -} - -/// The outcome of a package check. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReportResult { - /// Check passed. - Ok, - /// Check failed, with the captured output. - Error(String), -} - -/// Current task name (e.g. `Build-All`); set via [`set_task`]. -static CURRENT_TASK: Mutex> = Mutex::new(None); - -/// Pending success entries: `(item, location)`. -type PendingOk = (String, String); - -/// Successful items pending a [`flush`], grouped by platform. -static OK_BUFFER: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Sets the task that subsequent [`export`] calls write under. -/// -/// # Panics -/// -/// Panics if the internal mutex is poisoned. -pub fn set_task(task: &str) { - *CURRENT_TASK.lock().unwrap() = Some(task.to_string()); -} - -/// Exports one item result. -/// -/// `item` and `location` are free-form strings chosen by the generator. -/// Successes are buffered and written to the `ok` file by [`flush`]; failures -/// write `{task}.{platform}.{item}.err` immediately (first line is the -/// location). Errors are reported to stderr and otherwise ignored. -/// -/// # Panics -/// -/// Panics if the internal task mutex is poisoned. -pub fn export(item: &str, location: &str, result: ReportResult) { - export_on(item, location, current_platform(), result); -} - -/// The `ReportPlatform` for the currently compiling target. -fn current_platform() -> ReportPlatform { - if cfg!(target_os = "windows") { - ReportPlatform::Windows - } else if cfg!(target_os = "macos") { - ReportPlatform::MacOS - } else { - ReportPlatform::Linux - } -} - -/// Exports one item result for a specific platform. -/// -/// `item` and `location` are free-form strings chosen by the generator. -/// Successes are buffered and written to the `ok` file by [`flush`]; failures -/// write `{task}.{platform}.{item}.err` immediately (first line is the -/// location). Errors are reported to stderr and otherwise ignored. -/// -/// # Panics -/// -/// Panics if the internal task mutex is poisoned. -pub fn export_on(item: &str, location: &str, platform: ReportPlatform, result: ReportResult) { - match result { - ReportResult::Ok => OK_BUFFER - .lock() - .unwrap() - .entry(platform) - .or_default() - .push((item.to_string(), location.to_string())), - ReportResult::Error(output) => write_err(item, location, platform, &output), - } -} - -/// Writes buffered successes to `collect/{task}.{platform}.ok`, one `item` (or -/// `item = location`) per line. -/// -/// # Panics -/// -/// Panics if the internal task mutex is poisoned. -pub fn flush() { - let Some(task) = CURRENT_TASK.lock().unwrap().clone() else { - eprintln!("reporter: no current task; call reporter::set_task first"); - return; - }; - - let buffered = std::mem::take(&mut *OK_BUFFER.lock().unwrap()); - if buffered.is_empty() { - return; - } - - if let Err(e) = fs::create_dir_all(COLLECT_DIR) { - eprintln!("reporter: failed to create {COLLECT_DIR}: {e}"); - return; - } - - for (platform, items) in buffered { - let lines: Vec = items - .iter() - .map(|(item, location)| { - if location.is_empty() { - item.clone() - } else { - format!("{item} = {location}") - } - }) - .collect(); - let content = if lines.is_empty() { - String::new() - } else { - lines.join("\n") + "\n" - }; - let platform_name = platform.dir_name(); - let path = Path::new(COLLECT_DIR).join(format!("{task}.{platform_name}.ok")); - if let Err(e) = fs::write(&path, content) { - eprintln!("reporter: failed to write {}: {e}", path.display()); - } - } -} - -/// Writes a failure entry to `collect/{task}.{platform}.{item}.err`, with the -/// location as the first line (empty when unknown). -fn write_err(item: &str, location: &str, platform: ReportPlatform, output: &str) { - let Some(task) = CURRENT_TASK.lock().unwrap().clone() else { - eprintln!("reporter: no current task; call reporter::set_task first"); - return; - }; - - if let Err(e) = fs::create_dir_all(COLLECT_DIR) { - eprintln!("reporter: failed to create {COLLECT_DIR}: {e}"); - return; - } - - let platform_name = platform.dir_name(); - let path = Path::new(COLLECT_DIR).join(format!("{task}.{platform_name}.{item}.err")); - if let Err(e) = fs::write(&path, format!("{location}\n{output}")) { - eprintln!("reporter: failed to write {}: {e}", path.display()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn export_writes_ok_and_err_files() { - set_task("reporter-test"); - let platform_name = current_platform().dir_name(); - let ok_path = Path::new(COLLECT_DIR).join(format!("reporter-test.{platform_name}.ok")); - let err_path = - Path::new(COLLECT_DIR).join(format!("reporter-test.{platform_name}.pkg-b.err")); - fs::remove_file(&ok_path).ok(); - fs::remove_file(&err_path).ok(); - - export("pkg-a", "./pkg-a", ReportResult::Ok); - export("pkg-b", "./pkg-b", ReportResult::Error("boom".to_string())); - export("pkg-c", "", ReportResult::Ok); // no location - flush(); - - assert!(ok_path.is_file()); - assert_eq!( - fs::read_to_string(&ok_path).unwrap(), - "pkg-a = ./pkg-a\npkg-c\n" - ); - assert!(err_path.is_file()); - assert_eq!(fs::read_to_string(&err_path).unwrap(), "./pkg-b\nboom"); - - fs::remove_file(ok_path).ok(); - fs::remove_file(err_path).ok(); - } -} diff --git a/mingling_ci/src/res.rs b/mingling_ci/src/res.rs deleted file mode 100644 index 54ed503..0000000 --- a/mingling_ci/src/res.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod collect_logs; -pub use collect_logs::*; - -mod crate_config; -pub use crate_config::*; - -mod features; -pub use features::*; - -mod manifests; -pub use manifests::*; - -mod print; -pub use print::*; diff --git a/mingling_ci/src/res/collect_logs.rs b/mingling_ci/src/res/collect_logs.rs deleted file mode 100644 index 6017168..0000000 --- a/mingling_ci/src/res/collect_logs.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! IO side of the report command: reads the collect directory once and keeps -//! the parsed data in a resource, so chains only do computation. - -use std::collections::BTreeMap; - -use mingling::{Program, macros::program_setup}; - -use crate::ThisProgram; -use crate::reporter::COLLECT_DIR; - -/// Git commit date and short hash for the report. -#[derive(Default, Clone, Debug)] -pub struct GitInfo { - pub date: String, - pub commit_hash: String, -} - -/// Parsed contents of the collect directory. -#[derive(Default, Clone)] -pub struct ResCollectLogs { - /// `(task, item) -> os -> ok` - pub statuses: BTreeMap<(String, String), BTreeMap>, - /// `(task, item) -> location` - pub locations: BTreeMap<(String, String), String>, - /// `(task, os, item) -> stripped error output (location line removed)` - pub err_outputs: BTreeMap<(String, String, String), String>, - pub git: GitInfo, -} - -impl ResCollectLogs { - /// Reads the flat `collect/` directory — aggregate `{task}.{os}.ok` files - /// (`item` or `item = location` per line) and per-item - /// `{task}.{os}.{item}.err` files (first line is the location) — plus the - /// git info. - #[must_use] - pub fn read() -> Self { - let mut logs = Self::default(); - - if let Ok(entries) = std::fs::read_dir(COLLECT_DIR) { - for entry in entries.flatten() { - let file_name = entry.file_name().to_string_lossy().into_owned(); - if let Some((task, os)) = parse_ok_name(&file_name) { - // Aggregate success file: `item` or `item = location` per line. - if let Ok(content) = std::fs::read_to_string(entry.path()) { - for line in content.lines().filter(|l| !l.is_empty()) { - let (item, location) = line - .split_once('=') - .map_or((line, ""), |(name, loc)| (name.trim(), loc.trim())); - logs.statuses - .entry((task.clone(), item.to_string())) - .or_default() - .insert(os.clone(), true); - logs.locations - .insert((task.clone(), item.to_string()), location.to_string()); - } - } - } else if let Some((task, os, item)) = parse_err_name(&file_name) { - let content = std::fs::read_to_string(entry.path()).unwrap_or_default(); - let mut lines = content.splitn(2, '\n'); - let location = lines.next().unwrap_or_default().to_string(); - let output = lines.next().unwrap_or_default().to_string(); - logs.statuses - .entry((task.clone(), item.clone())) - .or_default() - .insert(os.clone(), false); - logs.locations - .insert((task.clone(), item.clone()), location); - logs.err_outputs - .insert((task, os, item), strip_ansi(&output)); - } - } - } - - logs.git = git_info(); - logs - } -} - -/// Parses a `{task}.{os}.ok` file name. -fn parse_ok_name(file_name: &str) -> Option<(String, String)> { - let name = file_name.strip_suffix(".ok")?; - let mut parts = name.rsplitn(2, '.'); - let os = parts.next()?.to_string(); - let task = parts.next()?.to_string(); - Some((task, os)) -} - -/// Parses a `{task}.{os}.{package}.err` file name. -/// -/// Split from the right: package names cannot contain dots (cargo forbids -/// them), while task names may. -fn parse_err_name(file_name: &str) -> Option<(String, String, String)> { - let name = file_name.strip_suffix(".err")?; - let mut parts = name.rsplitn(3, '.'); - let package = parts.next()?.to_string(); - let os = parts.next()?.to_string(); - let task = parts.next()?.to_string(); - Some((task, os, package)) -} - -#[program_setup] -pub fn report_setup(p: &mut Program) { - p.with_resource(ResCollectLogs::read()); -} - -/// Strips ANSI escape sequences from `input`. -/// -/// Handles CSI (`ESC [ ...`), OSC (`ESC ] ...` terminated by BEL or `ESC \`) -/// and other single-character escapes, while preserving UTF-8 text. Literal -/// `^[` (caret-bracket, produced by some terminal captures) is normalized to -/// `ESC` first. -fn strip_ansi(input: &str) -> String { - // Normalize literal `^[` (0x5E 0x5B) to a real ESC byte. - let normalized = input.replace("^[", "\u{1b}"); - let mut out = String::with_capacity(normalized.len()); - let mut rest = normalized.as_str(); - while let Some(idx) = rest.find('\u{1b}') { - out.push_str(&rest[..idx]); - rest = &rest[idx..]; - rest = &rest[ansi_len(rest)..]; - } - out.push_str(rest); - out -} - -/// Byte length of the ANSI escape sequence starting at `s[0]` (`s[0]` is `ESC`). -fn ansi_len(s: &str) -> usize { - let b = s.as_bytes(); - match b.get(1) { - Some(b'[') => { - // CSI: `ESC [` params/intermediates (0x20-0x3F) then a final byte (0x40-0x7E). - let mut i = 2; - while i < b.len() { - let byte = b[i]; - i += 1; - if (0x40..=0x7E).contains(&byte) { - break; - } - if !(0x20..=0x3F).contains(&byte) { - break; - } - } - i - } - Some(b']') => { - // OSC: `ESC ]` ... terminated by BEL (0x07) or `ESC \`. - let mut i = 2; - while i < b.len() { - let byte = b[i]; - i += 1; - if byte == 0x07 { - break; - } - if byte == 0x1b { - if b.get(i) == Some(&b'\\') { - i += 1; - } - break; - } - } - i - } - Some(_) => 2.min(b.len()), - None => 1, - } -} - -/// Commit date (`YYYY-MM-DD`) and short commit hash; empty on failure. -fn git_info() -> GitInfo { - let run = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default() - }; - GitInfo { - date: run(&["log", "-1", "--format=%cs"]), - commit_hash: run(&["rev-parse", "--short", "HEAD"]), - } -} - -#[cfg(test)] -mod tests { - use super::strip_ansi; - - #[test] - fn strips_csi_and_osc_and_literal_caret() { - let input = - "\u{1b}[1m\u{1b}[92mok\u{1b}[0m \u{1b}]8;;https://x\u{1b}\\done\u{1b}]8;;\u{1b}\\\n"; - assert_eq!(strip_ansi(input), "ok done\n"); - - // Literal `^[` (caret-bracket) captured by some terminals. - assert_eq!(strip_ansi("^[[31mred^[[0m"), "red"); - } - - #[test] - fn preserves_utf8() { - assert_eq!(strip_ansi("你好\u{1b}[1m世界!\u{1b}[0m"), "你好世界!"); - } -} diff --git a/mingling_ci/src/res/crate_config.rs b/mingling_ci/src/res/crate_config.rs deleted file mode 100644 index b20e83d..0000000 --- a/mingling_ci/src/res/crate_config.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::collections::HashMap; -use std::path::Path; - -use mingling::{Program, macros::program_setup}; - -use crate::ThisProgram; -use crate::res::{Manifests, ResFeatureList}; - -/// Per-crate CI overrides from `mingling-ci.toml` (optional, crate root). -/// -/// Currently only `[test] command` is read; `clippy.command` / `build.command` -/// will follow the same shape. -#[derive(Default, Clone)] -pub struct ResCrateConfig { - /// Package name -> test command argv (with `<<>>` expanded). - test_commands: HashMap>, -} - -impl ResCrateConfig { - /// The configured `[test] command` for a package, if any. - #[must_use] - pub fn test_command(&self, package: &str) -> Option<&[String]> { - self.test_commands.get(package).map(Vec::as_slice) - } -} - -#[program_setup] -pub fn crate_config_setup(p: &mut Program) { - let features = p - .res::() - .map(|f| f.list.clone()) - .unwrap_or_default(); - let joined_features = features.join(","); - - let Some(manifests) = p.res::() else { - return; - }; - - let mut test_commands = HashMap::new(); - for (name, manifest_path) in &manifests.package_dirs { - let config_path = manifest_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("mingling-ci.toml"); - - let Ok(content) = std::fs::read_to_string(&config_path) else { - continue; - }; - - let Ok(table) = content.parse::() else { - continue; - }; - - let Some(command) = table - .get("test") - .and_then(|t| t.get("command")) - .and_then(|c| c.as_array()) - else { - continue; - }; - - let argv: Vec = command - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); - - if argv.is_empty() { - continue; - } - - let argv = argv - .into_iter() - .map(|arg| arg.replace("<<>>", &joined_features)) - .collect(); - test_commands.insert(name.clone(), argv); - } - - p.with_resource(ResCrateConfig { test_commands }); -} diff --git a/mingling_ci/src/res/features.rs b/mingling_ci/src/res/features.rs deleted file mode 100644 index 8009514..0000000 --- a/mingling_ci/src/res/features.rs +++ /dev/null @@ -1,47 +0,0 @@ -use mingling::{Program, macros::program_setup}; - -use crate::ThisProgram; - -/// Manifest that declares the documented feature list. -/// -/// Path is relative to the repo root (the CI's working directory). -const FEATURES_MANIFEST: &str = "./mingling/Cargo.toml"; - -/// The docs.rs feature list of `mingling`, the single source of truth for the -/// feature combinations used by CI checks. -#[derive(Default, Clone)] -pub struct ResFeatureList { - pub list: Vec, -} - -#[program_setup] -pub fn features_setup(p: &mut Program) { - p.with_resource(ResFeatureList { - list: docs_rs_features(), - }); -} - -/// Reads `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`. -#[must_use] -fn docs_rs_features() -> Vec { - let Ok(content) = std::fs::read_to_string(FEATURES_MANIFEST) else { - return Vec::new(); - }; - let Ok(toml_value) = content.parse::() else { - return Vec::new(); - }; - toml_value - .get("package") - .and_then(|p| p.get("metadata")) - .and_then(|m| m.get("docs")) - .and_then(|d| d.get("rs")) - .and_then(|rs| rs.get("features")) - .and_then(|f| f.as_array()) - .map(|features| { - features - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default() -} diff --git a/mingling_ci/src/res/manifests.rs b/mingling_ci/src/res/manifests.rs deleted file mode 100644 index 91836d6..0000000 --- a/mingling_ci/src/res/manifests.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use mingling::{Program, macros::program_setup}; - -use crate::ThisProgram; - -/// Directories whose manifests are excluded from CI checks. -/// -/// Path is relative to the crate source file (`mingling_ci/src/res/`). -const IGNORED_DIRS_FILE: &str = include_str!("../../../.config/ci-ignored-dirs.txt"); - -/// All `Cargo.toml` manifests the CI will check. -#[derive(Default, Clone)] -pub struct Manifests { - pub path: Vec, - /// Package name -> its manifest path. - pub package_dirs: HashMap, -} - -#[program_setup] -pub fn manifests_setup(p: &mut Program) { - let path = cargo_tomls(); - let package_dirs = path.iter().map(|p| (package_name(p), p.clone())).collect(); - p.with_resource(Manifests { path, package_dirs }); -} - -/// Recursively collects every `Cargo.toml` under the current directory, -/// skipping the legacy `.run` CI directory and any directory listed in -/// `.config/ci-ignored-dirs.txt`. -#[must_use] -fn cargo_tomls() -> Vec { - let ignored = ignored_dirs(); - let mut cargo_tomls = Vec::new(); - let mut dirs = vec![PathBuf::from(".")]; - while let Some(dir) = dirs.pop() { - if is_ignored(&dir.to_string_lossy(), &ignored) { - continue; - } - if let Ok(entries) = std::fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - // Skip the legacy `.run` CI directory - if path.file_name().and_then(|n| n.to_str()) == Some(".run") { - continue; - } - dirs.push(path); - } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") { - cargo_tomls.push(path); - } - } - } - } - cargo_tomls -} - -/// Parses `.config/ci-ignored-dirs.txt` into directory prefixes: -/// non-empty lines that do not start with `#`, with the trailing `/` stripped -/// (e.g. `./.temp/` → `./.temp`). -fn ignored_dirs() -> Vec { - IGNORED_DIRS_FILE - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(|line| line.trim_end_matches('/').to_string()) - .collect() -} - -/// Whether `path` (a walk directory, e.g. `./.temp` or `./examples`) is inside -/// one of the ignored directories. -fn is_ignored(path: &str, ignored: &[String]) -> bool { - ignored.iter().any(|dir| { - path.strip_prefix(dir.as_str()) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) - }) -} - -/// Extracts the package name from a `Cargo.toml`. -/// -/// Falls back to the parent directory name (e.g. `mingling_core/Cargo.toml` → -/// `mingling_core`, workspace root → `(root)`), matching the legacy CI. -fn package_name(path: &Path) -> String { - let fallback = || { - path.parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - .unwrap_or("(root)") - .to_string() - }; - - let Ok(content) = std::fs::read_to_string(path) else { - return fallback(); - }; - let Ok(toml_value) = content.parse::() else { - return fallback(); - }; - toml_value - .get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - .map_or_else(fallback, str::to_string) -} diff --git a/mingling_ci/src/res/print.rs b/mingling_ci/src/res/print.rs deleted file mode 100644 index 9d844a6..0000000 --- a/mingling_ci/src/res/print.rs +++ /dev/null @@ -1,174 +0,0 @@ -use colored::Colorize; -use mingling::config::ErrorOutput; -use mingling::hook::ProgramHook; -use mingling::{Program, macros::program_setup}; -use mingling::{StringVec, this}; - -use crate::ThisProgram; - -#[program_setup] -pub fn print_setup(p: &mut Program) { - p.with_resource(CargoError::default()); - p.with_resource(CargoWarn::default()); - p.with_resource(CargoHelp::default()); - p.with_resource(CargoStatus::default()); - - p.with_hook(ProgramHook::empty().on_begin::<_, ()>(move |_| { - let p = this::(); - let silence_err = p.stdout_setting.error_output == ErrorOutput::Hide; - - p.modify_res(|r: &mut CargoError| r.silence = silence_err); - p.modify_res(|r: &mut CargoWarn| r.silence = silence_err); - p.modify_res(|r: &mut CargoHelp| r.silence = silence_err); - p.modify_res(|r: &mut CargoStatus| r.silence = silence_err); - })); -} - -#[derive(Default, Clone)] -pub struct CargoError { - silence: bool, -} - -impl MessagePrinter for CargoError { - fn format(&self, msg: impl Into) -> String { - format!("{}: {}", "error".bold().bright_red(), msg.into().join("")) - } - - fn std_mode(&self) -> StandardOutMode { - if self.silence { - StandardOutMode::Silence - } else { - StandardOutMode::Error - } - } -} - -#[derive(Default, Clone)] -pub struct CargoWarn { - silence: bool, -} - -impl MessagePrinter for CargoWarn { - fn format(&self, msg: impl Into) -> String { - format!("{}: {}", "warning".bright_yellow(), msg.into().join("")) - } - - fn std_mode(&self) -> StandardOutMode { - if self.silence { - StandardOutMode::Silence - } else { - StandardOutMode::Error - } - } -} - -#[derive(Default, Clone)] -pub struct CargoHelp { - silence: bool, -} - -impl MessagePrinter for CargoHelp { - fn format(&self, msg: impl Into) -> String { - format!("{}: {}", "help".bright_white(), msg.into().join("")) - } - - fn std_mode(&self) -> StandardOutMode { - if self.silence { - StandardOutMode::Silence - } else { - StandardOutMode::Error - } - } -} - -#[derive(Default, Clone)] -pub struct CargoStatus { - silence: bool, -} - -impl MessagePrinter for CargoStatus { - fn format(&self, msg: impl Into) -> String { - let parts: Vec = msg.into().to_vec(); - let first = if parts.is_empty() { - String::new() - } else { - parts[0].trim().to_string() - }; - - let (prefix, content) = if first.is_empty() { - // Empty: fall back to Info with full message - ("Info".to_string(), parts.join(" ")) - } else if first.chars().count() == 1 { - // Single character: prefix is Info, entire message is content - ("Info".to_string(), parts.join(" ")) - } else if first.chars().count() <= 12 { - // Single part that is a status prefix (no message after it) - if parts.len() == 1 { - ("Info".to_string(), first) - } else { - // First part is a status prefix, remaining parts are the message - let content = parts[1..].join(" ").trim_start().to_string(); - (first, content) - } - } else { - // First part too long: all is message, fall back to Info - ("Info".to_string(), parts.join(" ")) - }; - - let padding = " ".repeat(12usize.saturating_sub(prefix.chars().count())); - - format!( - "{}{} {}", - padding, - prefix.bold().bright_green(), - content.trim() - ) - } - - fn std_mode(&self) -> StandardOutMode { - if self.silence { - StandardOutMode::Silence - } else { - StandardOutMode::Out - } - } -} - -pub trait MessagePrinter { - #[doc(hidden)] - fn println(&self, msg: impl Into) { - match self.std_mode() { - StandardOutMode::Out => println!("{}", self.format(msg)), - StandardOutMode::Error => eprintln!("{}", self.format(msg)), - StandardOutMode::Silence => {} - } - } - - #[doc(hidden)] - fn print(&self, msg: impl Into) { - match self.std_mode() { - StandardOutMode::Out => print!("{}", self.format(msg)), - StandardOutMode::Error => eprint!("{}", self.format(msg)), - StandardOutMode::Silence => {} - } - } - - /// Formats the message string before output. - fn format(&self, msg: impl Into) -> String; - - /// Returns the standard output mode (stdout or stderr). - fn std_mode(&self) -> StandardOutMode; -} - -/// Specifies where standard output messages should be directed. -/// -/// This enum determines whether messages are printed to stdout, stderr, or suppressed entirely. -#[repr(u8)] -pub enum StandardOutMode { - /// Print messages to standard output (stdout). - Out, - /// Print messages to standard error (stderr). - Error, - /// Suppress all output. - Silence, -} diff --git a/mingling_ci/src/task.rs b/mingling_ci/src/task.rs deleted file mode 100644 index a42e458..0000000 --- a/mingling_ci/src/task.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub(crate) mod cmd_build_check; -pub(crate) mod cmd_clippy_check; -pub(crate) mod cmd_docs_check; -pub(crate) mod cmd_example_check; -pub(crate) mod cmd_markdown_check; -pub(crate) mod cmd_markdown_compare; -pub(crate) mod cmd_test; -pub(crate) mod run; - diff --git a/mingling_ci/src/task/cmd_build_check.rs b/mingling_ci/src/task/cmd_build_check.rs deleted file mode 100644 index f67fe2e..0000000 --- a/mingling_ci/src/task/cmd_build_check.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::ffi::OsString; -use std::path::Path; - -use mingling::{ - Grouped, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::res::Manifests; -use crate::task::run::{location, run_parallel_checks}; - -#[command(node = "build-check")] -pub async fn build_check(manifests: &Manifests) -> Next { - let tasks = manifests - .package_dirs - .iter() - .map(|(name, path)| (name.clone(), location(path), build_args(path))) - .collect(); - let fail_count = run_parallel_checks("Build-Check", "Building", tasks).await; - ResultBuildCheck { fail_count }.to_chain() -} - -/// `cargo build --manifest-path ` -fn build_args(path: &Path) -> Vec { - vec![ - "cargo".into(), - "build".into(), - "--manifest-path".into(), - path.as_os_str().to_os_string(), - ] -} - -/// Number of packages that failed to build. -#[derive(Grouped)] -pub struct ResultBuildCheck { - pub fail_count: usize, -} - -/// Silently sets a non-zero exit code when any build failed. -#[renderer(buffer)] -pub fn render_build_check(r: ResultBuildCheck, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} diff --git a/mingling_ci/src/task/cmd_clippy_check.rs b/mingling_ci/src/task/cmd_clippy_check.rs deleted file mode 100644 index a0dd46e..0000000 --- a/mingling_ci/src/task/cmd_clippy_check.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::ffi::OsString; -use std::path::Path; - -use mingling::{ - Grouped, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::res::Manifests; -use crate::task::run::{location, run_parallel_checks}; - -#[command(node = "clippy-check")] -pub async fn clippy_check(manifests: &Manifests) -> Next { - let tasks = manifests - .package_dirs - .iter() - .map(|(name, path)| (name.clone(), location(path), clippy_args(path))) - .collect(); - let fail_count = run_parallel_checks("Clippy-Check", "Clippy", tasks).await; - ResultClippyCheck { fail_count }.to_chain() -} - -/// `cargo clippy --manifest-path -- -D warnings` -fn clippy_args(path: &Path) -> Vec { - vec![ - "cargo".into(), - "clippy".into(), - "--manifest-path".into(), - path.as_os_str().to_os_string(), - "--".into(), - "-D".into(), - "warnings".into(), - ] -} - -/// Number of packages that failed clippy. -#[derive(Grouped)] -pub struct ResultClippyCheck { - pub fail_count: usize, -} - -/// Silently sets a non-zero exit code when any clippy check failed. -#[renderer(buffer)] -pub fn render_clippy_check(r: ResultClippyCheck, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} diff --git a/mingling_ci/src/task/cmd_docs_check.rs b/mingling_ci/src/task/cmd_docs_check.rs deleted file mode 100644 index 3a77d4d..0000000 --- a/mingling_ci/src/task/cmd_docs_check.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::ffi::OsString; - -use mingling::{ - Grouped, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::res::ResFeatureList; -use crate::task::run::run_parallel_checks; - -#[command(node = "docs-check")] -pub async fn docs_check(features: &ResFeatureList) -> Next { - let args = vec![ - OsString::from("cargo"), - OsString::from("rustdoc"), - OsString::from("--features"), - OsString::from(features.list.join(",")), - OsString::from("-p"), - OsString::from("mingling"), - OsString::from("--"), - OsString::from("-D"), - OsString::from("warnings"), - ]; - let tasks = vec![("mingling".to_string(), "./mingling".to_string(), args)]; - let fail_count = run_parallel_checks("Docs-Check", "Docs", tasks).await; - - ResultDocsCheck { fail_count }.to_chain() -} - -/// Number of failed doc builds (0 or 1). -#[derive(Grouped)] -pub struct ResultDocsCheck { - pub fail_count: usize, -} - -/// Silently sets a non-zero exit code when the doc build failed. -#[renderer(buffer)] -pub fn render_docs_check(r: ResultDocsCheck, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} diff --git a/mingling_ci/src/task/cmd_example_check.rs b/mingling_ci/src/task/cmd_example_check.rs deleted file mode 100644 index 1b9f440..0000000 --- a/mingling_ci/src/task/cmd_example_check.rs +++ /dev/null @@ -1,69 +0,0 @@ -use colored::Colorize; -use mingling::{ - Grouped, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::examples::{check_example, load_test_configs}; -use crate::progress::task_progress_bar; -use crate::reporter::{self, ReportResult}; - -#[command(node = "example-check")] -pub async fn example_check() -> Next { - reporter::set_task("Example-Check"); - - let configs = load_test_configs(); - let total = configs.len(); - let pb = task_progress_bar(total, "Testing"); - pb.set_message("examples"); - - // One blocking task per example: build + run its test cases. - let mut handles = Vec::new(); - for example in configs { - handles.push(tokio::task::spawn_blocking(move || check_example(example))); - } - - let mut fail_count = 0; - for handle in handles { - let Ok(outcome) = handle.await else { - continue; - }; - pb.set_message(outcome.name.clone()); - pb.inc(1); - - if outcome.ok { - reporter::export(&outcome.name, &outcome.location, ReportResult::Ok); - } else { - fail_count += 1; - // Plain stderr: `pb.println` is swallowed on non-TTY (CI). - eprintln!(" {} {}", "failed".bright_red(), outcome.name); - eprintln!(" {}", outcome.output); - reporter::export( - &outcome.name, - &outcome.location, - ReportResult::Error(outcome.output), - ); - } - } - - pb.finish_and_clear(); - reporter::flush(); - - ResultExampleCheck { fail_count }.to_chain() -} - -/// Number of examples that failed to build or pass their tests. -#[derive(Grouped)] -pub struct ResultExampleCheck { - pub fail_count: usize, -} - -/// Silently sets a non-zero exit code when any example failed. -#[renderer(buffer)] -pub fn render_example_check(r: ResultExampleCheck, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} diff --git a/mingling_ci/src/task/cmd_markdown_check.rs b/mingling_ci/src/task/cmd_markdown_check.rs deleted file mode 100644 index 2408636..0000000 --- a/mingling_ci/src/task/cmd_markdown_check.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use just_fmt::snake_case; -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::markdown::project::parse_markdown; -use crate::markdown::test::{MarkdownBlockOutcome, try_test_markdown_project}; -use crate::reporter::{self, ReportResult}; -use crate::res::{CargoError, MessagePrinter}; - -const VERIFIED_DOCS: &str = ".config/verified-docs.toml"; - -#[command(node = "markdown-check")] -pub async fn markdown_check(args: Vec) -> Next { - let Some(path_str) = args.first() else { - return ErrorMarkdownArgs("missing argument".to_string()).to_chain(); - }; - let path = - std::env::current_dir().map_or_else(|_| PathBuf::from(path_str), |cwd| cwd.join(path_str)); - if !path.is_file() { - return ErrorMarkdownArgs(format!("{} is not a file", path.display())).to_chain(); - } - let Ok(content) = std::fs::read_to_string(&path) else { - return ErrorMarkdownArgs(format!("failed to read {}", path.display())).to_chain(); - }; - - let location = path.to_string_lossy().into_owned(); - let item = format!("doc-{}", snake_case!(&stem_of(&path))); - reporter::set_task("Markdown-Check"); - - let projects = parse_markdown(&content, &location); - let outcomes = try_test_markdown_project(projects).await; - let file_info = HashMap::from([(location.clone(), (item, location))]); - let fail_count = report_files(&outcomes, &file_info); - reporter::flush(); - - ResultMarkdownCheck { fail_count }.to_chain() -} - -#[command(node = "markdown-check-all")] -pub async fn markdown_check_all() -> Next { - let Some(files) = verified_md_files() else { - return ErrorMarkdownConfig.to_chain(); - }; - reporter::set_task("Markdown-Check-All"); - - // Collect all projects; remember each file's report identity - // (`{key}-{snake_case(file_stem)}` -> location). - let mut projects = Vec::new(); - let mut file_info: HashMap = HashMap::new(); - for (label, path) in files { - let Ok(content) = std::fs::read_to_string(&path) else { - continue; - }; - let file_name = path.file_name().unwrap().to_string_lossy(); - let source_file = format!("{label}/{file_name}"); - let item = format!("{label}-{}", snake_case!(&stem_of(&path))); - let location = path.to_string_lossy().into_owned(); - file_info.insert(source_file.clone(), (item, location)); - projects.extend(parse_markdown(&content, &source_file)); - } - - let outcomes = try_test_markdown_project(projects).await; - let fail_count = report_files(&outcomes, &file_info); - reporter::flush(); - - ResultMarkdownCheck { fail_count }.to_chain() -} - -/// The file name without extension, e.g. `README.md` → `README`. -pub(crate) fn stem_of(path: &Path) -> String { - path.file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned() -} - -/// Exports one report entry per source file: `ok` when every block passed, -/// otherwise an error carrying the failed blocks' details. -fn report_files( - outcomes: &[MarkdownBlockOutcome], - file_info: &HashMap, -) -> usize { - let mut by_file: HashMap<&str, (bool, Vec)> = HashMap::new(); - for outcome in outcomes { - let (ok, outputs) = by_file - .entry(outcome.source_file.as_str()) - .or_insert((true, Vec::new())); - if !outcome.ok { - *ok = false; - outputs.push(format!( - "{}:{}:\n{}", - outcome.source_file, outcome.line, outcome.output - )); - } - } - - let mut fail_count = 0; - for (source_file, (ok, outputs)) in by_file { - let Some((item, location)) = file_info.get(source_file) else { - continue; - }; - if ok { - reporter::export(item, location, ReportResult::Ok); - } else { - fail_count += outputs.len(); - reporter::export(item, location, ReportResult::Error(outputs.join("\n\n"))); - } - } - fail_count -} - -/// Reads `verified-docs.toml` and collects all `.md` files: single files, -/// directories, or `**` globs (walked from the base directory). -fn verified_md_files() -> Option> { - let content = std::fs::read_to_string(VERIFIED_DOCS).ok()?; - let table: toml::Table = content.parse().ok()?; - - let mut files: Vec<(String, PathBuf)> = Vec::new(); - for (label, value) in table.get("verified")?.as_table()? { - let value_str = value.as_str()?; - let candidate = PathBuf::from(value_str); - if candidate.is_dir() { - collect_md_files(&candidate, &mut files, label); - } else if candidate.is_file() { - files.push((label.clone(), candidate)); - } else if candidate.extension().is_none() { - // Glob like "docs/pages/**": walk the base directory. - let base = PathBuf::from(value_str.trim_end_matches("/**").trim_end_matches('*')); - if base.is_dir() { - collect_md_files(&base, &mut files, label); - } - } - } - - files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); - Some(files) -} - -/// Recursively collects all `.md` files under a directory. -fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, label: &str) { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_md_files(&path, files, label); - } else if path.extension().is_some_and(|ext| ext == "md") { - files.push((label.to_string(), path)); - } - } - } -} - -/// Number of code blocks that failed to build. -#[derive(Grouped)] -pub struct ResultMarkdownCheck { - pub fail_count: usize, -} - -#[derive(Grouped, Default)] -pub struct ErrorMarkdownArgs(pub String); - -#[derive(Grouped, Default)] -pub struct ErrorMarkdownConfig; - -/// Silently sets a non-zero exit code when any block failed. -#[renderer(buffer)] -pub fn render_markdown_check(r: ResultMarkdownCheck, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} - -#[renderer] -pub fn render_error_markdown_args(e: ErrorMarkdownArgs, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![e.0]); - render_result -} - -#[renderer] -pub fn render_error_markdown_config(_: ErrorMarkdownConfig, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![format!("failed to read {VERIFIED_DOCS}")]); - render_result -} diff --git a/mingling_ci/src/task/cmd_markdown_compare.rs b/mingling_ci/src/task/cmd_markdown_compare.rs deleted file mode 100644 index b014f1e..0000000 --- a/mingling_ci/src/task/cmd_markdown_compare.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; - -use colored::Colorize; -use just_fmt::snake_case; -use mingling::{ - Grouped, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::markdown::compare::{collect_md_files, compare_signature}; -use crate::reporter::{self, ReportResult}; -use crate::task::cmd_markdown_check::{ErrorMarkdownArgs, ErrorMarkdownConfig, stem_of}; - -const DOCS_DIR: &str = "./docs"; -const LANG_CONFIG: &str = ".config/docs-lang.txt"; - -/// One file-pair outcome of a structure comparison. -struct CompareOutcome { - item: String, - location: String, - ok: bool, - output: String, -} - -#[command(node = "markdown-compare")] -// `#[command]` rewrites an owned first param into the entry type, so the args -// must be passed by value even though the body only reads them. -#[allow(clippy::needless_pass_by_value)] -pub fn markdown_compare(args: Vec) -> Next { - let [ref_arg, trans_arg] = args.as_slice() else { - return ErrorMarkdownArgs("missing and arguments".to_string()) - .to_chain(); - }; - let ref_path = cwd().join(ref_arg); - let trans_path = cwd().join(trans_arg); - - reporter::set_task("Markdown-Compare"); - let outcomes = if ref_path.is_dir() && trans_path.is_dir() { - compare_dirs(&ref_path, &trans_path, "doc") - } else if ref_path.is_file() && trans_path.is_file() { - compare_files(&ref_path, &trans_path, "doc") - } else { - return ErrorMarkdownArgs( - "both arguments must be files or both must be directories".to_string(), - ) - .to_chain(); - }; - let fail_count = export_outcomes(&outcomes); - reporter::flush(); - - ResultMarkdownCompare { fail_count }.to_chain() -} - -#[command(node = "markdown-compare-all")] -pub fn markdown_compare_all() -> Next { - let Some(langs) = lang_config() else { - return ErrorMarkdownConfig.to_chain(); - }; - let Some(reference) = langs.first() else { - return ErrorMarkdownConfig.to_chain(); - }; - let ref_dir = PathBuf::from(DOCS_DIR).join(reference); - if !ref_dir.is_dir() { - return ErrorMarkdownArgs(format!( - "reference docs directory `{}` does not exist", - ref_dir.display() - )) - .to_chain(); - } - - reporter::set_task("Markdown-Compare-All"); - let mut fail_count = 0; - for lang in &langs[1..] { - let lang_dir = PathBuf::from(DOCS_DIR).join(lang); - if !lang_dir.is_dir() { - eprintln!( - " {}: `{}` does not exist", - "ERROR".bright_red(), - lang_dir.display() - ); - fail_count += 1; - continue; - } - let outcomes = compare_dirs(&ref_dir, &lang_dir, &lang_key(lang)); - fail_count += export_outcomes(&outcomes); - } - reporter::flush(); - - ResultMarkdownCompare { fail_count }.to_chain() -} - -/// Compares one file pair (reference vs translation). -fn compare_files(ref_path: &Path, trans_path: &Path, prefix: &str) -> Vec { - let item = format!("{prefix}-{}", snake_case!(&stem_of(ref_path))); - let location = trans_path.to_string_lossy().into_owned(); - match compare_signature(ref_path, trans_path) { - Ok(()) => vec![CompareOutcome { - item, - location, - ok: true, - output: String::new(), - }], - Err(diffs) => vec![CompareOutcome { - item, - location, - ok: false, - output: diffs.join("\n"), - }], - } -} - -/// Compares two directories: every `.md` file in the reference must exist in -/// the translation with the same structural signature; extra files are errors. -fn compare_dirs(ref_dir: &Path, trans_dir: &Path, prefix: &str) -> Vec { - let ref_files = collect_md_files(ref_dir); - let ref_set: BTreeSet = ref_files.iter().cloned().collect(); - let trans_set: BTreeSet = collect_md_files(trans_dir).into_iter().collect(); - - let mut outcomes = Vec::new(); - for file in ref_files { - let item = format!("{prefix}-{}", snake_case!(&stem_of(&file))); - let trans_path = trans_dir.join(&file); - let location = trans_path.to_string_lossy().into_owned(); - if !trans_set.contains(&file) { - outcomes.push(CompareOutcome { - item, - location, - ok: false, - output: "missing in translation".to_string(), - }); - continue; - } - outcomes.push(match compare_signature(&ref_dir.join(&file), &trans_path) { - Ok(()) => CompareOutcome { - item, - location, - ok: true, - output: String::new(), - }, - Err(diffs) => CompareOutcome { - item, - location, - ok: false, - output: diffs.join("\n"), - }, - }); - } - - for file in trans_set.difference(&ref_set) { - let item = format!("{prefix}-{}", snake_case!(&stem_of(file))); - let trans_path = trans_dir.join(file); - outcomes.push(CompareOutcome { - item, - location: trans_path.to_string_lossy().into_owned(), - ok: false, - output: "extra file, not in reference".to_string(), - }); - } - outcomes -} - -/// Exports the outcomes via `reporter`; failures also print to stderr. -fn export_outcomes(outcomes: &[CompareOutcome]) -> usize { - let mut fail_count = 0; - for outcome in outcomes { - if outcome.ok { - reporter::export(&outcome.item, &outcome.location, ReportResult::Ok); - } else { - fail_count += 1; - eprintln!(" {} {}", "failed".bright_red(), outcome.item); - eprintln!(" {}\n{}", outcome.location, outcome.output); - reporter::export( - &outcome.item, - &outcome.location, - ReportResult::Error(outcome.output.clone()), - ); - } - } - fail_count -} - -/// Reads `.config/docs-lang.txt`: the first line is the reference directory -/// (relative to `./docs/`), the rest are translations that must mirror it. -fn lang_config() -> Option> { - let content = std::fs::read_to_string(LANG_CONFIG).ok()?; - Some( - content - .lines() - .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .map(|l| l.trim_start_matches("./").to_string()) - .collect(), - ) -} - -/// Turns a lang directory path into a report-item key, e.g. -/// `./_zh_CN/pages/` → `_zh_CN_pages`. -fn lang_key(lang: &str) -> String { - lang.trim_matches('/').replace('/', "_") -} - -fn cwd() -> PathBuf { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Number of files that failed the structure comparison. -#[derive(Grouped)] -pub struct ResultMarkdownCompare { - pub fail_count: usize, -} - -/// Silently sets a non-zero exit code when any comparison failed. -#[renderer(buffer)] -pub fn render_markdown_compare(r: ResultMarkdownCompare, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} diff --git a/mingling_ci/src/task/cmd_test.rs b/mingling_ci/src/task/cmd_test.rs deleted file mode 100644 index 5b9f55a..0000000 --- a/mingling_ci/src/task/cmd_test.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::ffi::OsString; -use std::path::Path; - -use mingling::{ - Grouped, Routable, - macros::{buffer, command, renderer}, - res::ResExitCode, -}; - -use crate::Next; -use crate::res::{Manifests, ResCrateConfig}; -use crate::task::run::{location, run_parallel_checks}; - -#[command(node = "test-all")] -pub async fn test_all(manifests: &Manifests, config: &ResCrateConfig) -> Next { - let tasks = manifests - .package_dirs - .iter() - .map(|(name, path)| { - let args = config.test_command(name).map_or_else( - || test_args(path), - |cmd| cmd.iter().map(|s| OsString::from(s.as_str())).collect(), - ); - (name.clone(), location(path), args) - }) - .collect(); - let fail_count = run_parallel_checks("Test-All", "Testing", tasks).await; - ResultTestAll { fail_count }.to_chain() -} - -/// Default: `cargo test --manifest-path ` (crates without a -/// `mingling-ci.toml` override). -fn test_args(path: &Path) -> Vec { - vec![ - "cargo".into(), - "test".into(), - "--manifest-path".into(), - path.as_os_str().to_os_string(), - ] -} - -/// Number of packages that failed tests. -#[derive(Grouped)] -pub struct ResultTestAll { - pub fail_count: usize, -} - -/// Silently sets a non-zero exit code when any test failed. -#[renderer(buffer)] -pub fn render_test_all(r: ResultTestAll, exit_code: &mut ResExitCode) { - if r.fail_count > 0 { - exit_code.exit_code = 1; - } -} diff --git a/mingling_ci/src/task/run.rs b/mingling_ci/src/task/run.rs deleted file mode 100644 index ba752dd..0000000 --- a/mingling_ci/src/task/run.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::ffi::OsString; -use std::path::Path; - -use colored::Colorize; - -use crate::progress::task_progress_bar; -use crate::reporter::{self, ReportResult}; - -/// The manifest's parent directory, e.g. `./mingling` — the report location -/// for a crate-based item. -pub(crate) fn location(path: &Path) -> String { - path.parent() - .map_or_else(|| ".".to_string(), |d| d.to_string_lossy().into_owned()) -} - -/// Outcome of a `cargo` subcommand. -struct CargoResult { - ok: bool, - exit_code: Option, - output: String, -} - -/// Runs the given cargo task list in parallel. -/// -/// Each task is an `(item, location, argv)` triple; progress and failures go -/// to stderr: a failing task prints its output immediately and writes its -/// report entry at the same time. Returns the number of failing tasks. -pub(crate) async fn run_parallel_checks( - task: &str, - phase: &str, - tasks: Vec<(String, String, Vec)>, -) -> usize { - reporter::set_task(task); - - let n = tasks.len(); - let pb = task_progress_bar(n, phase); - pb.set_message("tasks"); - - // Run each task in parallel. - let mut set = tokio::task::JoinSet::new(); - for (item, location, args) in tasks { - set.spawn(async move { (item, location, run_cargo(args).await) }); - } - - let mut fail_count = 0; - while let Some(joined) = set.join_next().await { - let Ok((item, location, result)) = joined else { - continue; - }; - pb.inc(1); - pb.set_message(item.clone()); - - if result.ok { - reporter::export(&item, &location, ReportResult::Ok); - } else { - fail_count += 1; - // Failures print to stderr immediately (bar suspended to avoid - // interleaving) and write their report entry at the same time. - pb.suspend(|| { - eprintln!( - "{}: {} failed{}", - phase.bold().bright_cyan(), - item, - result - .exit_code - .map_or_else(String::new, |c| format!(" (exit code {c})")) - ); - for line in result.output.lines() { - eprintln!(" {line}"); - } - }); - reporter::export(&item, &location, ReportResult::Error(result.output)); - } - } - - pb.finish_and_clear(); - reporter::flush(); - fail_count -} - -/// Runs a `cargo` subcommand, capturing its output. -/// Runs a cargo subcommand (`argv[0]` is the program), capturing its output. -async fn run_cargo(argv: Vec) -> CargoResult { - let mut argv = argv.into_iter(); - let Some(program) = argv.next() else { - return CargoResult { - ok: false, - exit_code: None, - output: "empty command".to_string(), - }; - }; - - let output = tokio::process::Command::new(program) - .args(argv) - .output() - .await; - - match output { - Ok(output) => { - let mut log = String::from_utf8_lossy(&output.stdout).into_owned(); - log.push_str(&String::from_utf8_lossy(&output.stderr)); - CargoResult { - ok: output.status.success(), - exit_code: output.status.code(), - output: log, - } - } - Err(e) => CargoResult { - ok: false, - exit_code: None, - output: format!("failed to run cargo: {e}"), - }, - } -} diff --git a/mingling_ci/src/tools.rs b/mingling_ci/src/tools.rs deleted file mode 100644 index 13c2ec4..0000000 --- a/mingling_ci/src/tools.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod docsify_refresh; -pub(crate) mod example_refresh; -pub(crate) mod features_refresh; diff --git a/mingling_ci/src/tools/docsify_refresh.rs b/mingling_ci/src/tools/docsify_refresh.rs deleted file mode 100644 index dfb9b11..0000000 --- a/mingling_ci/src/tools/docsify_refresh.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Docsify maintenance: fix code-box blank lines and regenerate `_sidebar.md` -//! files under `docs/`. - -use std::collections::BTreeMap; -use std::fmt::Write as _; -use std::fs; -use std::path::{Path, PathBuf}; - -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, r_println, renderer}, -}; - -use crate::Next; -use crate::res::{CargoError, MessagePrinter}; - -const DOCS_DIR: &str = "./docs"; -const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n"; - -#[command(node = "docsify-refresh")] -pub fn docsify_refresh() -> Next { - match refresh_all() { - Ok(written) => ResultDocsifyRefresh { written }.to_chain(), - Err(e) => ErrorDocsifyRefresh(e).to_chain(), - } -} - -fn refresh_all() -> Result, String> { - let mut written = Vec::new(); - written.extend(fix_code_boxes()); - written.extend(gen_sidebars()?); - Ok(written) -} - -/// Part 1: docsify renders code blocks poorly when the blank lines around -/// them are completely empty — replace them with a single space. -fn fix_code_boxes() -> Vec { - let mut file_count = 0; - let mut fixed_count = 0; - let mut written = Vec::new(); - - collect_md_files(Path::new(DOCS_DIR), &mut |path| { - if path - .file_name() - .is_some_and(|n| n.to_string_lossy().to_lowercase() == "_sidebar.md") - { - return; - } - let content = fs::read_to_string(path).unwrap_or_default(); - if content.is_empty() { - return; - } - let new_content = fix_code_box_empty_lines(&content); - if new_content != content { - fs::write(path, &new_content).unwrap(); - written.push(format!("fixed: {}", path.display())); - fixed_count += 1; - } - file_count += 1; - }); - - written.push(format!("scanned {file_count} files, fixed {fixed_count}")); - written -} - -/// Replaces completely empty lines adjacent to fenced code blocks with lines -/// containing a single space. -fn fix_code_box_empty_lines(content: &str) -> String { - let mut result = String::new(); - let lines: Vec<&str> = content.lines().collect(); - let len = lines.len(); - - let mut i = 0; - while i < len { - let line = lines[i]; - result.push_str(line); - result.push('\n'); - i += 1; - - if !line.trim_start().starts_with("```") { - continue; - } - - // In a code block: find the closing fence. - let code_start = i; - let mut code_end = len; - let mut found_end = false; - while i < len { - let cline = lines[i]; - if cline.trim_start().starts_with("```") && !cline.trim().is_empty() { - code_end = i; - found_end = true; - break; - } - i += 1; - } - - ensure_space_before_code_block(&mut result); - - for code_line in lines.iter().take(code_end).skip(code_start) { - if code_line.is_empty() { - result.push(' '); - } else { - result.push_str(code_line); - } - result.push('\n'); - } - - if found_end { - result.push_str(lines[code_end]); - result.push('\n'); - i += 1; - - if i < len && lines[i].trim().is_empty() && lines[i].is_empty() { - result.push(' '); - result.push('\n'); - i += 1; - } - } - } - - while result.ends_with('\n') { - result.pop(); - } - result.push('\n'); - result -} - -/// Turns a trailing `\n\n` before a code block into `\n \n`. -fn ensure_space_before_code_block(result: &mut String) { - let len = result.len(); - if len >= 2 && &result[len - 2..] == "\n\n" { - result.insert(len - 1, ' '); - } -} - -/// Part 2: find every README.md under `docs/` (each is a site root) and -/// regenerate its `_sidebar.md`. -fn gen_sidebars() -> Result, String> { - let mut written = Vec::new(); - for readme_path in find_all_readmes(Path::new(DOCS_DIR)) { - let site_root = readme_path - .parent() - .ok_or_else(|| format!("{} has no parent", readme_path.display()))?; - if let Some(content_dir) = find_content_dir(site_root) { - let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD); - let sidebar_path = site_root.join("_sidebar.md"); - fs::write(&sidebar_path, lines) - .map_err(|e| format!("failed to write {}: {e}", sidebar_path.display()))?; - written.push(format!("generated: {}", sidebar_path.display())); - } - } - Ok(written) -} - -/// Recursively finds all README.md files under a directory. -fn find_all_readmes(dir: &Path) -> Vec { - let mut results = Vec::new(); - if let Ok(read_dir) = fs::read_dir(dir) { - let mut entries: Vec<_> = read_dir.flatten().collect(); - entries.sort_by_key(std::fs::DirEntry::path); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - results.extend(find_all_readmes(&path)); - } else if path.file_name().is_some_and(|n| n == "README.md") { - results.push(path); - } - } - } - results -} - -/// The content directory of a site: `pages/` if present, else the first -/// subdirectory containing markdown files. -fn find_content_dir(site_root: &Path) -> Option { - let pages_dir = site_root.join("pages"); - if pages_dir.is_dir() { - return Some(pages_dir); - } - if let Ok(read_dir) = fs::read_dir(site_root) { - let mut entries: Vec<_> = read_dir.flatten().collect(); - entries.sort_by_key(std::fs::DirEntry::path); - for entry in entries { - let path = entry.path(); - if path.is_dir() && has_markdown_files(&path) { - return Some(path); - } - } - } - None -} - -fn has_markdown_files(dir: &Path) -> bool { - if let Ok(read_dir) = fs::read_dir(dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - if path.is_dir() { - if has_markdown_files(&path) { - return true; - } - } else if path.extension().is_some_and(|ext| ext == "md") { - return true; - } - } - } - false -} - -#[derive(Clone)] -struct SidebarEntry { - title: String, - link: String, -} - -/// Builds the sidebar content from the markdown files under `pages_dir`. -fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String { - let mut lines = String::from(sidebar_head); - - let mut root_files: Vec = Vec::new(); - let mut sub_dirs: BTreeMap> = BTreeMap::new(); - - if let Ok(read_dir) = fs::read_dir(pages_dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - if path.is_dir() { - let dir_name = entry.file_name().to_string_lossy().into_owned(); - let entries = collect_markdown_files(&path, base_dir); - if !entries.is_empty() { - let display_name = get_directory_display_name(&path, &dir_name); - sub_dirs.insert(display_name, entries); - } - } else if path.extension().is_some_and(|ext| ext == "md") { - root_files.push(SidebarEntry { - title: extract_title(&path), - link: relative_link(&path, base_dir), - }); - } - } - } - - root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link)); - for f in &root_files { - let _ = writeln!(lines, "* [{}]({})", f.title, f.link); - } - - for (dir_name, entries) in &sub_dirs { - let mut sorted_entries = entries.clone(); - sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link)); - let _ = writeln!(lines, "* {dir_name}"); - for f in &sorted_entries { - let _ = writeln!(lines, " * [{}]({})", f.title, f.link); - } - } - - lines -} - -/// All `.md` files directly under `dir`, as sidebar entries. -fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec { - let mut entries = Vec::new(); - if let Ok(read_dir) = fs::read_dir(dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "md") { - entries.push(SidebarEntry { - title: extract_title(&path), - link: relative_link(&path, base_dir), - }); - } - } - } - entries -} - -/// The link of a file relative to `base_dir`, without the `.md` suffix. -fn relative_link(path: &Path, base_dir: &Path) -> String { - path.strip_prefix(base_dir) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") - .strip_suffix(".md") - .unwrap_or_default() - .to_string() -} - -/// Extracts the title from the first line `

TITLE

`, -/// falling back to the file stem. -fn extract_title(path: &Path) -> String { - let content = fs::read_to_string(path).unwrap_or_default(); - if let Some(first_line) = content.lines().next() { - let trimmed = first_line.trim(); - if let Some(start) = trimmed.find('>') { - let after_start = &trimmed[start + 1..]; - if let Some(end) = after_start.find('<') { - return after_start[..end].to_string(); - } - } - } - path.file_stem().map_or_else( - || "Untitled".to_string(), - |s| s.to_string_lossy().into_owned(), - ) -} - -/// Reads a directory's `.name` file to override its sidebar display name. -fn get_directory_display_name(dir_path: &Path, fallback: &str) -> String { - let name_file = dir_path.join(".name"); - if name_file.is_file() { - fs::read_to_string(&name_file) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| fallback.to_string()) - } else { - fallback.to_string() - } -} - -/// Numeric-aware comparison: `1-x` sorts before `10-x`, unnumbered last. -fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { - extract_leading_number(a) - .cmp(&extract_leading_number(b)) - .then_with(|| a.cmp(b)) -} - -/// The leading numeric prefix of a link's file stem, `usize::MAX` if absent. -fn extract_leading_number(link: &str) -> usize { - if let Some(file_stem) = link.rsplit('/').next() - && let Some(num_end) = file_stem.find('-') - && let Ok(num) = file_stem[..num_end].parse::() - { - return num; - } - usize::MAX -} - -/// Recursively collects all `.md` files under a directory. -fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_md_files(&path, callback); - } else if path.extension().is_some_and(|ext| ext == "md") { - callback(&path); - } - } - } -} - -/// Files written by `docsify-refresh`. -#[derive(Grouped)] -pub struct ResultDocsifyRefresh { - pub written: Vec, -} - -#[derive(Grouped, Default)] -pub struct ErrorDocsifyRefresh(pub String); - -#[renderer(buffer)] -pub fn render_docsify_refresh(r: ResultDocsifyRefresh) { - for item in r.written { - r_println!("{item}"); - } -} - -#[renderer] -pub fn render_error_docsify_refresh(e: ErrorDocsifyRefresh, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![e.0]); - render_result -} diff --git a/mingling_ci/src/tools/example_refresh.rs b/mingling_ci/src/tools/example_refresh.rs deleted file mode 100644 index ca8443c..0000000 --- a/mingling_ci/src/tools/example_refresh.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! Regenerates the example documentation module and the examples index. - -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -use just_fmt::snake_case; -use just_template::Template; -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, r_println, renderer}, -}; -use serde::Serialize; - -use crate::Next; -use crate::res::{CargoError, MessagePrinter}; - -const EXAMPLE_ROOT: &str = "./examples"; -const EXAMPLE_DOCS_OUTPUT: &str = "./mingling/src/example_docs.rs"; -const EXAMPLE_DOCS_TEMPLATE: &str = include_str!("../../../mingling/src/example_docs.rs.tmpl"); -const EXAMPLES_JSON_OUTPUT: &str = "./docs/example-pages/examples.json"; - -#[command(node = "example-refresh")] -pub fn example_refresh() -> Next { - match refresh_all() { - Ok(written) => ResultExampleRefresh { written }.to_chain(), - Err(e) => ErrorExampleRefresh(e).to_chain(), - } -} - -fn refresh_all() -> Result, String> { - let mut written = Vec::new(); - written.extend(refresh_example_docs()?); - written.extend(sync_examples()?); - Ok(written) -} - -/// Part 1: regenerate `mingling/src/example_docs.rs` from the examples' -/// `src/main.rs` (header `//!` + code) and `Cargo.toml`. -fn refresh_example_docs() -> Result, String> { - let mut template = Template::from(EXAMPLE_DOCS_TEMPLATE); - - let mut examples = Vec::new(); - let entries = - fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - if !name.starts_with("example-") { - continue; - } - examples.push(ExampleContent::read(&name)); - } - examples.sort_by(|a, b| a.name.cmp(&b.name)); - - let mut written = Vec::new(); - for example in examples { - template - .add_impl("examples".to_string()) - .push(HashMap::from([ - ("example_header".to_string(), example.header), - ("example_import".to_string(), example.cargo_toml), - ("example_code".to_string(), example.code), - ("example_name".to_string(), snake_case!(&example.name)), - ])); - written.push(format!("example_docs: {}", example.name)); - } - - let template_str = template.to_string(); - let template_str = template_str - .lines() - .map(str::trim_end) - .collect::>() - .join("\n") - + "\n"; - fs::write(EXAMPLE_DOCS_OUTPUT, template_str) - .map_err(|e| format!("failed to write {EXAMPLE_DOCS_OUTPUT}: {e}"))?; - written.push(format!("written: {EXAMPLE_DOCS_OUTPUT}")); - Ok(written) -} - -struct ExampleContent { - name: String, - header: String, - code: String, - cargo_toml: String, -} - -impl ExampleContent { - fn read(name: &str) -> Self { - let prefix = |s: &str| { - s.lines() - .map(|line| format!("/// {line}")) - .collect::>() - .join("\n") - }; - - let (header, code) = read_header_and_code(name); - Self { - name: name.to_string(), - header: prefix(&header), - code: prefix(&code), - cargo_toml: prefix(&read_cargo_toml(name)), - } - } -} - -/// Reads an example's `src/main.rs`, splitting `//!` doc header from code. -fn read_header_and_code(name: &str) -> (String, String) { - let content = fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("src/main.rs")) - .unwrap_or_default(); - let mut lines = content.lines(); - let mut header = String::new(); - let mut code = String::new(); - - for line in lines.by_ref() { - if line.trim_start().starts_with("//!") { - header.push_str(line.trim_start_matches("//!")); - header.push('\n'); - } else { - code.push_str(line); - code.push('\n'); - break; - } - } - for line in lines { - code.push_str(line); - code.push('\n'); - } - - (header.trim().to_string(), code.trim().to_string()) -} - -fn read_cargo_toml(name: &str) -> String { - fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("Cargo.toml")).unwrap_or_default() -} - -/// Part 2: regenerate `docs/example-pages/examples.json` from each example's -/// `page.toml`. -fn sync_examples() -> Result, String> { - fs::create_dir_all("docs/example-pages") - .map_err(|e| format!("failed to create docs/example-pages: {e}"))?; - - let mut examples = Vec::new(); - let entries = - fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let dir_name = entry.file_name().to_string_lossy().into_owned(); - let page_toml = path.join("page.toml"); - if !page_toml.is_file() { - continue; - } - let Ok(content) = fs::read_to_string(&page_toml) else { - continue; - }; - let Ok(table) = content.parse::() else { - eprintln!("Warning: failed to parse {}", page_toml.display()); - continue; - }; - let Some(example) = table.get("example") else { - continue; - }; - - let get = |key: &str| { - example - .get(key) - .and_then(|v| v.as_str()) - .unwrap_or_default() - }; - let str_vec = |key: &str| { - example - .get(key) - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default() - }; - - let id = get("id"); - examples.push(ExampleMeta { - id: if id.is_empty() { - dir_name.clone() - } else { - id.to_string() - }, - name: { - let name = get("name"); - if name.is_empty() { - dir_name.clone() - } else { - name.to_string() - } - }, - icon: { - let icon = get("icon"); - if icon.is_empty() { - "📦".to_string() - } else { - icon.to_string() - } - }, - category: get("category").to_string(), - desc: get("desc").to_string(), - tags: str_vec("tags"), - files: { - let files = str_vec("files"); - if files.is_empty() { - vec!["Cargo.toml".to_string(), "src/main.rs".to_string()] - } else { - files - } - }, - }); - } - - // Basic first, then alphabetical. - examples.sort_by( - |a, b| match (a.id == "example-basic", b.id == "example-basic") { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.id.cmp(&b.id), - }, - ); - - let json = serde_json::to_string_pretty(&examples) - .map_err(|e| format!("failed to serialize examples: {e}"))?; - fs::write(EXAMPLES_JSON_OUTPUT, json) - .map_err(|e| format!("failed to write {EXAMPLES_JSON_OUTPUT}: {e}"))?; - - Ok(vec![format!( - "synced: {} examples -> {EXAMPLES_JSON_OUTPUT}", - examples.len() - )]) -} - -/// One entry of `docs/example-pages/examples.json`. -#[derive(Serialize)] -struct ExampleMeta { - id: String, - name: String, - icon: String, - category: String, - desc: String, - tags: Vec, - files: Vec, -} - -/// Files written by `example-refresh`. -#[derive(Grouped)] -pub struct ResultExampleRefresh { - pub written: Vec, -} - -#[derive(Grouped, Default)] -pub struct ErrorExampleRefresh(pub String); - -#[renderer(buffer)] -pub fn render_example_refresh(r: ResultExampleRefresh) { - for item in r.written { - r_println!("{item}"); - } -} - -#[renderer] -pub fn render_error_example_refresh(e: ErrorExampleRefresh, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![e.0]); - render_result -} diff --git a/mingling_ci/src/tools/features_refresh.rs b/mingling_ci/src/tools/features_refresh.rs deleted file mode 100644 index 87aeead..0000000 --- a/mingling_ci/src/tools/features_refresh.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Regenerates `mingling/src/features.rs` from the `[features]` section of -//! `mingling/Cargo.toml`. - -use std::collections::HashMap; -use std::fs; - -use just_fmt::snake_case; -use just_template::Template; -use mingling::{ - Grouped, RenderResult, Routable, - macros::{buffer, command, r_println, renderer}, -}; - -use crate::Next; -use crate::res::{CargoError, MessagePrinter}; - -const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml"; -const OUTPUT_PATH: &str = "./mingling/src/features.rs"; -const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl"); - -#[command(node = "features-refresh")] -pub fn features_refresh() -> Next { - match gen_feature_module() { - Ok(written) => ResultFeaturesRefresh { written }.to_chain(), - Err(e) => ErrorFeaturesRefresh(e).to_chain(), - } -} - -fn gen_feature_module() -> Result, String> { - let features = parse_features()?; - - let mut template = Template::from(TEMPLATE_CONTENT); - let mut written = Vec::new(); - for feat_name in &features { - let feat_const_name = snake_case!(feat_name).to_uppercase(); - template - .add_impl("features".to_string()) - .push(HashMap::from([ - ("feat_name".to_string(), feat_name.clone()), - ("feat_const_name".to_string(), feat_const_name), - ])); - written.push(format!("feature: {feat_name}")); - } - - let template_str = template.to_string(); - let template_str = template_str - .lines() - .map(str::trim_end) - .collect::>() - .join("\n") - + "\n"; - fs::write(OUTPUT_PATH, template_str) - .map_err(|e| format!("failed to write {OUTPUT_PATH}: {e}"))?; - written.push(format!("written: {OUTPUT_PATH}")); - Ok(written) -} - -/// All feature names from the `[features]` section, sorted. -fn parse_features() -> Result, String> { - let content = fs::read_to_string(CARGO_TOML_PATH) - .map_err(|e| format!("failed to read {CARGO_TOML_PATH}: {e}"))?; - let table: toml::Value = content - .parse() - .map_err(|e| format!("failed to parse {CARGO_TOML_PATH}: {e}"))?; - let features = table - .get("features") - .and_then(|v| v.as_table()) - .ok_or_else(|| format!("no [features] section in {CARGO_TOML_PATH}"))?; - - let mut names: Vec = features.keys().cloned().collect(); - names.sort(); - Ok(names) -} - -/// Feature names written by `features-refresh`. -#[derive(Grouped)] -pub struct ResultFeaturesRefresh { - pub written: Vec, -} - -#[derive(Grouped, Default)] -pub struct ErrorFeaturesRefresh(pub String); - -#[renderer(buffer)] -pub fn render_features_refresh(r: ResultFeaturesRefresh) { - for item in r.written { - r_println!("{item}"); - } -} - -#[renderer] -pub fn render_error_features_refresh(e: ErrorFeaturesRefresh, error: &CargoError) -> RenderResult { - let render_result = RenderResult::new(); - error.println(vec![e.0]); - render_result -} diff --git a/mingling_ci/tmpls/report.md b/mingling_ci/tmpls/report.md deleted file mode 100644 index 020fda2..0000000 --- a/mingling_ci/tmpls/report.md +++ /dev/null @@ -1,9 +0,0 @@ -

Mingling CI Results

- -

<<>> - <<>>

- ->>>>>>>>>> task_sections -@@@ >>> task_sections -<<
>> - -@@@ <<< diff --git a/mingling_ci/tmpls/task_section.md b/mingling_ci/tmpls/task_section.md deleted file mode 100644 index 78c9801..0000000 --- a/mingling_ci/tmpls/task_section.md +++ /dev/null @@ -1,18 +0,0 @@ -## Task: <<>> - -| Item-Name | Location | PASS (Windows) | PASS (Linux) | PASS (Mac OS) | -| ----------- | -------- | -------------- | ------------ | ------------- | ->>>>>>>>>> rows -@@@ >>> rows -| <<>> | <<>> | <<>> | <<>> | <<>> | -@@@ <<< - ->>>>>>>>>> fails -@@@ >>> fails -### Fail: <<>> - -```stdout -<<>> -``` - -@@@ <<< diff --git a/run.ps1 b/run.ps1 index 65ba0b9..ddf0b69 100644 --- a/run.ps1 +++ b/run.ps1 @@ -83,62 +83,62 @@ function Show-List { $tools = @{} -if (Test-Path ".run/src/bin/*.ps1") { - Get-ChildItem -Path ".run/src/bin/*.ps1" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.ps1") { + Get-ChildItem -Path "dev/run/src/bin/*.ps1" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "ps1"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.cs") { - Get-ChildItem -Path ".run/src/bin/*.cs" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.cs") { + Get-ChildItem -Path "dev/run/src/bin/*.cs" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "cs"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.exe") { - Get-ChildItem -Path ".run/src/bin/*.exe" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.exe") { + Get-ChildItem -Path "dev/run/src/bin/*.exe" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "exe"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.go") { - Get-ChildItem -Path ".run/src/bin/*.go" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.go") { + Get-ChildItem -Path "dev/run/src/bin/*.go" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "go"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.nim") { - Get-ChildItem -Path ".run/src/bin/*.nim" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.nim") { + Get-ChildItem -Path "dev/run/src/bin/*.nim" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "nim"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.pl") { - Get-ChildItem -Path ".run/src/bin/*.pl" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.pl") { + Get-ChildItem -Path "dev/run/src/bin/*.pl" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "pl"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.py") { - Get-ChildItem -Path ".run/src/bin/*.py" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.py") { + Get-ChildItem -Path "dev/run/src/bin/*.py" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "py"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.rb") { - Get-ChildItem -Path ".run/src/bin/*.rb" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.rb") { + Get-ChildItem -Path "dev/run/src/bin/*.rb" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "rb"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.rs") { - Get-ChildItem -Path ".run/src/bin/*.rs" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.rs") { + Get-ChildItem -Path "dev/run/src/bin/*.rs" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "rs"; Path = $_.FullName } } } -if (Test-Path ".run/src/bin/*.zig") { - Get-ChildItem -Path ".run/src/bin/*.zig" | ForEach-Object { +if (Test-Path "dev/run/src/bin/*.zig") { + Get-ChildItem -Path "dev/run/src/bin/*.zig" | ForEach-Object { $tools[$_.BaseName] = @{ Type = "zig"; Path = $_.FullName } } } @@ -200,7 +200,7 @@ switch ($info.Type) { & $info.Path @script_args } "cs" { - $temp_dir = ".run/target/csproj/$target_name" + $temp_dir = "dev/run/target/csproj/$target_name" $null = New-Item -ItemType Directory -Path $temp_dir -Force $props_content = @' @@ -249,7 +249,7 @@ switch ($info.Type) { ruby $info.Path $script_args } "rs" { - if (-not (Test-Path ".run/Cargo.toml")) { + if (-not (Test-Path "dev/run/Cargo.toml")) { @" [package] name = "run_rust" @@ -259,12 +259,12 @@ edition = "2024" [workspace] [dependencies] -"@ | Set-Content -Path ".run/Cargo.toml" +"@ | Set-Content -Path "dev/run/Cargo.toml" } - cargo build --manifest-path ".run/Cargo.toml" --target-dir ".run/target" --bin $target_name --quiet - $binary = ".run/target/debug/$target_name.exe" + cargo build --manifest-path "dev/run/Cargo.toml" --target-dir "dev/run/target" --bin $target_name --quiet + $binary = "dev/run/target/debug/$target_name.exe" if (-not (Test-Path $binary)) { - $binary = ".run/target/debug/$target_name" + $binary = "dev/run/target/debug/$target_name" } & $binary $script_args } diff --git a/run.sh b/run.sh index 947aefc..962308e 100755 --- a/run.sh +++ b/run.sh @@ -16,14 +16,14 @@ cd "$(dirname "$0")" || exit 1 declare -A tools -for file in .run/src/bin/*.sh; do +for file in dev/run/src/bin/*.sh; do if [ -f "$file" ]; then name=$(basename "$file" .sh) tools["$name"]="sh" fi done -for file in .run/src/bin/*; do +for file in dev/run/src/bin/*; do if [ -f "$file" ]; then name=$(basename "$file") if [[ ! "$name" == *.* ]]; then @@ -32,56 +32,56 @@ for file in .run/src/bin/*; do fi done -for file in .run/src/bin/*.cs; do +for file in dev/run/src/bin/*.cs; do if [ -f "$file" ]; then name=$(basename "$file" .cs) tools["$name"]="cs" fi done -for file in .run/src/bin/*.go; do +for file in dev/run/src/bin/*.go; do if [ -f "$file" ]; then name=$(basename "$file" .go) tools["$name"]="go" fi done -for file in .run/src/bin/*.nim; do +for file in dev/run/src/bin/*.nim; do if [ -f "$file" ]; then name=$(basename "$file" .nim) tools["$name"]="nim" fi done -for file in .run/src/bin/*.pl; do +for file in dev/run/src/bin/*.pl; do if [ -f "$file" ]; then name=$(basename "$file" .pl) tools["$name"]="pl" fi done -for file in .run/src/bin/*.py; do +for file in dev/run/src/bin/*.py; do if [ -f "$file" ]; then name=$(basename "$file" .py) tools["$name"]="py" fi done -for file in .run/src/bin/*.rb; do +for file in dev/run/src/bin/*.rb; do if [ -f "$file" ]; then name=$(basename "$file" .rb) tools["$name"]="rb" fi done -for file in .run/src/bin/*.rs; do +for file in dev/run/src/bin/*.rs; do if [ -f "$file" ]; then name=$(basename "$file" .rs) tools["$name"]="rs" fi done -for file in .run/src/bin/*.zig; do +for file in dev/run/src/bin/*.zig; do if [ -f "$file" ]; then name=$(basename "$file" .zig) tools["$name"]="zig" @@ -241,15 +241,15 @@ type="${tools[$target_name]}" case "$type" in sh) - chmod +x ".run/src/bin/$target_name.sh" - ".run/src/bin/$target_name.sh" "$@" + chmod +x "dev/run/src/bin/$target_name.sh" + "dev/run/src/bin/$target_name.sh" "$@" ;; binary) - chmod +x ".run/src/bin/$target_name" - ".run/src/bin/$target_name" "$@" + chmod +x "dev/run/src/bin/$target_name" + "dev/run/src/bin/$target_name" "$@" ;; cs) - temp_dir=".run/target/csproj/$target_name" + temp_dir="dev/run/target/csproj/$target_name" mkdir -p "$temp_dir" cat > "$temp_dir/Directory.Build.props" <<'PROPS' @@ -271,27 +271,27 @@ PROPS CSPROJ - cp ".run/src/bin/$target_name.cs" "$temp_dir/Program.cs" + cp "dev/run/src/bin/$target_name.cs" "$temp_dir/Program.cs" dotnet run --project "$temp_dir/$target_name.csproj" -- "$@" ;; go) - go run ".run/src/bin/$target_name.go" "$@" + go run "dev/run/src/bin/$target_name.go" "$@" ;; nim) - nim r --hints:off ".run/src/bin/$target_name.nim" "$@" + nim r --hints:off "dev/run/src/bin/$target_name.nim" "$@" ;; pl) - perl ".run/src/bin/$target_name.pl" "$@" + perl "dev/run/src/bin/$target_name.pl" "$@" ;; py) - python ".run/src/bin/$target_name.py" "$@" + python "dev/run/src/bin/$target_name.py" "$@" ;; rb) - ruby ".run/src/bin/$target_name.rb" "$@" + ruby "dev/run/src/bin/$target_name.rb" "$@" ;; rs) - if [ ! -f ".run/Cargo.toml" ]; then - cat > ".run/Cargo.toml" <<'EOF' + if [ ! -f "dev/run/Cargo.toml" ]; then + cat > "dev/run/Cargo.toml" <<'EOF' [package] name = "run_rust" version = "0.1.0" @@ -302,10 +302,10 @@ edition = "2024" [dependencies] EOF fi - cargo build --manifest-path ".run/Cargo.toml" --target-dir ".run/target" --bin "$target_name" --quiet - ".run/target/debug/$target_name" "$@" + cargo build --manifest-path "dev/run/Cargo.toml" --target-dir "dev/run/target" --bin "$target_name" --quiet + "dev/run/target/debug/$target_name" "$@" ;; zig) - zig run ".run/src/bin/$target_name.zig" "$@" + zig run "dev/run/src/bin/$target_name.zig" "$@" ;; esac -- cgit