aboutsummaryrefslogtreecommitdiff
path: root/dev
diff options
context:
space:
mode:
Diffstat (limited to 'dev')
-rw-r--r--dev/ci/Cargo.lock777
-rw-r--r--dev/ci/Cargo.toml47
-rw-r--r--dev/ci/build.rs6
-rw-r--r--dev/ci/help.txt34
-rw-r--r--dev/ci/src/bin/ci.rs30
-rw-r--r--dev/ci/src/cmd.rs6
-rw-r--r--dev/ci/src/cmd/cmd_git_lock.rs77
-rw-r--r--dev/ci/src/cmd/cmd_git_unlock.rs116
-rw-r--r--dev/ci/src/cmd/cmd_report_clean.rs54
-rw-r--r--dev/ci/src/cmd/cmd_report_collect.rs150
-rw-r--r--dev/ci/src/cmd/cmd_show_features.rs26
-rw-r--r--dev/ci/src/cmd/cmd_show_manifests.rs71
-rw-r--r--dev/ci/src/examples.rs186
-rw-r--r--dev/ci/src/git.rs69
-rw-r--r--dev/ci/src/lib.rs28
-rw-r--r--dev/ci/src/markdown.rs3
-rw-r--r--dev/ci/src/markdown/compare.rs203
-rw-r--r--dev/ci/src/markdown/project.rs347
-rw-r--r--dev/ci/src/markdown/test.rs152
-rw-r--r--dev/ci/src/progress.rs24
-rw-r--r--dev/ci/src/reporter.rs208
-rw-r--r--dev/ci/src/res.rs14
-rw-r--r--dev/ci/src/res/collect_logs.rs203
-rw-r--r--dev/ci/src/res/crate_config.rs79
-rw-r--r--dev/ci/src/res/features.rs47
-rw-r--r--dev/ci/src/res/manifests.rs103
-rw-r--r--dev/ci/src/res/print.rs174
-rw-r--r--dev/ci/src/task.rs9
-rw-r--r--dev/ci/src/task/cmd_build_check.rs47
-rw-r--r--dev/ci/src/task/cmd_clippy_check.rs50
-rw-r--r--dev/ci/src/task/cmd_docs_check.rs44
-rw-r--r--dev/ci/src/task/cmd_example_check.rs69
-rw-r--r--dev/ci/src/task/cmd_markdown_check.rs192
-rw-r--r--dev/ci/src/task/cmd_markdown_compare.rs221
-rw-r--r--dev/ci/src/task/cmd_test.rs54
-rw-r--r--dev/ci/src/task/run.rs114
-rw-r--r--dev/ci/src/tools.rs3
-rw-r--r--dev/ci/src/tools/docsify_refresh.rs373
-rw-r--r--dev/ci/src/tools/example_refresh.rs279
-rw-r--r--dev/ci/src/tools/features_refresh.rs96
-rw-r--r--dev/ci/tmpls/report.md9
-rw-r--r--dev/ci/tmpls/task_section.md18
-rw-r--r--dev/configs/ci-ignored-dirs.txt5
-rw-r--r--dev/configs/docs-lang.txt2
-rw-r--r--dev/configs/rust-analyzer.json16
-rw-r--r--dev/configs/verified-docs.toml8
-rw-r--r--dev/configs/version-files.toml39
-rw-r--r--dev/run/.gitignore5
-rw-r--r--dev/run/Cargo.lock572
-rw-r--r--dev/run/Cargo.toml26
-rw-r--r--dev/run/src/bin/ci.py75
-rw-r--r--dev/run/src/bin/clippy-fix.ps18
-rwxr-xr-xdev/run/src/bin/clippy-fix.sh6
-rw-r--r--dev/run/src/bin/cov-test.rs571
-rw-r--r--dev/run/src/bin/deploy-api-docs.rs118
-rw-r--r--dev/run/src/bin/display-dependency-order.rs12
-rw-r--r--dev/run/src/bin/http-page-preview.ps13
-rwxr-xr-xdev/run/src/bin/http-page-preview.sh2
-rw-r--r--dev/run/src/bin/install-mling.ps110
-rwxr-xr-xdev/run/src/bin/install-mling.sh17
-rw-r--r--dev/run/src/bin/package-all.rs736
-rw-r--r--dev/run/src/bin/update-version.rs104
-rw-r--r--dev/run/src/bin/windows-folder-hide.ps1115
-rw-r--r--dev/run/src/dependency_order.rs196
-rw-r--r--dev/run/src/lib.rs459
-rw-r--r--dev/run/src/verify.rs506
66 files changed, 8423 insertions, 0 deletions
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 <FLAGS> [SUBCOMMAND] <ARGS...>
+
+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 <PATH> Verify rust code blocks in one markdown file
+ markdown-check-all Verify rust code blocks in all configured markdown files
+ markdown-compare <PATH> <PATH> 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<PathBuf>,
+}
+
+#[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<String, bool>;
+
+/// 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<TaskRow<'a>>>;
+
+#[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<HashMap<String, String>> = 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<String>,
+}
+
+#[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<ManifestEntry> = 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<ManifestEntry>,
+}
+
+#[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<String>,
+ expect: Expect,
+}
+
+struct Expect {
+ exit_code: i32,
+ result: String,
+}
+
+/// One example and its test cases.
+pub(crate) struct ExampleCase {
+ name: String,
+ cases: Vec<TestCase>,
+}
+
+/// Outcome of checking one example.
+pub(crate) struct ExampleOutcome {
+ pub name: String,
+ pub location: String,
+ pub ok: bool,
+ pub output: String,
+}
+
+/// Loads `examples/<name>/test.toml` for every example that has one, in
+/// alphabetical order of the example directory name.
+pub(crate) fn load_test_configs() -> Vec<ExampleCase> {
+ 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::<toml::Value>() 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<Vec<TestCase>> {
+ let runs = table.get("runs")?.as_array()?;
+ let mut cases = Vec::new();
+ for run in runs {
+ let input: Vec<String> = 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 <args>`, 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<I, S>(args: I) -> Result<String, String>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<OsStr>,
+{
+ 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<String, String> {
+ 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 `<hN>`), 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<PathBuf> {
+ 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(&current) 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<String>> {
+ 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("<missing>");
+ let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>");
+ diffs.push(format!("line {}", i + 1));
+ diffs.push(format!(
+ "expect `{}` {}",
+ token_label(ref_tok.map_or("<eof>", String::as_str)),
+ display_line(ref_line)
+ ));
+ diffs.push(format!(
+ "found `{}` {}",
+ token_label(lang_tok.map_or("<eof>", 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<String> {
+ 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("<h") || line.starts_with("</h") {
+ // HTML headings (e.g. `<h1 align="center">` / `</h1>`)
+ 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 `<blank>`.
+fn display_line(line: &str) -> String {
+ if line.trim().is_empty() {
+ "<blank>".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<String>,
+ 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<String> = 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<MarkdownTestProject> {
+ 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<MarkdownTestProject> {
+ let mut code_lines: Vec<String> = Vec::new();
+ let mut features: Vec<String> = 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<String> = 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<String> = 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<MarkdownTestProject>,
+) -> Vec<MarkdownBlockOutcome> {
+ // Group by dependency hash for crate sharing.
+ let mut groups: BTreeMap<String, Vec<MarkdownTestProject>> = 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<Option<String>> = Mutex::new(None);
+
+/// Pending success entries: `(item, location)`.
+type PendingOk = (String, String);
+
+/// Successful items pending a [`flush`], grouped by platform.
+static OK_BUFFER: LazyLock<Mutex<HashMap<ReportPlatform, Vec<PendingOk>>>> =
+ 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<String> = 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<String, bool>>,
+ /// `(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<ThisProgram>) {
+ 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 `<<<features>>>` expanded).
+ test_commands: HashMap<String, Vec<String>>,
+}
+
+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<ThisProgram>) {
+ let features = p
+ .res::<ResFeatureList>()
+ .map(|f| f.list.clone())
+ .unwrap_or_default();
+ let joined_features = features.join(",");
+
+ let Some(manifests) = p.res::<Manifests>() 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::<toml::Value>() 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<String> = 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("<<<features>>>", &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<String>,
+}
+
+#[program_setup]
+pub fn features_setup(p: &mut Program<ThisProgram>) {
+ 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<String> {
+ let Ok(content) = std::fs::read_to_string(FEATURES_MANIFEST) else {
+ return Vec::new();
+ };
+ let Ok(toml_value) = content.parse::<toml::Value>() 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<PathBuf>,
+ /// Package name -> its manifest path.
+ pub package_dirs: HashMap<String, PathBuf>,
+}
+
+#[program_setup]
+pub fn manifests_setup(p: &mut Program<ThisProgram>) {
+ 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<PathBuf> {
+ 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<String> {
+ 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::<toml::Table>() 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<ThisProgram>) {
+ 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::<ThisProgram>();
+ 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<StringVec>) -> 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<StringVec>) -> 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<StringVec>) -> 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<StringVec>) -> String {
+ let parts: Vec<String> = 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<StringVec>) {
+ 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<StringVec>) {
+ 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<StringVec>) -> 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 <path>`
+fn build_args(path: &Path) -> Vec<OsString> {
+ 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 <path> -- -D warnings`
+fn clippy_args(path: &Path) -> Vec<OsString> {
+ 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<String>) -> Next {
+ let Some(path_str) = args.first() else {
+ return ErrorMarkdownArgs("missing <path> 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<String, (String, String)> = 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<String, (String, String)>,
+) -> usize {
+ let mut by_file: HashMap<&str, (bool, Vec<String>)> = 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<Vec<(String, PathBuf)>> {
+ 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<String>) -> Next {
+ let [ref_arg, trans_arg] = args.as_slice() else {
+ return ErrorMarkdownArgs("missing <reference> and <translation> 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<CompareOutcome> {
+ 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<CompareOutcome> {
+ let ref_files = collect_md_files(ref_dir);
+ let ref_set: BTreeSet<PathBuf> = ref_files.iter().cloned().collect();
+ let trans_set: BTreeSet<PathBuf> = 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<Vec<String>> {
+ 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 <path>` (crates without a
+/// `mingling-ci.toml` override).
+fn test_args(path: &Path) -> Vec<OsString> {
+ 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<i32>,
+ 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<OsString>)>,
+) -> 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<OsString>) -> 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<Vec<String>, 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<String> {
+ 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<Vec<String>, 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<PathBuf> {
+ 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<PathBuf> {
+ 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<SidebarEntry> = Vec::new();
+ let mut sub_dirs: BTreeMap<String, Vec<SidebarEntry>> = 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<SidebarEntry> {
+ 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 `<h1 align="center">TITLE</h1>`,
+/// 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::<usize>()
+ {
+ 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<String>,
+}
+
+#[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<Vec<String>, 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<Vec<String>, 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::<Vec<_>>()
+ .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::<Vec<_>>()
+ .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<Vec<String>, 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::<toml::Value>() 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<String>,
+ files: Vec<String>,
+}
+
+/// Files written by `example-refresh`.
+#[derive(Grouped)]
+pub struct ResultExampleRefresh {
+ pub written: Vec<String>,
+}
+
+#[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<Vec<String>, 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::<Vec<_>>()
+ .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<Vec<String>, 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<String> = features.keys().cloned().collect();
+ names.sort();
+ Ok(names)
+}
+
+/// Feature names written by `features-refresh`.
+#[derive(Grouped)]
+pub struct ResultFeaturesRefresh {
+ pub written: Vec<String>,
+}
+
+#[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 @@
+<h1 align="center">Mingling CI Results</h1>
+
+<p align="center"> <<<date>>> - <<<commit_hash>>> </p>
+
+>>>>>>>>>> task_sections
+@@@ >>> task_sections
+<<<section>>>
+
+@@@ <<<
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: <<<task_name>>>
+
+| Item-Name | Location | PASS (Windows) | PASS (Linux) | PASS (Mac OS) |
+| ----------- | -------- | -------------- | ------------ | ------------- |
+>>>>>>>>>> rows
+@@@ >>> rows
+| <<<item_name>>> | <<<location>>> | <<<pass_win>>> | <<<pass_linux>>> | <<<pass_mac>>> |
+@@@ <<<
+
+>>>>>>>>>> fails
+@@@ >>> fails
+### Fail: <<<item_name>>>
+
+```stdout
+<<<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:
+//! <https://github.com/Weicao-CatilGrass/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 <PATH>`: 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<TestCase>,
+}
+
+/// One `[[runs]]` entry of an example's `test.toml`.
+#[derive(Deserialize)]
+struct TestCase {
+ input: Vec<String>,
+}
+
+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 <output_path>/html/ to <output_path>
+ 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/<crate>/Cargo.toml` manifests.
+fn find_test_crate_manifests(repo_root: &Path) -> Vec<PathBuf> {
+ 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/<name>/test.toml` into `(example_name, input)` pairs.
+fn load_example_commands(repo_root: &Path) -> Vec<(String, Vec<String>)> {
+ 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<String> {
+ 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::<serde_json::Value>(&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 <path>` arguments.
+///
+/// - `debug/` root: example binaries (built via `cargo llvm-cov run`).
+/// - `debug/deps/`: test crate binaries (e.g. `integration-<hash>`); 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::<Vec<_>>()
+ .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 `<td class='column-entry-...'><pre>XX% ...</pre></td>` 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 = "<td class='column-entry-";
+ let mut out = String::with_capacity(input.len());
+ let mut rest = input;
+ while let Some(pos) = rest.find(TD) {
+ out.push_str(&rest[..pos + TD.len()]);
+ rest = &rest[pos + TD.len()..];
+ let Some(pre_end) = rest.find("'><pre>") else {
+ out.push_str(rest);
+ return out;
+ };
+ let color = &rest[..pre_end];
+ let tail = &rest[pre_end + "'><pre>".len()..];
+ let pct: String = tail
+ .trim_start()
+ .chars()
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
+ .collect();
+ let new_color = match pct.parse::<f64>() {
+ 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("'><pre>");
+ 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<std::path::PathBuf> {
+ 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!(
+ "<td class='column-entry-red'><pre> 50.00% (2/4)</pre></td>",
+ "<td class='column-entry-yellow'><pre> 51.23% (32/52)</pre></td>",
+ "<td class='column-entry-red'><pre> 80.00% (48/89)</pre></td>",
+ "<td class='column-entry-green'><pre> 81.00% (1/1)</pre></td>",
+ "<td class='column-entry-yellow'><pre> 90.00% (6/7)</pre></td>",
+ "<td class='column-entry-gray'><pre>- (0/0)</pre></td>",
+ );
+ let out = recolor_coverage_table(input);
+ assert!(out.contains("class='column-entry-red'><pre> 50.00%"));
+ assert!(out.contains("class='column-entry-yellow'><pre> 51.23%"));
+ assert!(out.contains("class='column-entry-yellow'><pre> 80.00%"));
+ assert!(out.contains("class='column-entry-green'><pre> 81.00%"));
+ assert!(out.contains("class='column-entry-green'><pre> 90.00%"));
+ assert!(out.contains("class='column-entry-gray'><pre>- (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<std::path::PathBuf> {
+ 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<MetadataPackage>,
+}
+
+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<String, String> = 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<PathBuf> = 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/<name>/
+ // 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<String, String>, HashMap<String, toml::Value>) {
+ 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<String> = t
+ .iter()
+ .map(|(k, val)| format!("{} = {}", k, toml_value_str(val)))
+ .collect();
+ format!("{{ {} }}", items.join(", "))
+ }
+ toml::Value::Array(a) => {
+ let items: Vec<String> = 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, String>,
+) -> 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<String> = 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, String>,
+) -> 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<toml::Value> = 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::<i64>() {
+ merged.insert(k.to_string(), toml::Value::Integer(n));
+ } else if let Ok(f) = v.parse::<f64>() {
+ 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<String> = 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<String, String>,
+ ws_deps: &HashMap<String, toml::Value>,
+ version_map: &HashMap<String, String>,
+) -> 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<VersionFile>,
+}
+
+fn main() {
+ let args: Vec<String> = 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}", &current_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<String> {
+ 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<String> {
+ 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<String>,
+ dep_map: &HashMap<String, Vec<String>>,
+) -> Vec<String> {
+ // 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<String> = 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 &current {
+ 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<PathBuf> {
+ 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<PathBuf> {
+ 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<String> = 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<String, Vec<String>> = 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<String> = 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<String>) {
+ 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<String>) {
+ 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<String>) {
+ 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<String>) -> 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<String>, 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<String>) -> Result<String, (i32, String)> {
+ 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<String>,
+ dir: &std::path::Path,
+) -> Result<String, (i32, String)> {
+ 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<String> = tasks.iter().map(|(l, _, _)| l.clone()).collect();
+
+ let (tx, rx) = std::sync::mpsc::channel::<(usize, String, Result<String, (i32, String)>)>();
+
+ 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<Vec<String>, 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<String> = 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<std::path::PathBuf> {
+ 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<String>,
+ /// 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<CodeBlock> {
+ 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<CodeBlock> {
+ let line_num = start + 1; // 1-based line number
+
+ let mut code_lines: Vec<String> = Vec::new();
+ let mut features: Vec<String> = 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<String> = 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<String> = 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<String> = 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());
+}