diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-19 05:54:00 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-19 06:15:20 +0800 |
| commit | ec9edc294fd5e7e29977fc7b0e6fb953422bc0e2 (patch) | |
| tree | bbf89ef4457b8de8a8a9bca5668045d612d27572 /dev | |
| parent | 06dfc27194c11e1d1033c292c759a1c5d82e780b (diff) | |
Move CI, dev tools, and configs from root-level scattered locations into
a
unified `dev/` directory structure. Update all references across build
scripts, documentation, and editor configurations.
Also consolidate editor config generation into build.rs for automated
synchronization of rust-analyzer settings across VS Code and Zed.
Diffstat (limited to 'dev')
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(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "md") { + out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf()); + } + } + } + out.sort(); + out +} + +/// Compares the structural signatures of two markdown files. +/// +/// Returns the human-readable diff lines (up to a small window) on the first +/// structural difference. +pub(crate) fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<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}", ¤t_ver); + let new_pattern = vf.pattern.replace("{VER}", &new_ver); + + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: Could not read {}: {}", file_path, e); + skipped_count += 1; + continue; + } + }; + + let new_content = content.replace(&old_pattern, &new_pattern); + + if new_content == content { + eprintln!( + "Warning: Pattern '{}' not found in {}", + old_pattern, file_path + ); + skipped_count += 1; + continue; + } + + std::fs::write(file_path, &new_content) + .unwrap_or_else(|e| panic!("Failed to write {}: {}", file_path, e)); + println_cargo_style!("Updated: {}", file_path); + updated_count += 1; + } + + println_cargo_style!( + "Done: {} file(s) updated, {} file(s) skipped", + updated_count, + skipped_count + ); +} diff --git a/dev/run/src/bin/windows-folder-hide.ps1 b/dev/run/src/bin/windows-folder-hide.ps1 new file mode 100644 index 0000000..ff53202 --- /dev/null +++ b/dev/run/src/bin/windows-folder-hide.ps1 @@ -0,0 +1,115 @@ +$skipDirs = @('.git', '.temp', 'target', 'node_modules', '.pnpm') +$selfPath = (Get-Item -LiteralPath $MyInvocation.MyCommand.Path).Directory.FullName + +function Test-InSkipDir { + param( + [object]$Item + ) + $path = if ($Item -is [string]) { + $Item + } elseif ($Item.PSPath) { + $Item.PSPath -replace '^.*::', '' + } else { + $Item.FullName + } + + $parts = $path.Split([System.IO.Path]::DirectorySeparatorChar) + for ($i = 0; $i -lt $parts.Length - 1; $i++) { + if ($parts[$i] -in $skipDirs) { + return $true + } + } + return $false +} + +function Invoke-UnhideRecursive { + param([string]$Path) + Get-ChildItem -LiteralPath $Path -Force | ForEach-Object { + if ($_.PSIsContainer) { + if ($_.Name -in $skipDirs) { + if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) { + Write-Host " -> unhiding skip directory (self only): `"$($_.FullName)`"" + $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden + } + return + } + Invoke-UnhideRecursive $_.FullName + } else { + if ($_.Attributes -band [System.IO.FileAttributes]::Hidden) { + Write-Host " -> unhiding: `"$($_.FullName)`"" + $_.Attributes = $_.Attributes -bxor [System.IO.FileAttributes]::Hidden + } + } + } +} + +function Test-GitPathSkippable { + param([string]$GitPath) + $parts = $GitPath.Split(@('/', '\')) + for ($i = 0; $i -lt $parts.Length - 1; $i++) { + if ($parts[$i] -in $skipDirs) { + return $true + } + } + return $false +} + +Write-Host "Step 1: Unhiding all files and directories (skipping $($skipDirs -join ', '))..." + +Invoke-UnhideRecursive -Path (Get-Location).Path + +Write-Host "Step 2: Hiding git-ignored items..." + +git ls-files --others --ignored --exclude-standard | Where-Object { + -not (Test-GitPathSkippable $_) +} | ForEach-Object { + $itemPath = $_ + Write-Host "... checking: `"$itemPath`"" + $item = Get-Item $_ -Force -ErrorAction SilentlyContinue + if (-not $item) { return } + + if ($item.FullName -eq $selfPath) { return } + + if (Test-InSkipDir $item) { + Write-Host " -> skipping (inside skip directory)" + return + } + + if ($item.PSIsContainer) { + if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding directory (non-recursive)" + $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden + } + } else { + if (-not ($item.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding" + $item.Attributes = $item.Attributes -bor [System.IO.FileAttributes]::Hidden + } + } +} + +Write-Host "Step 3: Hiding dot-prefixed items..." +Get-ChildItem -Path . -Force -Directory | Where-Object { $_.Name -match '^\.' } | ForEach-Object { + Write-Host "... checking: `"$($_.FullName)`"" + if (Test-InSkipDir $_) { + Write-Host " -> skipping (inside skip directory)" + return + } + if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding directory" + $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden + } +} + +Get-ChildItem -Path . -Force -File | Where-Object { $_.Name -match '^\.' } | ForEach-Object { + if ($_.FullName -eq $selfPath) { return } + Write-Host "... checking: `"$($_.FullName)`"" + if (Test-InSkipDir $_) { + Write-Host " -> skipping (inside skip directory)" + return + } + if (-not ($_.Attributes -band [System.IO.FileAttributes]::Hidden)) { + Write-Host " -> hiding file" + $_.Attributes = $_.Attributes -bor [System.IO.FileAttributes]::Hidden + } +} diff --git a/dev/run/src/dependency_order.rs b/dev/run/src/dependency_order.rs new file mode 100644 index 0000000..145bdbd --- /dev/null +++ b/dev/run/src/dependency_order.rs @@ -0,0 +1,196 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Parse Cargo.toml content and return dependency names that start with `prefix`. +fn parse_mingling_deps(content: &str, prefix: &str) -> Vec<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 ¤t { + if let Some(dependents) = reverse.get(node) { + for &dependent in dependents { + if let Some(degree) = in_degree.get_mut(dependent) { + *degree -= 1; + } + } + } + } + } + + result +} + +/// Strip the `\\?\` prefix that `std::fs::canonicalize` may add on Windows. +fn strip_verbatim_prefix(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + let s_ref: &str = &s; + if let Some(rest) = s_ref.strip_prefix("\\\\?\\") { + PathBuf::from(rest) + } else { + p.to_path_buf() + } +} + +/// Find the workspace root by looking for a Cargo.toml with `[workspace]` members. +/// Starts from `start` and walks up the directory tree. +pub fn find_workspace_root(start: &std::path::Path) -> Option<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()); +} |
