diff --git a/.config/nextest.toml b/.config/nextest.toml index 827acc4fc..9ff67e899 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -30,10 +30,11 @@ serial-toolchain-installs = { max-threads = 1 } # "Access is denied (os error 5)". Retrying with back-off is the standard # mitigation for this class of transient failure. # -# The final clause `(binary(ui) & test(=ui))` catches crates whose test -# integration binary is `tests/ui.rs` with a top-level `fn ui()` — the test -# name reported to nextest is plain `ui`, not `ui::ui`, so the substring -# match `test(ui::ui)` does not capture them (e.g. bumpy_road_function). +# The final clause constrains `test(=ui)` to the `ui` binary, catching crates +# whose integration binary is `tests/ui.rs` with a top-level `fn ui()` — the +# test name reported to nextest is plain `ui`, not `ui::ui`, so the substring +# match `test(ui::ui)` does not capture them (e.g. bumpy_road_function). Other +# tests from that binary can share the same binary clause. # # `ui::example_compiles_under_test_harness`, # `ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code`, and @@ -43,7 +44,7 @@ serial-toolchain-installs = { max-threads = 1 } # hangs during cargo's dev-dependency resolution on Windows CI regardless of # whether any example source uses rstest, so all Dylint example compilation for # that crate is compiled away on Windows. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(ui::example_compiles_under_test_harness) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(ui::example_compiles_under_test_harness) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & (test(=ui) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics)))" test-group = "serial-dylint-ui" retries = { backoff = "exponential", count = 2, delay = "5s" } slow-timeout = { period = "10m", terminate-after = 1 } diff --git a/Cargo.lock b/Cargo.lock index 17d862a7d..5250df5a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -208,11 +217,17 @@ dependencies = [ "serde", "tempfile", "toml 1.1.4+spec-1.1.0", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" @@ -367,6 +382,18 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-dispatch" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a558b9547b590c876e46e301da15d3b0e93b0384fd50d2c7870f7ea86760df5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "clap_builder" version = "4.6.6" @@ -460,8 +487,8 @@ dependencies = [ "serde", "serial_test", "tempfile", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -670,6 +697,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "directories-next" version = "2.0.0" @@ -680,6 +716,15 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -690,6 +735,30 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -697,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -724,6 +793,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dylint" version = "6.0.4" @@ -762,7 +837,7 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "toml 1.1.4+spec-1.1.0", - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -783,8 +858,6 @@ dependencies = [ [[package]] name = "dylint_testing" version = "6.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f7d8b8cb1558ef0d090882c6359e5e2d5571ca6633446fa2347fa48da7b27bb" dependencies = [ "anyhow", "cargo_metadata", @@ -855,6 +928,22 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml 0.8.23", + "uncased", + "version_check", +] + [[package]] name = "filetime" version = "0.2.29" @@ -1037,8 +1126,8 @@ dependencies = [ "rustc_session", "rustc_span", "serial_test", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1188,6 +1277,29 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "googletest" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94" +dependencies = [ + "googletest_macro", + "num-traits", + "regex", + "rustversion", +] + +[[package]] +name = "googletest_macro" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1345,6 +1457,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inout" version = "0.2.2" @@ -1711,8 +1829,8 @@ dependencies = [ "rustc_session", "rustc_span", "tempfile", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1733,8 +1851,8 @@ dependencies = [ "rustc_session", "rustc_span", "serial_test", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1764,8 +1882,8 @@ dependencies = [ "serde", "temp-env", "tokio", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1794,8 +1912,8 @@ dependencies = [ "serial_test", "tempfile", "toml 1.1.4+spec-1.1.0", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1818,8 +1936,8 @@ dependencies = [ "rustc_span", "serde", "temp-env", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -1860,6 +1978,51 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ortho_config" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1275151857af166ec59cab125a6fec5d2d0ddceda70a8c50c83c45456b953" +dependencies = [ + "camino", + "cap-std 4.0.3", + "clap", + "clap-dispatch", + "directories", + "dirs", + "dunce", + "figment", + "fluent-bundle", + "fluent-syntax", + "ortho_config_macros", + "serde", + "serde_json", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "tracing", + "uncased", + "unic-langid", + "xdg", +] + +[[package]] +name = "ortho_config_macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca0f4ba149c15111da8c4d3b6180af452d44c36049b25c22b0902d2383a1da9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1899,6 +2062,29 @@ dependencies = [ "hmac", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "peg" version = "0.6.3" @@ -2006,13 +2192,23 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -2054,6 +2250,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2224,6 +2433,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + [[package]] name = "regex" version = "1.13.1" @@ -2383,8 +2602,8 @@ dependencies = [ "serde", "toml 1.1.4+spec-1.1.0", "trybuild", - "whitaker", "whitaker-common", + "whitaker_lint_core", ] [[package]] @@ -2666,6 +2885,15 @@ dependencies = [ "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 = "serde_spanned" version = "1.1.1" @@ -2910,8 +3138,8 @@ dependencies = [ "rustc_lint", "rustc_span", "serde", - "whitaker", "whitaker-common", + "whitaker_lint_core", "whitaker_test_macros", ] @@ -3046,6 +3274,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -3054,13 +3294,22 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.4", ] +[[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_datetime" version = "1.1.1+spec-1.1.0" @@ -3070,6 +3319,20 @@ dependencies = [ "serde_core", ] +[[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 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -3077,7 +3340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.4", @@ -3092,6 +3355,12 @@ dependencies = [ "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -3197,6 +3466,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unic-langid" version = "0.9.6" @@ -3441,18 +3719,19 @@ name = "whitaker" version = "0.2.7" dependencies = [ "camino", - "cargo_metadata", + "cap-std 4.0.3", "clap", - "dylint_linting", - "dylint_testing", + "googletest", + "insta", + "ortho_config", + "pretty_assertions", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", - "rustc_ast", - "rustc_hir", - "rustc_lint", - "rustc_span", "serde", + "serde_json", + "tempfile", "thiserror 2.0.20", "toml 1.1.4+spec-1.1.0", "whitaker-common", @@ -3492,6 +3771,7 @@ dependencies = [ "directories-next", "flate2", "fs2", + "insta", "log", "mockall", "rstest", @@ -3540,6 +3820,29 @@ dependencies = [ "whitaker_test_macros", ] +[[package]] +name = "whitaker_lint_core" +version = "0.2.7" +dependencies = [ + "camino", + "cargo_metadata", + "clap", + "dylint_linting", + "dylint_testing", + "rstest", + "rstest-bdd", + "rstest-bdd-macros", + "rustc_ast", + "rustc_hir", + "rustc_lint", + "rustc_span", + "serde", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "whitaker-common", + "whitaker_test_macros", +] + [[package]] name = "whitaker_sarif" version = "0.2.7" @@ -3627,6 +3930,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3663,6 +3975,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3696,6 +4023,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3708,6 +4041,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3720,6 +4059,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3744,6 +4089,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3756,6 +4107,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3768,6 +4125,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3780,6 +4143,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3797,6 +4166,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" @@ -3833,6 +4205,18 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.8.56" diff --git a/Cargo.toml b/Cargo.toml index 31da331a5..4c253b9f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = ["common", "crates/*", "installer", "suite"] +exclude = ["vendor/dylint_testing"] resolver = "2" [workspace.package] @@ -12,7 +13,10 @@ version = "0.2.7" [workspace.dependencies] whitaker-common = { path = "common", version = "0.2.7" } whitaker-installer = { path = "installer", version = "0.2.7" } -whitaker = { path = ".", version = "0.2.7" } +whitaker_lint_core = { path = "crates/whitaker_lint_core", version = "0.2.7" } +ortho_config = "0.9.0" +googletest = "0.14.3" +pretty_assertions = "1.4.1" camino = "1.2.1" cap-std = { version = "4.0.2", features = ["fs_utf8"] } cargo_metadata = "0.23.0" @@ -65,45 +69,59 @@ rustc_middle = { path = "crates/rustc_middle", version = "0.2.7" } rustc_session = { path = "crates/rustc_session", version = "0.2.7" } rustc_span = { path = "crates/rustc_span", version = "0.2.7" } +# Dylint 6.0.4 recognizes only commands that begin with `rustc` in verbose +# Cargo output. Locate the compiler after managed wrappers without bypassing +# them; remove this compatibility patch when upstream supports wrappers. +[patch.crates-io] +dylint_testing = { path = "vendor/dylint_testing" } + [package] name = "whitaker" version = "0.2.7" edition = "2024" - -[features] -default = [] -dylint-driver = [ - "dep:dylint_linting", - "dep:rustc_ast", - "dep:rustc_hir", - "dep:rustc_lint", - "dep:rustc_span", -] +description = "Whitaker command-line interface" +include = ["/README.md", "/LICENSE", "/src/**"] +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +autobins = false + +[[bin]] +name = "whitaker" +path = "src/main.rs" [dependencies] - -camino = { workspace = true } -cargo_metadata = { workspace = true } -clap = { workspace = true, features = ["derive"] } -serde = { workspace = true } -toml = { workspace = true } -thiserror = { workspace = true } -whitaker-common = { workspace = true } -whitaker-installer = { workspace = true } - -rustc_ast = { workspace = true, optional = true } -rustc_hir = { workspace = true, optional = true } -rustc_lint = { workspace = true, optional = true } -rustc_span = { workspace = true, optional = true } -dylint_linting = { workspace = true, optional = true } +camino.workspace = true +clap.workspace = true +ortho_config.workspace = true +thiserror.workspace = true +whitaker-installer.workspace = true [dev-dependencies] -whitaker_test_macros = { workspace = true } -whitaker-common = { workspace = true } -rstest = { workspace = true } -rstest-bdd = { workspace = true } -rstest-bdd-macros = { workspace = true } -dylint_testing = { workspace = true } +cap-std.workspace = true +googletest.workspace = true +insta.workspace = true +pretty_assertions.workspace = true +proptest.workspace = true +rstest.workspace = true +rstest-bdd.workspace = true +rstest-bdd-macros.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +toml.workspace = true +whitaker-common.workspace = true +whitaker-installer = { workspace = true, features = ["test-support"] } +whitaker_test_macros.workspace = true + +[package.metadata.binstall] +pkg-url = "https://github.com/leynos/whitaker/releases/download/v{version}/{name}-{target}-v{version}.{archive-format}" +bin-dir = "{name}-{target}-v{version}/{bin}" +pkg-fmt = "tgz" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-fmt = "zip" [lints] workspace = true diff --git a/Makefile b/Makefile index 8829d3fb5..4f9006e9d 100644 --- a/Makefile +++ b/Makefile @@ -20,13 +20,18 @@ CARGO_FLAGS ?= --workspace --all-targets --all-features # Lint every other target rather than `--all-targets`; `typecheck` still builds # the fixtures, and the suite still lints them through the UI harness. CLIPPY_FLAGS ?= --workspace --lib --bins --tests --benches --all-features -TEST_EXCLUDES ?= --exclude rustc_ast --exclude rustc_attr_data_structures --exclude rustc_hir --exclude rustc_lint --exclude rustc_middle --exclude rustc_session --exclude rustc_span --exclude whitaker --exclude function_attrs_follow_docs --exclude module_max_lines --exclude no_expect_outside_tests +TEST_EXCLUDES ?= --exclude rustc_ast --exclude rustc_attr_data_structures --exclude rustc_hir --exclude rustc_lint --exclude rustc_middle --exclude rustc_session --exclude rustc_span --exclude whitaker_lint_core --exclude function_attrs_follow_docs --exclude module_max_lines --exclude no_expect_outside_tests TEST_CARGO_FLAGS ?= $(CARGO_FLAGS) $(TEST_EXCLUDES) +# The Dylint driver feature links compiler-private crates. Cargo unifies that +# feature for the all-workspace pass, so exercise core's ordinary tests in a +# dedicated feature-free invocation rather than excluding their coverage. +CORE_TEST_CARGO_FLAGS ?= -p whitaker_lint_core --no-default-features NEXTEST_PROFILE ?= # The cargo test driver. `test` runs `cargo nextest run`; `coverage` # overrides this with `cargo llvm-cov nextest ...` so instrumentation runs # over the exact same crate subset (TEST_CARGO_FLAGS) and RUSTFLAGS. TEST_RUNNER ?= nextest run +CORE_TEST_RUNNER ?= nextest run COVERAGE_OUTPUT ?= lcov.info RUST_FLAGS ?= -D warnings RUSTDOC_FLAGS ?= --cfg docsrs -D warnings @@ -56,14 +61,12 @@ SPELLING_HELPER_PYTEST = PYTHONPATH=scripts $(SPELLING_PY_ENV) \ --with pytest-cov==7.0.0 python -m pytest WORKFLOW_TEST_VENV ?= .venv LINT_CRATES ?= bumpy_road_function conditional_max_n_branches function_attrs_follow_docs module_max_lines module_must_have_inner_docs no_expect_outside_tests test_must_not_have_example no_std_fs_operations no_unwrap_or_else_panic whitaker_suite -# Doctests compile as their own crate and do not inherit the lib's -# `#![cfg_attr(feature = "dylint-driver", feature(rustc_private))]`, so the -# Dylint driver crates cannot link `rustc_driver` from a doctest and fail with -# "use of unstable library feature `rustc_private`". Their examples are covered -# by the unit and UI suites instead, so exclude them from the doctest run. +# Doctests for the Dylint driver crates cannot link `rustc_driver`, because +# doctest crates do not inherit the `rustc_private` feature configuration. +# Their examples are covered by the unit and UI suites instead. DOCTEST_EXCLUDES ?= --exclude rustc_ast --exclude rustc_attr_data_structures \ --exclude rustc_hir --exclude rustc_lint --exclude rustc_middle \ - --exclude rustc_session --exclude rustc_span --exclude whitaker \ + --exclude rustc_session --exclude rustc_span --exclude whitaker_lint_core \ --exclude rstest_helper_should_be_fixture \ $(foreach crate,$(LINT_CRATES),--exclude $(crate)) CARGO_DYLINT_VERSION ?= 6.0.1 @@ -73,12 +76,11 @@ DYLINT_LINK_VERSION ?= 6.0.1 DYLINT_TOOLS_TOOLCHAIN ?= stable WHITAKER_SCRIPT ?= $(HOME)/.local/bin/whitaker WHITAKER ?= whitaker -# Crates linted by the Whitaker suite. The rustc_* proxy shims, the lint -# crates, the aggregated suite, and the whitaker root crate all require -# rustc_private plumbing (dylint-driver feature, prefer-dynamic RUSTFLAGS) -# that `cargo dylint`'s plain check build cannot provide, so the suite runs -# over the support crates that build as ordinary libraries. -WHITAKER_PACKAGES ?= -p whitaker-common -p whitaker-installer -p whitaker_clones_core -p whitaker_sarif +# Crates linted by the Whitaker suite. The rustc_* proxy shims, lint crates, +# aggregated suite, and `whitaker_lint_core` require `rustc_private` plumbing +# that `cargo dylint`'s plain check build cannot provide. The root CLI does +# not, so the suite covers it with the ordinary support crates. +WHITAKER_PACKAGES ?= -p whitaker -p whitaker-common -p whitaker-installer -p whitaker_clones_core -p whitaker_sarif build: target/debug/$(APP) ## Build debug binary release: target/release/$(APP) ## Build release binary @@ -133,22 +135,22 @@ test: ## Run tests with warnings treated as errors WHITAKER_BACKUP=""; \ fi; \ RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked $(RUST_FLAGS)" $(CARGO) $(TEST_RUNNER) $(CARGO_LOCKED) $(TEST_CARGO_FLAGS) $(BUILD_JOBS) $(if $(NEXTEST_PROFILE),--profile $(NEXTEST_PROFILE)); \ + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(CORE_TEST_RUNNER) $(CARGO_LOCKED) $(CORE_TEST_CARGO_FLAGS) $(BUILD_JOBS) $(if $(NEXTEST_PROFILE),--profile $(NEXTEST_PROFILE)); \ RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --workspace --doc --all-features $(DOCTEST_EXCLUDES) $(BUILD_JOBS); \ + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test $(CARGO_LOCKED) $(CORE_TEST_CARGO_FLAGS) --doc $(BUILD_JOBS); \ if [ "$${ACT_WORKFLOW_TESTS:-0}" = "1" ]; then \ $(MAKE) workflow-test; \ fi coverage: ## Generate LCOV coverage over the CI-tested crate subset - @# Reuse the `test` recipe verbatim (same TEST_CARGO_FLAGS excludes, - @# same prefer-dynamic RUSTFLAGS, same WHITAKER_SCRIPT safeguard) but - @# swap the driver to `cargo llvm-cov nextest`. This keeps the - @# instrumented run in lockstep with the plain test run: the 11 - @# CI-excluded crates (rustc_* proxy shims, the whitaker root, and the - @# three lint crates whose dylint UI tests are excluded) stay excluded, - @# so coverage never attempts a bare `--workspace` build the suite - @# cannot support. + @# Instrument both test passes. The all-feature workspace pass keeps its + @# 11 exclusions (rustc_* shims, whitaker_lint_core, and three lint crates); + @# the separate feature-free pass adds core coverage to the same report. @export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; command -v cargo-llvm-cov >/dev/null || { echo "Install cargo-llvm-cov (cargo install cargo-llvm-cov)"; exit 1; } - @$(MAKE) test TEST_RUNNER="llvm-cov nextest --lcov --output-path $(COVERAGE_OUTPUT)" + @$(CARGO) llvm-cov clean --workspace + @$(MAKE) test TEST_RUNNER="llvm-cov nextest --no-report" \ + CORE_TEST_RUNNER="llvm-cov nextest --no-report --no-clean" + @$(CARGO) llvm-cov report --lcov --output-path $(COVERAGE_OUTPUT) workflow-test: workflow-test-deps ## Run opt-in GitHub workflow smoke tests with act + pytest @export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; command -v act >/dev/null || { echo "Install act to run workflow tests"; exit 1; } @@ -323,6 +325,7 @@ publish-check: ## Build, test, and validate packages before publishing rustup component add --toolchain "$$TOOLCHAIN" rust-src rustc-dev llvm-tools-preview; \ RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) build $(CARGO_LOCKED) --workspace --all-features $(BUILD_JOBS); \ RUSTFLAGS="-Z force-unstable-if-unmarked $(RUST_FLAGS)" $(CARGO) +$$TOOLCHAIN nextest run $(CARGO_LOCKED) --profile ci $(TEST_CARGO_FLAGS) $(BUILD_JOBS); \ + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) +$$TOOLCHAIN nextest run $(CARGO_LOCKED) --profile ci $(CORE_TEST_CARGO_FLAGS) $(BUILD_JOBS); \ TMP_DIR=$$(mktemp -d); \ trap 'rm -rf "$$TMP_DIR"' 0 INT TERM HUP; \ DYLINT_TOOLS_DIR="$$TMP_DIR/dylint-tools"; \ diff --git a/crates/bumpy_road_function/Cargo.toml b/crates/bumpy_road_function/Cargo.toml index 11ae9e98a..7ff5fa553 100644 --- a/crates/bumpy_road_function/Cargo.toml +++ b/crates/bumpy_road_function/Cargo.toml @@ -25,7 +25,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker", + "dep:whitaker_lint_core", ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -39,7 +39,7 @@ rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } @@ -48,7 +48,7 @@ rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } toml = { workspace = true } -whitaker = { workspace = true } +whitaker_lint_core = { workspace = true } whitaker-common = { workspace = true } camino = { workspace = true } tempfile = { workspace = true } diff --git a/crates/bumpy_road_function/src/driver/mod.rs b/crates/bumpy_road_function/src/driver/mod.rs index af679b4cb..dfa3bfc6a 100644 --- a/crates/bumpy_road_function/src/driver/mod.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -9,13 +9,13 @@ use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; use rustc_span::{Ident, Span, symbol::Symbol}; -use whitaker::SharedConfig; use whitaker_common::{ Localizer, complexity_signal::{rasterize_signal, smooth_moving_average}, get_localizer_for_lint, i18n::MessageKey, }; +use whitaker_lint_core::SharedConfig; use crate::analysis::{Settings, detect_bumps, normalize_settings}; diff --git a/crates/bumpy_road_function/tests/ui.rs b/crates/bumpy_road_function/tests/ui.rs index 02256b5b3..56a2b858e 100644 --- a/crates/bumpy_road_function/tests/ui.rs +++ b/crates/bumpy_road_function/tests/ui.rs @@ -17,14 +17,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/conditional_max_n_branches/Cargo.toml b/crates/conditional_max_n_branches/Cargo.toml index aef24cd72..7fddfe05f 100644 --- a/crates/conditional_max_n_branches/Cargo.toml +++ b/crates/conditional_max_n_branches/Cargo.toml @@ -25,7 +25,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -39,12 +39,12 @@ rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } whitaker-common = { workspace = true } -whitaker = { workspace = true } +whitaker_lint_core = { workspace = true } camino = { workspace = true } glob = "0.3.0" tempfile = "3.14.0" diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index ba7d27385..cc1a85d0d 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -14,7 +14,6 @@ use rustc_hir::{BinOpKind, ExprKind, LoopSource, UnOp}; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{DesugaringKind, Span}; use serde::Deserialize; -use whitaker::SharedConfig; use whitaker_common::{ Arguments, FALLBACK_LOCALE, @@ -26,6 +25,7 @@ use whitaker_common::{ noop_reporter, safe_resolve_message_set, }; +use whitaker_lint_core::SharedConfig; const LINT_NAME: &str = "conditional_max_n_branches"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); diff --git a/crates/conditional_max_n_branches/src/lib_ui_tests.rs b/crates/conditional_max_n_branches/src/lib_ui_tests.rs index c05dc47a6..494b3e6bd 100644 --- a/crates/conditional_max_n_branches/src/lib_ui_tests.rs +++ b/crates/conditional_max_n_branches/src/lib_ui_tests.rs @@ -12,14 +12,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/function_attrs_follow_docs/Cargo.toml b/crates/function_attrs_follow_docs/Cargo.toml index 94b5a09a6..0f175bc46 100644 --- a/crates/function_attrs_follow_docs/Cargo.toml +++ b/crates/function_attrs_follow_docs/Cargo.toml @@ -23,7 +23,7 @@ dylint-driver = [ "dep:rustc_middle", "dep:rustc_session", "dep:rustc_span", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -38,7 +38,7 @@ rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index 45285d235..38f913eb5 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -11,7 +11,6 @@ use rustc_hir as hir; use rustc_hir::attrs::AttributeKind; use rustc_lint::{DiagDecorator, LateContext, LateLintPass, LintContext}; use rustc_span::Span; -use whitaker::{SharedConfig, recover_user_editable_hir_span}; use whitaker_common::i18n::{ Arguments, BundleLookup, @@ -26,6 +25,7 @@ use whitaker_common::i18n::{ }; #[cfg(test)] use whitaker_common::i18n::{I18nError, resolve_message_set}; +use whitaker_lint_core::{SharedConfig, recover_user_editable_hir_span}; /// Lint pass that validates the ordering of doc comments on functions and methods. pub struct FunctionAttrsFollowDocs { diff --git a/crates/function_attrs_follow_docs/src/tests/ui.rs b/crates/function_attrs_follow_docs/src/tests/ui.rs index b0c391319..3e9f93274 100644 --- a/crates/function_attrs_follow_docs/src/tests/ui.rs +++ b/crates/function_attrs_follow_docs/src/tests/ui.rs @@ -18,6 +18,7 @@ fn ui_runs_in_welsh_locale() { run_ui_with_locale("ui-cy", Some("cy")); } fn run_ui_with_locale(directory: &str, locale: Option<&str>) { with_locale(locale, || { - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + whitaker_lint_core::run_ui_tests!(directory) + .expect("UI tests should execute without diffs"); }); } diff --git a/crates/module_max_lines/Cargo.toml b/crates/module_max_lines/Cargo.toml index 9a3c2b456..6d3f7c2c7 100644 --- a/crates/module_max_lines/Cargo.toml +++ b/crates/module_max_lines/Cargo.toml @@ -18,7 +18,7 @@ dylint-driver = [ "dep:rustc_lint", "dep:rustc_session", "dep:rustc_span", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -30,7 +30,7 @@ rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } fluent-templates = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index 6b150debe..5018ca467 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -8,7 +8,6 @@ use log::debug; use rustc_hir as hir; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{Span, source_map::SourceMap, symbol::Ident}; -use whitaker::{ModuleMaxLinesConfig, SharedConfig, module_body_span, module_header_span}; use whitaker_common::i18n::{ Arguments, DiagnosticMessageSet, @@ -19,6 +18,12 @@ use whitaker_common::i18n::{ noop_reporter, safe_resolve_message_set, }; +use whitaker_lint_core::{ + ModuleMaxLinesConfig, + SharedConfig, + module_body_span, + module_header_span, +}; const LINT_NAME: &str = "module_max_lines"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("module_max_lines"); diff --git a/crates/module_max_lines/src/lib_ui_tests.rs b/crates/module_max_lines/src/lib_ui_tests.rs index 62e4989e4..ed6e659d1 100644 --- a/crates/module_max_lines/src/lib_ui_tests.rs +++ b/crates/module_max_lines/src/lib_ui_tests.rs @@ -12,14 +12,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/module_must_have_inner_docs/Cargo.toml b/crates/module_must_have_inner_docs/Cargo.toml index b8c11ea9f..c69acdc7a 100644 --- a/crates/module_must_have_inner_docs/Cargo.toml +++ b/crates/module_must_have_inner_docs/Cargo.toml @@ -17,7 +17,7 @@ dylint-driver = [ "dep:rustc_lint", "dep:rustc_session", "dep:rustc_span", - "dep:whitaker", + "dep:whitaker_lint_core", ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -30,7 +30,7 @@ rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } newt-hype = "0.2" [dev-dependencies] diff --git a/crates/module_must_have_inner_docs/src/driver/mod.rs b/crates/module_must_have_inner_docs/src/driver/mod.rs index 6b7868360..8839e5d1f 100644 --- a/crates/module_must_have_inner_docs/src/driver/mod.rs +++ b/crates/module_must_have_inner_docs/src/driver/mod.rs @@ -14,7 +14,6 @@ use rustc_lint::{LateContext, LateLintPass, LintContext}; #[cfg(test)] use rustc_span::DUMMY_SP; use rustc_span::{BytePos, Span, source_map::SourceMap, symbol::Ident}; -use whitaker::{SharedConfig, module_body_span, module_header_span}; use whitaker_common::i18n::{ Arguments, DiagnosticMessageSet, @@ -26,6 +25,7 @@ use whitaker_common::i18n::{ noop_reporter, safe_resolve_message_set, }; +use whitaker_lint_core::{SharedConfig, module_body_span, module_header_span}; mod inner_attr; mod parser; diff --git a/crates/module_must_have_inner_docs/src/tests/ui.rs b/crates/module_must_have_inner_docs/src/tests/ui.rs index 7782bbd25..74a06ea51 100644 --- a/crates/module_must_have_inner_docs/src/tests/ui.rs +++ b/crates/module_must_have_inner_docs/src/tests/ui.rs @@ -28,6 +28,7 @@ use whitaker_common::test_support::with_locale; #[serial] fn ui_tests_across_locales(#[case] directory: &str, #[case] locale: Option<&str>) { with_locale(locale, || { - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + whitaker_lint_core::run_ui_tests!(directory) + .expect("UI tests should execute without diffs"); }); } diff --git a/crates/no_expect_outside_tests/Cargo.toml b/crates/no_expect_outside_tests/Cargo.toml index 4453452f5..00632205d 100644 --- a/crates/no_expect_outside_tests/Cargo.toml +++ b/crates/no_expect_outside_tests/Cargo.toml @@ -19,7 +19,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -32,7 +32,7 @@ rustc_middle = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } serde = { version = "1.0", features = ["derive"], optional = true } log = { workspace = true, optional = true } diff --git a/crates/no_expect_outside_tests/src/context/mod.rs b/crates/no_expect_outside_tests/src/context/mod.rs index 367b25a6f..095c94a51 100644 --- a/crates/no_expect_outside_tests/src/context/mod.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -10,7 +10,6 @@ use rustc_hir as hir; use rustc_hir::{Node, attrs::AttributeKind as HirAttributeKind}; use rustc_lint::LateContext; use rustc_span::sym; -use whitaker::hir::has_test_like_hir_attributes; use whitaker_common::{ Attribute, AttributeKind, @@ -20,6 +19,7 @@ use whitaker_common::{ PARSED_ATTRIBUTE_PLACEHOLDER, in_test_like_context_with, }; +use whitaker_lint_core::hir::has_test_like_hir_attributes; #[derive(Default, Debug, Clone, PartialEq, Eq)] pub(crate) struct ContextSummary { diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 756dca676..209a14bfd 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -17,8 +17,8 @@ use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, Ty}; use rustc_span::{RemapPathScopeComponents, sym}; use serde::Deserialize; -use whitaker::{SharedConfig, hir::has_test_like_hir_attributes}; use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint}; +use whitaker_lint_core::{SharedConfig, hir::has_test_like_hir_attributes}; use crate::{ context::{collect_context, is_cfg_test_attribute, summarize_context}, @@ -85,8 +85,8 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { .is_some(); self.is_test_harness = cx.tcx.sess.opts.test; self.harness_marked_test_functions = if self.is_test_harness { - let mut marked = whitaker::hir::collect_harness_test_functions(cx); - marked.extend(whitaker::hir::collect_rstest_companion_test_functions(cx)); + let mut marked = whitaker_lint_core::hir::collect_harness_test_functions(cx); + marked.extend(whitaker_lint_core::hir::collect_rstest_companion_test_functions(cx)); marked } else { HashSet::new() diff --git a/crates/no_expect_outside_tests/src/lib.rs b/crates/no_expect_outside_tests/src/lib.rs index 2d3372fb9..cfe67ab69 100644 --- a/crates/no_expect_outside_tests/src/lib.rs +++ b/crates/no_expect_outside_tests/src/lib.rs @@ -22,7 +22,7 @@ mod lib_ui_tests; mod tests; #[cfg(all(feature = "dylint-driver", test))] mod ui { - whitaker::declare_ui_tests!("ui"); + whitaker_lint_core::declare_ui_tests!("ui"); } // Re-export only the documented lint surface. `impl_late_lint!` also expands diff --git a/crates/no_expect_outside_tests/src/lib_ui_tests.rs b/crates/no_expect_outside_tests/src/lib_ui_tests.rs index a642015fb..5a93792b6 100644 --- a/crates/no_expect_outside_tests/src/lib_ui_tests.rs +++ b/crates/no_expect_outside_tests/src/lib_ui_tests.rs @@ -84,7 +84,7 @@ struct DependencyRlib { fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, |_, _| { run_test_runner(spec.name, || { let _guard = env_test_guard(); with_vars_unset( @@ -153,7 +153,7 @@ fn run_fixture_under_test_harness( fn run_fixture_harness_test(spec: &FixtureHarnessRun<'_>) { let crate_name = spec.crate_name; let directory = spec.directory; - whitaker::testing::ui::run_with_runner(crate_name, directory, |_, dir| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, |_, dir| { run_fixture_under_test_harness(spec, dir) }) .unwrap_or_else(|error| { diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index a979d087e..9ba620e32 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -18,7 +18,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -32,7 +32,7 @@ rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } log = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index bf339cb1d..ee98afd9b 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -7,11 +7,11 @@ use rustc_hir::AmbigArg; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty; use rustc_span::{Span, sym}; -use whitaker::SharedConfig; use whitaker_common::{ SimplePath, i18n::{Localizer, get_localizer_for_lint}, }; +use whitaker_lint_core::SharedConfig; use crate::{ config::{LINT_NAME, load_configuration}, diff --git a/crates/no_std_fs_operations/src/tests/ui.rs b/crates/no_std_fs_operations/src/tests/ui.rs index b0e05e715..aea61ca5a 100644 --- a/crates/no_std_fs_operations/src/tests/ui.rs +++ b/crates/no_std_fs_operations/src/tests/ui.rs @@ -21,6 +21,7 @@ fn ui_runs_in_fallback_locale() { run_with_locale("ui-fallback", Some("zz")); } fn run_with_locale(directory: &str, locale: Option<&str>) { with_locale(locale, || { - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + whitaker_lint_core::run_ui_tests!(directory) + .expect("UI tests should execute without diffs"); }); } diff --git a/crates/no_unwrap_or_else_panic/Cargo.toml b/crates/no_unwrap_or_else_panic/Cargo.toml index c390d3117..693af17da 100644 --- a/crates/no_unwrap_or_else_panic/Cargo.toml +++ b/crates/no_unwrap_or_else_panic/Cargo.toml @@ -20,7 +20,7 @@ dylint-driver = [ "dep:rustc_middle", "dep:rustc_span", "dep:serde", - "dep:whitaker", + "dep:whitaker_lint_core", ] constituent = ["dylint-driver", "dylint_linting/constituent"] clippy = ["dylint-driver", "dep:clippy_utils"] @@ -35,7 +35,7 @@ rustc_middle = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } rustc_ast = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } clippy_utils = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index 920d53589..780bbc848 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -7,8 +7,8 @@ use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; use serde::Deserialize; -use whitaker::SharedConfig; use whitaker_common::i18n::{Localizer, get_localizer_for_lint}; +use whitaker_lint_core::SharedConfig; use crate::{ LINT_NAME, @@ -83,8 +83,8 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { self.is_test_harness = cx.tcx.sess.opts.test; self.harness_test_functions = if self.is_test_harness { - let mut marked = whitaker::hir::collect_harness_test_functions(cx); - marked.extend(whitaker::hir::collect_rstest_companion_test_functions(cx)); + let mut marked = whitaker_lint_core::hir::collect_harness_test_functions(cx); + marked.extend(whitaker_lint_core::hir::collect_rstest_companion_test_functions(cx)); marked } else { HashSet::new() diff --git a/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs b/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs index 3783215a0..5f3a136f9 100644 --- a/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs +++ b/crates/no_unwrap_or_else_panic/src/lib_ui_tests.rs @@ -53,14 +53,13 @@ impl<'a> ExampleHarnessRun<'a> { fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } /// Runs an example-based regression under the dylint UI test harness. @@ -71,7 +70,7 @@ fn ui() { fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, |_, _| { run_test_runner(spec.name, || { let _guard = env_test_guard(); with_vars_unset( diff --git a/crates/rstest_helper_should_be_fixture/Cargo.toml b/crates/rstest_helper_should_be_fixture/Cargo.toml index e44bb18df..1bc4c6bae 100644 --- a/crates/rstest_helper_should_be_fixture/Cargo.toml +++ b/crates/rstest_helper_should_be_fixture/Cargo.toml @@ -25,7 +25,7 @@ dylint-driver = [ "dep:rustc_session", "dep:rustc_span", "dep:serde", - "dep:whitaker", + "dep:whitaker_lint_core", "dep:camino", "dep:cap-std", ] @@ -43,7 +43,7 @@ rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } rustc_span = { workspace = true, optional = true } serde = { workspace = true, optional = true } -whitaker = { workspace = true, features = ["dylint-driver"], optional = true } +whitaker_lint_core = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] dylint_testing = { workspace = true } diff --git a/crates/rstest_helper_should_be_fixture/src/collector.rs b/crates/rstest_helper_should_be_fixture/src/collector.rs index f9dea57bd..9de9350e4 100644 --- a/crates/rstest_helper_should_be_fixture/src/collector.rs +++ b/crates/rstest_helper_should_be_fixture/src/collector.rs @@ -235,7 +235,7 @@ pub(crate) fn lower_arg_atom<'tcx>( } fn should_skip_arg_for_unrecoverable_span(span: Span) -> bool { - whitaker::hir::recover_user_editable_hir_span(span).is_none() + whitaker_lint_core::hir::recover_user_editable_hir_span(span).is_none() } fn lower_path_arg<'tcx>( diff --git a/crates/rstest_helper_should_be_fixture/src/driver.rs b/crates/rstest_helper_should_be_fixture/src/driver.rs index f5b4e4ae4..31440381d 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver.rs @@ -17,12 +17,12 @@ use rustc_hir::{def_id::LocalDefId, intravisit::Visitor}; use rustc_lint::{LateContext, LateLintPass}; use rustc_span::Span; use serde::Deserialize; -use whitaker::SharedConfig; use whitaker_common::{ attributes::AttributePath, i18n::{Localizer, get_localizer_for_lint}, rstest::{RstestDetectionOptions, is_rstest_test_with}, }; +use whitaker_lint_core::SharedConfig; use crate::{ collector::CallSiteCollector, @@ -226,7 +226,8 @@ impl RstestHelperShouldBeFixture { impl<'tcx> LateLintPass<'tcx> for RstestHelperShouldBeFixture { fn check_crate(&mut self, cx: &LateContext<'tcx>) { self.apply_loaded_crate_configuration(load_configuration(), &load_shared_config()); - self.rstest_collection_roots = whitaker::hir::collect_rstest_companion_test_functions(cx); + self.rstest_collection_roots = + whitaker_lint_core::hir::collect_rstest_companion_test_functions(cx); } fn check_fn( diff --git a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs index 843f8d26b..8aa2f30c5 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs @@ -6,7 +6,7 @@ use proptest::prelude::*; use rstest::rstest; -use whitaker::SharedConfig; +use whitaker_lint_core::SharedConfig; use super::*; diff --git a/crates/rstest_helper_should_be_fixture/src/visitor.rs b/crates/rstest_helper_should_be_fixture/src/visitor.rs index de7b835c3..3f151bd32 100644 --- a/crates/rstest_helper_should_be_fixture/src/visitor.rs +++ b/crates/rstest_helper_should_be_fixture/src/visitor.rs @@ -126,11 +126,13 @@ impl<'tcx> Visitor<'tcx> for CallSiteVisitor<'_, 'tcx> { impl CallSiteVisitor<'_, '_> { fn recover_call_span(&self, span: Span) -> Option { - whitaker::hir::recover_user_editable_hir_span(span).or_else(|| { + whitaker_lint_core::hir::recover_user_editable_hir_span(span).or_else(|| { self.closure_span_fallbacks .iter() .rev() - .find_map(|fallback| whitaker::hir::recover_user_editable_hir_span(*fallback)) + .find_map(|fallback| { + whitaker_lint_core::hir::recover_user_editable_hir_span(*fallback) + }) }) } } diff --git a/crates/rstest_helper_should_be_fixture/tests/ui.rs b/crates/rstest_helper_should_be_fixture/tests/ui.rs index 25c39ca4a..e17e6ac75 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui.rs @@ -94,13 +94,17 @@ impl ExampleHarness { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; let lock_path = self.lock.path().display(); - whitaker::testing::ui::run_with_runner(crate_name, directory, |runner_crate, _| { - run_test_runner(example, || { - let mut test = Test::example(runner_crate, example); - test.rustc_flags(["--test"]); - test.run(); - }) - }) + whitaker_lint_core::testing::ui::run_with_runner( + crate_name, + directory, + |runner_crate, _| { + run_test_runner(example, || { + let mut test = Test::example(runner_crate, example); + test.rustc_flags(["--test"]); + test.run(); + }) + }, + ) .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ diff --git a/crates/test_must_not_have_example/Cargo.toml b/crates/test_must_not_have_example/Cargo.toml index 492398ecf..92d4c646b 100644 --- a/crates/test_must_not_have_example/Cargo.toml +++ b/crates/test_must_not_have_example/Cargo.toml @@ -19,7 +19,7 @@ dylint-driver = [ "dep:rustc_lint", "dep:rustc_span", "dep:serde", - "dep:whitaker" + "dep:whitaker_lint_core" ] constituent = ["dylint-driver", "dylint_linting/constituent"] @@ -31,7 +31,7 @@ rustc_span = { workspace = true, optional = true } whitaker-common = { workspace = true, optional = true } serde = { version = "1.0", features = ["derive"], optional = true } log = { workspace = true, optional = true } -whitaker = { version = "0.2.7", path = "../../", features = ["dylint-driver"], optional = true } +whitaker_lint_core = { version = "0.2.7", path = "../whitaker_lint_core", features = ["dylint-driver"], optional = true } [dev-dependencies] whitaker_test_macros = { workspace = true } diff --git a/crates/test_must_not_have_example/src/driver.rs b/crates/test_must_not_have_example/src/driver.rs index dc46e3ae3..1f4629740 100644 --- a/crates/test_must_not_have_example/src/driver.rs +++ b/crates/test_must_not_have_example/src/driver.rs @@ -8,7 +8,6 @@ use rustc_hir::Node; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{Ident, Span, Symbol}; use serde::Deserialize; -use whitaker::{SharedConfig, hir::has_test_like_hir_attributes}; use whitaker_common::{ AttributePath, i18n::{ @@ -23,6 +22,7 @@ use whitaker_common::{ safe_resolve_message_set, }, }; +use whitaker_lint_core::{SharedConfig, hir::has_test_like_hir_attributes}; use crate::heuristics::{DocExampleViolation, detect_example_violation}; diff --git a/crates/test_must_not_have_example/src/lib_ui_tests.rs b/crates/test_must_not_have_example/src/lib_ui_tests.rs index a97121d88..ed503679a 100644 --- a/crates/test_must_not_have_example/src/lib_ui_tests.rs +++ b/crates/test_must_not_have_example/src/lib_ui_tests.rs @@ -10,14 +10,13 @@ use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, run_fixtures).unwrap_or_else( - |error| { + whitaker_lint_core::testing::ui::run_with_runner(crate_name, directory, run_fixtures) + .unwrap_or_else(|error| { panic!( "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ \"{crate_name}\", directory: \"{directory}\", message: {error} }}" ) - }, - ); + }); } fn run_fixtures(crate_name: &str, directory: &Utf8Path) -> Result<(), String> { diff --git a/crates/whitaker_lint_core/Cargo.toml b/crates/whitaker_lint_core/Cargo.toml new file mode 100644 index 000000000..4f96bbaa8 --- /dev/null +++ b/crates/whitaker_lint_core/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "whitaker_lint_core" +version = "0.2.7" +edition = "2024" +publish = false + +[features] +default = [] +dylint-driver = [ + "dep:dylint_linting", + "dep:rustc_ast", + "dep:rustc_hir", + "dep:rustc_lint", + "dep:rustc_span", +] + +[dependencies] +camino = { workspace = true } +cargo_metadata = { workspace = true } +clap = { workspace = true, features = ["derive"] } +dylint_linting = { workspace = true, optional = true } +rustc_ast = { workspace = true, optional = true } +rustc_hir = { workspace = true, optional = true } +rustc_lint = { workspace = true, optional = true } +rustc_span = { workspace = true, optional = true } +serde = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +whitaker-common = { workspace = true } + +[dev-dependencies] +dylint_testing = { workspace = true } +rstest = { workspace = true } +rstest-bdd = { workspace = true } +rstest-bdd-macros = { workspace = true } +whitaker-common = { workspace = true } +whitaker_test_macros = { workspace = true } + +[lints] +workspace = true diff --git a/src/config.rs b/crates/whitaker_lint_core/src/config.rs similarity index 98% rename from src/config.rs rename to crates/whitaker_lint_core/src/config.rs index 0314448f1..9bb86f011 100644 --- a/src/config.rs +++ b/crates/whitaker_lint_core/src/config.rs @@ -40,7 +40,7 @@ impl SharedConfig { /// ``` /// # #[cfg(feature = "dylint-driver")] /// # { - /// use whitaker::SharedConfig; + /// use whitaker_lint_core::SharedConfig; /// /// let config = SharedConfig::load(); /// assert_eq!(config.module_max_lines.max_lines, 400); @@ -74,7 +74,7 @@ impl SharedConfig { /// # Examples /// /// ``` - /// use whitaker::SharedConfig; + /// use whitaker_lint_core::SharedConfig; /// /// let config = SharedConfig::load_with("whitaker", |_| SharedConfig::default()); /// assert_eq!(config.module_max_lines.max_lines, 400); diff --git a/src/hir/mod.rs b/crates/whitaker_lint_core/src/hir/mod.rs similarity index 100% rename from src/hir/mod.rs rename to crates/whitaker_lint_core/src/hir/mod.rs diff --git a/src/hir/tests.rs b/crates/whitaker_lint_core/src/hir/tests.rs similarity index 100% rename from src/hir/tests.rs rename to crates/whitaker_lint_core/src/hir/tests.rs diff --git a/crates/whitaker_lint_core/src/lib.rs b/crates/whitaker_lint_core/src/lib.rs new file mode 100644 index 000000000..cb69408bf --- /dev/null +++ b/crates/whitaker_lint_core/src/lib.rs @@ -0,0 +1,30 @@ +//! Shared configuration and helpers for Whitaker Dylint lint crates. +#![cfg_attr(feature = "dylint-driver", feature(rustc_private))] + +// Link against `rustc_driver` only when consumers need the dylint driver runtime. +// Unit tests of this crate should not pull the compiler driver to avoid the +// duplicated `std`/`core` link errors seen during all-features test runs. +#[cfg(feature = "dylint-driver")] +extern crate rustc_data_structures; +#[cfg(all(feature = "dylint-driver", not(test)))] +extern crate rustc_driver; + +pub mod config; +#[cfg(feature = "dylint-driver")] +pub mod hir; +pub mod lints; +pub mod testing; + +pub use config::{ModuleMaxLinesConfig, SharedConfig}; +#[cfg(feature = "dylint-driver")] +pub use hir::{ + module_body_span, + module_header_span, + recover_user_editable_hir_span, + span_recovery_frames, +}; +pub use lints::{LintCrateTemplate, TemplateError, TemplateFiles}; + +/// Returns a greeting for the library. +#[must_use] +pub const fn greet() -> &'static str { "Hello from Whitaker!" } diff --git a/src/lints/mod.rs b/crates/whitaker_lint_core/src/lints/mod.rs similarity index 100% rename from src/lints/mod.rs rename to crates/whitaker_lint_core/src/lints/mod.rs diff --git a/src/lints/template/content.rs b/crates/whitaker_lint_core/src/lints/template/content.rs similarity index 89% rename from src/lints/template/content.rs rename to crates/whitaker_lint_core/src/lints/template/content.rs index 3e0fa160f..1366502c6 100644 --- a/src/lints/template/content.rs +++ b/crates/whitaker_lint_core/src/lints/template/content.rs @@ -18,7 +18,7 @@ rustc_span = { workspace = true } whitaker-common = { path = "../../common" } [dev-dependencies] -whitaker = { path = "../../" } +whitaker_lint_core = { path = "../whitaker_lint_core" } "#; const LIB_RS_TEMPLATE: &str = r#"//! Lint crate for `{crate_name}`. @@ -58,7 +58,7 @@ impl_late_lint! { #[cfg(test)] mod tests { - whitaker::declare_ui_tests!("{ui_tests_directory}"); + whitaker_lint_core::declare_ui_tests!("{ui_tests_directory}"); } "#; @@ -150,7 +150,9 @@ mod tests { #[test] fn render_lib_rs_escapes_ui_directory() { let rendered = render_lib_rs("demo_lint", "DEMO_LINT", "DemoLint", "ui/space \"quote\""); - assert!(rendered.contains(r#"whitaker::declare_ui_tests!("ui/space \"quote\"");"#)); + assert!( + rendered.contains(r#"whitaker_lint_core::declare_ui_tests!("ui/space \"quote\"");"#) + ); } #[test] @@ -161,7 +163,10 @@ mod tests { "DemoLint", "ui/wave\\multiline\ncase", ); - assert!(rendered.contains(r#"whitaker::declare_ui_tests!("ui/wave\\multiline\ncase");"#)); + assert!( + rendered + .contains(r#"whitaker_lint_core::declare_ui_tests!("ui/wave\\multiline\ncase");"#) + ); } #[test] @@ -169,7 +174,7 @@ mod tests { let directory = "ui/\"outer 'inner'\""; let rendered = render_lib_rs("demo_lint", "DEMO_LINT", "DemoLint", directory); let expected = format!( - "whitaker::declare_ui_tests!(\"{}\");", + "whitaker_lint_core::declare_ui_tests!(\"{}\");", escape_rust_string_literal(directory) ); assert!(rendered.contains(expected.as_str())); @@ -178,6 +183,6 @@ mod tests { #[test] fn render_lib_rs_handles_empty_ui_directory() { let rendered = render_lib_rs("demo_lint", "DEMO_LINT", "DemoLint", ""); - assert!(rendered.contains(r#"whitaker::declare_ui_tests!("");"#)); + assert!(rendered.contains(r#"whitaker_lint_core::declare_ui_tests!("");"#)); } } diff --git a/src/lints/template/mod.rs b/crates/whitaker_lint_core/src/lints/template/mod.rs similarity index 97% rename from src/lints/template/mod.rs rename to crates/whitaker_lint_core/src/lints/template/mod.rs index 4cf6db5ea..34bd3b916 100644 --- a/src/lints/template/mod.rs +++ b/crates/whitaker_lint_core/src/lints/template/mod.rs @@ -25,7 +25,7 @@ impl TemplateFiles { /// # Examples /// /// ``` - /// use whitaker::lints::LintCrateTemplate; + /// use whitaker_lint_core::lints::LintCrateTemplate; /// /// let files = LintCrateTemplate::new("demo_lint") /// .expect("valid crate name") @@ -41,7 +41,7 @@ impl TemplateFiles { /// # Examples /// /// ``` - /// use whitaker::lints::LintCrateTemplate; + /// use whitaker_lint_core::lints::LintCrateTemplate; /// /// let files = LintCrateTemplate::new("demo_lint") /// .expect("valid crate name") @@ -61,7 +61,7 @@ impl TemplateFiles { /// # Examples /// /// ``` - /// use whitaker::lints::LintCrateTemplate; + /// use whitaker_lint_core::lints::LintCrateTemplate; /// /// let files = LintCrateTemplate::new("demo_lint") /// .expect("valid crate name") @@ -271,7 +271,7 @@ mod tests { assert!( files .lib_rs() - .contains("whitaker::declare_ui_tests!(\"ui\");") + .contains("whitaker_lint_core::declare_ui_tests!(\"ui\");") ); } } diff --git a/src/lints/template/validation.rs b/crates/whitaker_lint_core/src/lints/template/validation.rs similarity index 100% rename from src/lints/template/validation.rs rename to crates/whitaker_lint_core/src/lints/template/validation.rs diff --git a/src/testing/mod.rs b/crates/whitaker_lint_core/src/testing/mod.rs similarity index 100% rename from src/testing/mod.rs rename to crates/whitaker_lint_core/src/testing/mod.rs diff --git a/src/testing/ui/mod.rs b/crates/whitaker_lint_core/src/testing/ui/mod.rs similarity index 97% rename from src/testing/ui/mod.rs rename to crates/whitaker_lint_core/src/testing/ui/mod.rs index e786a7d2d..af4891dbf 100644 --- a/src/testing/ui/mod.rs +++ b/crates/whitaker_lint_core/src/testing/ui/mod.rs @@ -130,9 +130,9 @@ impl std::error::Error for HarnessError {} /// /// ```no_run /// use camino::Utf8Path; -/// use whitaker::testing::ui::run_with_runner; +/// use whitaker_lint_core::testing::ui::run_with_runner; /// -/// fn main() -> Result<(), whitaker::testing::ui::HarnessError> { +/// fn main() -> Result<(), whitaker_lint_core::testing::ui::HarnessError> { /// run_with_runner("my_lint", "ui", |crate_name, dir: &Utf8Path| { /// ::dylint_testing::ui_test(crate_name, dir); /// Ok(()) @@ -235,7 +235,7 @@ fn directory_is_rooted(path: &Utf8Path) -> bool { /// # Examples /// /// ```ignore -/// whitaker::run_ui_tests!("ui").expect("UI tests should succeed"); +/// whitaker_lint_core::run_ui_tests!("ui").expect("UI tests should succeed"); /// ``` /// /// # Errors @@ -266,7 +266,7 @@ macro_rules! run_ui_tests { /// # Examples /// /// ```ignore -/// whitaker::declare_ui_tests!("ui"); +/// whitaker_lint_core::declare_ui_tests!("ui"); /// ``` #[macro_export] macro_rules! declare_ui_tests { diff --git a/src/testing/ui/tests.rs b/crates/whitaker_lint_core/src/testing/ui/tests.rs similarity index 100% rename from src/testing/ui/tests.rs rename to crates/whitaker_lint_core/src/testing/ui/tests.rs diff --git a/src/testing/ui/toolchain.rs b/crates/whitaker_lint_core/src/testing/ui/toolchain.rs similarity index 100% rename from src/testing/ui/toolchain.rs rename to crates/whitaker_lint_core/src/testing/ui/toolchain.rs diff --git a/crates/whitaker_lint_core/tests/build_config.rs b/crates/whitaker_lint_core/tests/build_config.rs new file mode 100644 index 000000000..011b8b73d --- /dev/null +++ b/crates/whitaker_lint_core/tests/build_config.rs @@ -0,0 +1,27 @@ +//! Build configuration guards for scoped dynamic-linking expectations. + +use std::fs; + +use toml::Value; + +mod workspace_support; + +use workspace_support::workspace_root; + +#[test] +fn cargo_config_keeps_dynamic_linking_out_of_workspace_configuration() { + let config_path = workspace_root().join(".cargo/config.toml"); + let contents = fs::read_to_string(&config_path) + .unwrap_or_else(|err| panic!("failed to read {config_path:?}: {err}")); + let value: Value = toml::from_str(&contents).expect("cargo config should parse as TOML table"); + + let rustflags = value + .get("build") + .and_then(|table| table.get("rustflags")) + .and_then(Value::as_array); + + assert!( + rustflags.is_none(), + "dynamic linker flags belong to the Dylint-aware Make recipes, not workspace configuration" + ); +} diff --git a/tests/config_loading.rs b/crates/whitaker_lint_core/tests/config_loading.rs similarity index 99% rename from tests/config_loading.rs rename to crates/whitaker_lint_core/tests/config_loading.rs index 9740a6375..9e584bf86 100644 --- a/tests/config_loading.rs +++ b/crates/whitaker_lint_core/tests/config_loading.rs @@ -13,8 +13,8 @@ mod support; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; -use whitaker::SharedConfig; use whitaker_common::i18n::normalize_locale; +use whitaker_lint_core::SharedConfig; #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] diff --git a/tests/features/config_loading.feature b/crates/whitaker_lint_core/tests/features/config_loading.feature similarity index 100% rename from tests/features/config_loading.feature rename to crates/whitaker_lint_core/tests/features/config_loading.feature diff --git a/tests/features/lint_template.feature b/crates/whitaker_lint_core/tests/features/lint_template.feature similarity index 100% rename from tests/features/lint_template.feature rename to crates/whitaker_lint_core/tests/features/lint_template.feature diff --git a/tests/features/locale_resolution.feature b/crates/whitaker_lint_core/tests/features/locale_resolution.feature similarity index 100% rename from tests/features/locale_resolution.feature rename to crates/whitaker_lint_core/tests/features/locale_resolution.feature diff --git a/tests/features/ui_harness.feature b/crates/whitaker_lint_core/tests/features/ui_harness.feature similarity index 100% rename from tests/features/ui_harness.feature rename to crates/whitaker_lint_core/tests/features/ui_harness.feature diff --git a/tests/lint_template.rs b/crates/whitaker_lint_core/tests/lint_template.rs similarity index 95% rename from tests/lint_template.rs rename to crates/whitaker_lint_core/tests/lint_template.rs index 935183ec6..c98dca853 100644 --- a/tests/lint_template.rs +++ b/crates/whitaker_lint_core/tests/lint_template.rs @@ -5,7 +5,7 @@ use std::cell::RefCell; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use toml::Value; -use whitaker::lints::{LintCrateTemplate, TemplateError, TemplateFiles}; +use whitaker_lint_core::lints::{LintCrateTemplate, TemplateError, TemplateFiles}; #[derive(Debug, Default)] struct TemplateWorld { @@ -167,23 +167,23 @@ fn then_manifest_reuses_shared_dependencies(world: &TemplateWorld) { panic!("dev-dependencies table should exist"); }; - let Some(whitaker) = dev_dependencies.get("whitaker").and_then(Value::as_table) else { - panic!("whitaker dev-dependency should exist"); + let Some(core) = dev_dependencies + .get("whitaker_lint_core") + .and_then(Value::as_table) + else { + panic!("whitaker_lint_core dev-dependency should exist"); }; - let dev_path = whitaker - .get("path") - .and_then(Value::as_str) - .unwrap_or_default(); + let dev_path = core.get("path").and_then(Value::as_str).unwrap_or_default(); - assert_eq!(dev_path, "../../"); + assert_eq!(dev_path, "../whitaker_lint_core"); } #[then("the library includes UI test harness boilerplate for directory {directory}")] fn then_library_includes_harness(world: &TemplateWorld, directory: StepString) { let files = world.files(); let directory_value = directory.into_inner(); - let expected = format!("whitaker::declare_ui_tests!(\"{directory_value}\");"); + let expected = format!("whitaker_lint_core::declare_ui_tests!(\"{directory_value}\");"); assert!(files.lib_rs().contains(expected.as_str())); } diff --git a/tests/locale_resolution.rs b/crates/whitaker_lint_core/tests/locale_resolution.rs similarity index 100% rename from tests/locale_resolution.rs rename to crates/whitaker_lint_core/tests/locale_resolution.rs diff --git a/tests/nextest_ui_filter.rs b/crates/whitaker_lint_core/tests/nextest_ui_filter.rs similarity index 88% rename from tests/nextest_ui_filter.rs rename to crates/whitaker_lint_core/tests/nextest_ui_filter.rs index 0f748690b..8b9388f13 100644 --- a/tests/nextest_ui_filter.rs +++ b/crates/whitaker_lint_core/tests/nextest_ui_filter.rs @@ -12,14 +12,18 @@ //! `ui`, **not** `ui::ui`) and asserts that the nextest filter contains the //! clause needed to capture that pattern. -use std::{fs, path::Path}; +use std::fs; use rstest::{fixture, rstest}; use toml::Value; +mod workspace_support; + +use workspace_support::workspace_root; + /// Parses `.config/nextest.toml` into a [`Value`]. fn load_nextest_config() -> Value { - let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".config/nextest.toml"); + let config_path = workspace_root().join(".config/nextest.toml"); let contents = fs::read_to_string(&config_path) .unwrap_or_else(|err| panic!("failed to read {}: {err}", config_path.display())); toml::from_str(&contents) @@ -62,7 +66,7 @@ fn extract_filter(ui_override: &Value) -> &str { /// name of `ui`. The substring match `test(ui::ui)` does **not** capture /// them because the reported test name is plain `ui`, not `ui::ui`. fn crates_with_integration_ui_test() -> Vec { - let crates_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("crates"); + let crates_dir = workspace_root().join("crates"); let entries = fs::read_dir(&crates_dir) .unwrap_or_else(|err| panic!("failed to read {}: {err}", crates_dir.display())); @@ -106,12 +110,16 @@ fn serial_dylint_ui_filter_captures_integration_ui_binaries(serial_dylint_ui_ove "expected at least one lint crate with tests/ui.rs" ); - // The integration-test pattern produces binary=ui, test=ui. The filter - // must contain a clause that matches this pattern — historically - // `test(ui::ui)` missed it because the test name is plain `ui`. + // The integration-test pattern produces binary=ui, test=ui. The filter + // must constrain that test to the `ui` binary — historically + // `test(ui::ui)` missed it because the test name is plain `ui`. The binary + // clause may group this exact test with other tests from the same binary. + let matches_ui_binary_test = filter.contains("(binary(ui) & test(=ui))") + || filter.contains("(binary(ui) & (test(=ui) | "); + assert!( - filter.contains("(binary(ui) & test(=ui))"), - "the serial-dylint-ui filter must contain `(binary(ui) & test(=ui))` to capture \ + matches_ui_binary_test, + "the serial-dylint-ui filter must constrain `test(=ui)` to the `ui` binary to capture \ integration test binaries named `ui` with a top-level `fn ui()` (e.g. {crates:?}); found \ filter: {filter}" ); diff --git a/tests/support/locale.rs b/crates/whitaker_lint_core/tests/support/locale.rs similarity index 100% rename from tests/support/locale.rs rename to crates/whitaker_lint_core/tests/support/locale.rs diff --git a/tests/support/mod.rs b/crates/whitaker_lint_core/tests/support/mod.rs similarity index 99% rename from tests/support/mod.rs rename to crates/whitaker_lint_core/tests/support/mod.rs index 2149af04e..8cb1effcb 100644 --- a/tests/support/mod.rs +++ b/crates/whitaker_lint_core/tests/support/mod.rs @@ -5,4 +5,5 @@ //! into the configuration and resolution flow. Reach for these utilities //! whenever a test needs to normalize locale input before exercising the i18n //! layer or verifying translation behaviour. + pub mod locale; diff --git a/tests/ui_harness.rs b/crates/whitaker_lint_core/tests/ui_harness.rs similarity index 98% rename from tests/ui_harness.rs rename to crates/whitaker_lint_core/tests/ui_harness.rs index ae8b5ab0d..8f1348973 100644 --- a/tests/ui_harness.rs +++ b/crates/whitaker_lint_core/tests/ui_harness.rs @@ -5,7 +5,7 @@ use std::{cell::RefCell, convert::Infallible}; use camino::Utf8PathBuf; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use whitaker::testing::ui::{HarnessError, run_with_runner}; +use whitaker_lint_core::testing::ui::{HarnessError, run_with_runner}; #[derive(Debug)] struct StepString(String); diff --git a/crates/whitaker_lint_core/tests/workspace_support/mod.rs b/crates/whitaker_lint_core/tests/workspace_support/mod.rs new file mode 100644 index 000000000..840812b3e --- /dev/null +++ b/crates/whitaker_lint_core/tests/workspace_support/mod.rs @@ -0,0 +1,17 @@ +//! Workspace-root lookup for repository-configuration integration tests. +//! +//! This module is included only by tests that inspect repository-level files. +//! Production inputs must be supplied explicitly by callers instead. + +use std::path::{Path, PathBuf}; + +/// Returns the workspace root for the nested core crate's integration tests. +pub fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .map_or_else( + || panic!("whitaker_lint_core must remain nested under crates/"), + Path::to_path_buf, + ) +} diff --git a/docs/adr-005-whitaker-cli-boundary.md b/docs/adr-005-whitaker-cli-boundary.md new file mode 100644 index 000000000..b9688df58 --- /dev/null +++ b/docs/adr-005-whitaker-cli-boundary.md @@ -0,0 +1,172 @@ +# Architectural decision record (ADR) 005: root Whitaker CLI boundary + +## Status + +Accepted (2026-10-17). + +## Date + +2026-10-17. + +## Context and problem statement + +Whitaker's installer currently creates a `whitaker` wrapper that invokes +`cargo dylint`, alongside a `whitaker-ls` wrapper. A Rust binary named +`whitaker` is needed to provide one command surface, but installing it beside +the existing wrapper creates a `PATH` collision. The root package also held +compiler-private Dylint driver code, preventing it from being packaged and +tested as an ordinary application. + +The command must retain existing `whitaker --all` workflows while the later +`whitaker check` command is still under development. It must reuse the +installer's established implementation without making command policy depend on +filesystem scanning, subprocess details, or installer internals. + +## Decision Drivers + +- Make the root `whitaker` package publishable and testable. +- Preserve existing installer behaviour and `cargo dylint` invocations. +- Prevent root installation from generating a script over the new executable. +- Keep the compiler-private driver out of the published dependency graph. +- Give future lazy repair in `whitaker check` a clear ownership boundary. + +## Requirements + +### Functional requirements + +- Provide `whitaker install`, `whitaker ls`, root help/version, and legacy + Dylint forwarding in roadmap item 3.5.1. +- Preserve the `whitaker-installer` executable and its current wrapper policy. +- Return the forwarded `cargo dylint` exit status to the caller. +- Keep `whitaker ls` output explicitly transitional until bundle manifests are + introduced. + +### Technical requirements + +- Keep command parsing, routing, and exit policy in the root CLI library. +- Access installation, inventory, and subprocess behaviour through ports and + system adapters. +- Keep installation orchestration in the `whitaker-installer` library. +- Ensure a root `whitaker install` does not generate a `whitaker` wrapper. + +## Options considered + +### Option A: retain the wrapper-only `whitaker` command + +Keep `whitaker` as a generated shell script and make no root binary. This +avoids a new executable but leaves the package boundary and requested command +surface unresolved. + +### Option B: give the new binary a different name + +Publish a separate name such as `whitaker-cli`. This avoids the `PATH` +collision but does not provide the requested `whitaker` command and adds a +second public product name. + +### Option C: add the root binary and make wrapper policy caller-specific + +Move compiler-private code into an internal crate, expose installer +orchestration from the installer library, and add the root binary behind +application ports. Keep the legacy installer unchanged; its root-CLI entry +point selects a policy that creates only `whitaker-ls`. Route existing Dylint +arguments to `cargo dylint` until `whitaker check` replaces that workflow. + +| Topic | Option A | Option B | Option C | +| -------------------------------------- | -------------- | -------- | ----------------------- | +| Requested `whitaker` binary | No | No | Yes | +| Existing installer compatibility | Yes | Yes | Yes | +| Avoids overwriting the root executable | Not applicable | Yes | Yes | +| Preserves `whitaker --all` | Yes | Unclear | Yes, through forwarding | +| Supports the approved package boundary | No | Partly | Yes | + +_Table 1: Comparison of executable-boundary options._ + +## Decision outcome / proposed direction + +Adopt Option C. The root package owns `whitaker`; its `src/cli/` modules own +parsing, routing, output policy, and process exit codes. `src/ports.rs` +declares installation, inventory, and Dylint-runner ports, and `src/adapters/` +supplies the production implementations. The CLI chooses whether and when an +operation runs. The installer library owns how installation and staging are +performed. + +The installer orchestration boundary is `whitaker_installer::orchestration`. +The root CLI calls `run_install_for_whitaker`, which uses the same installation +flow but does not write a `whitaker` wrapper. Inventory discovery is shared +through `whitaker_installer::list::collect_installed`; the root CLI and legacy +installer retain their own output and toolchain-display decisions. The +installer's existing public helpers remain source-compatible. + +For any invocation whose first non-global token is not `install`, `ls`, root +help, or root version, the root binary forwards all arguments after its own +executable name, in order, to `cargo dylint`. This includes `--` and the +arguments after it. The child exit status is preserved. This requirement is +`CLI-REQ-FWD`; it is derived from current user and Makefile workflows rather +than an upstream specification. It is deferred for removal until roadmap item +3.9.3, after `whitaker check` supersedes it. + +The root package depends on the installer library and root-only CLI +dependencies. The compiler-private driver remains in the non-publishable +`whitaker_lint_core` crate, so `ortho_config` and CLI dependencies do not +propagate into the cross-compiled lint crates. This boundary also makes the +future `whitaker check` lazy-repair decision belong to the CLI, while the +installer remains responsible for executing a requested repair. + +The installer records one canonical staging directory per toolchain in +`/installed_lint_paths.json` after a successful installation. +The root CLI supplies all extant recorded paths to Dylint when forwarding a +legacy invocation, preserving custom `--target-dir` installs and allowing +Dylint to choose the matching toolchain. A non-empty user-provided +`DYLINT_LIBRARY_PATH` remains authoritative. The installer owns this record's +format and updates it at the common completion point for source builds, +prebuilt downloads, and staged-suite installs. + +The root package carries cargo-binstall metadata. Publishing the crate reserves +the name `whitaker`, but GitHub release archives for that executable are not +published until roadmap item 3.5.3. Until those archives exist, +`cargo binstall` falls back to a source build. This decision does not claim +that the GitHub release workflow already packages the root executable. + +## Goals and non-goals + +- Goals: + - Provide one root executable while preserving the legacy installer. + - Keep command policy separate from installer operations. + - Preserve current Dylint invocations during the migration. +- Non-goals: + - Implement `whitaker check` or `whitaker doctor` in item 3.5.1. + - Make the current `ls` rendering a stable output contract. + - Publish GitHub binary release assets before item 3.5.3. + +## Migration plan + +1. Move compiler-private driver code to `whitaker_lint_core` and make the root + package an ordinary publishable package. +2. Move installer orchestration behind `whitaker-installer`'s library API and + preserve its existing CLI behaviour. +3. Add the root `whitaker` binary with `install`, `ls`, and legacy Dylint + forwarding. Use a root-specific wrapper policy to avoid replacing the + executable. +4. Add cargo-binstall metadata and reserve the crates.io name by publishing + the package after the local release gates pass. +5. Document the transitional command surface. Add lazy repair and the later + stable bundle and diagnostics contracts in their roadmap phases. + +## Known risks and limitations + +- An older generated `whitaker` script can still take precedence if its + directory appears earlier on `PATH` than Cargo's binary directory. The root + installer does not create or overwrite that script. Removing the root binary + with `cargo uninstall whitaker` leaves the legacy installer script intact. +- The `ls` output reflects the installer's current scanner and is not the + future bundle-manifest or effective-configuration view. +- There are no `whitaker` GitHub release archives until item 3.5.3, so + cargo-binstall uses its source-build fallback in the interim. +- The unified parser is wired through `ortho_config`'s localized parsing + interface with `NoOpLocalizer`; translations are not part of this milestone. + +## Outstanding decisions + +- No additional architecture decision is required for item 3.5.1. The public + `check`, bundle-manifest, and `doctor` contracts remain with their later + roadmap phases. diff --git a/docs/contents.md b/docs/contents.md index 5f3cb3b87..cbf1e49b0 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -56,6 +56,8 @@ explains the repository's preferred approach to testable Rust design. - [Rust doctest dry guide](rust-doctest-dry-guide.md) explains how to keep Rust doctests accurate without unnecessary execution. +- [OrthoConfig user's guide](ortho-config-users-guide.md) explains how to build + layered command-line configuration with OrthoConfig. - [Complexity antipatterns and refactoring strategies](complexity-antipatterns-and-refactoring-strategies.md) records the complexity patterns Whitaker flags and the refactorings they are intended to encourage. @@ -83,6 +85,10 @@ contract](adr-004-pin-only-mutation-testing-contract.md) records why the shared mutation workflow tests its declared configuration without claiming parity with the continuous integration test baseline. +- [Architectural decision record (ADR) 005: root Whitaker CLI + boundary](adr-005-whitaker-cli-boundary.md) records the executable namespace, + installer API boundary, Dylint forwarding, and staged publication decision + for the unified CLI. ## Requests for comments @@ -96,5 +102,5 @@ ## Planning material -- [docs/execplans/](execplans/) stores execution plans for branch-scoped - implementation work and larger follow-up tasks. +- [docs/execplans/](execplans/) and [debugging plans](debugging/) record + implementation plans and bounded investigations. diff --git a/docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md b/docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md new file mode 100644 index 000000000..5d39eac05 --- /dev/null +++ b/docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md @@ -0,0 +1,73 @@ +# Debugging Plan: M0 core integration-test linkage + +- **Generated**: 2026-08-24 +- **Issue ID**: EP-M0 gate failure +- **Severity**: medium +- **Falsification sub-agent**: alchemist +- **Planning agent boundary**: The planning agent prepared this document. The + named sub-agent, not the planning agent, must execute falsification. + +## Problem Statement + +After extracting `whitaker_lint_core`, `make test NEXTEST_PROFILE=ci` fails +while linking its `lint_template` integration-test target. The milestone +requires the six formerly excluded test targets to run, so the correction must +preserve that coverage without suppressing a lint or weakening the gate. + +## Context Summary + +| Aspect | Details | +| ------------------- | -------------------------------------------------------- | +| First observed | 2026-08-24, first M0 full gate run | +| Reproduction rate | deterministic under workspace `--all-features` | +| Affected components | core test target and Dylint driver feature | +| Recent changes | root driver library moved to `crates/whitaker_lint_core` | + +### Error Artefacts + +```plaintext +cannot satisfy dependencies so std/core/alloc only shows up once +feature(rustc_private) is needed to link to rustc_driver +``` + +## Hypotheses + +### H1: Feature unification enables the compiler driver in the test executable + +**Claim**: Workspace `--all-features` enables `whitaker_lint_core`'s +`dylint-driver` feature through each lint crate, causing its integration-test +executable to link `rustc_driver` with ordinary dependencies. + +**Plausibility**: High — the failing target is the core integration test and +the linker explicitly names `rustc_driver`. + +**Prediction**: The focused test passes without the feature and reproduces the +link failure when it is enabled directly. + +#### H1 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | ------------------------------------------------------------------------------------------- | ---------------------------- | +| 1 | Run `cargo test -p whitaker_lint_core --test lint_template --no-default-features --no-run`. | A link failure disproves H1. | +| 2 | Run the same command with `--features dylint-driver`. | Success disproves H1. | + +**Tooling**: Cargo only; do not edit tracked files or run a repository gate. + +**Confidence on falsification**: High. The two commands vary only the feature +that owns the compiler-private runtime. + +## Recommended Execution Order + +1. **H1** — it is the smallest decisive experiment and fully explains the + captured linker diagnostics. + +## Termination Criteria + +- **Root cause identified**: H1 survives both focused tests. +- **Escalation trigger**: Either result contradicts H1; revise the debugging + plan before editing implementation code. + +## Notes for Executing Agent + +Use the shared Cargo cache. Capture only concise outputs and report a verdict +of falsified, not-falsified, or inconclusive. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index fab9517e2..aeeeeca79 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -294,54 +294,57 @@ make fmt # Apply formatting ## Mutation-testing workflow contract tests Whitaker runs scheduled, informational mutation testing through a thin caller -workflow, [`.github/workflows/mutation-testing.yml`](../.github/workflows/mutation-testing.yml), +workflow, +[`.github/workflows/mutation-testing.yml`](../.github/workflows/mutation-testing.yml), which delegates to the shared reusable workflow -`leynos/shared-actions/.github/workflows/mutation-cargo.yml`. The heavy -lifting — running `cargo-mutants`, sharding, and summarizing survivors — lives -in `shared-actions`; this repository carries only declarative configuration. -The run is **informational only**: it never gates a pull request. Survivors -are reported through the job summary and downloadable artefacts so they can be +`leynos/shared-actions/.github/workflows/mutation-cargo.yml`. The heavy lifting +— running `cargo-mutants`, sharding, and summarizing survivors — lives in +`shared-actions`; this repository carries only declarative configuration. The +run is **informational only**: it never gates a pull request. Survivors are +reported through the job summary and downloadable artefacts so they can be triaged into tests, not enforced as a blocking check. The workflow runs in two modes. A **daily schedule** (04:50 UTC) fires a change-scoped run that mutates only the source files touched within the detection window, so quiet days are cheap no-ops. A **manual dispatch** (the -Actions "Run workflow" control) mutates the whole workspace; select a branch -in that control to exercise a feature branch. +Actions "Run workflow" control) mutates the whole workspace; select a branch in +that control to exercise a feature branch. The caller passes a small set of configuration inputs, each carrying intent: - `paths` — `src/,common/,crates/,installer/,suite/`, the workspace member - prefixes rooted at the repository root; there are no top-level `examples/` - or `benches/` directories to add. + prefixes rooted at the repository root; there are no top-level `examples/` or + `benches/` directories to add. - `exclude-globs` — scaffolding whose surviving mutants would be noise rather than genuine test gaps: the `rustc_*` proxy crates and the `clippy_utils` stub (both re-export compiler internals), each lint's `ui/` and `examples/` - dylint fixtures, and the shared test infrastructure in `src/testing` and - `common/src/test_support`. + dylint fixtures, and shared test infrastructure in + `crates/whitaker_lint_core/src/testing` and `common/src/test_support`. - `extra-args` — `--all-features`, matching the feature baseline the Makefile's `CARGO_FLAGS` uses for `make test`, so feature-gated code is not reported as untested. -Unlike the excludes applied by `make test`'s `TEST_EXCLUDES`, the mutation -caller does **not** exclude the `whitaker` root crate or the -`function_attrs_follow_docs`, `module_max_lines`, and `no_expect_outside_tests` -lint crates from mutation scope (only their `ui/`/`examples/` fixtures are -excluded). Those crates enable `feature(rustc_private)` under the -`dylint-driver` feature and need the dynamic-linking `RUSTFLAGS` +Unlike the all-feature `make test` pass, the mutation caller includes +`whitaker_lint_core` and the `function_attrs_follow_docs`, `module_max_lines`, +and `no_expect_outside_tests` lint crates in mutation scope (only their +`ui/`/`examples/` fixtures are excluded). The root `whitaker` package is an +ordinary CLI crate; compiler-private driver code and its +`feature(rustc_private)` use live in `whitaker_lint_core`. The three listed +lint crates still enable `feature(rustc_private)` under `dylint-driver` and +need the dynamic-linking `RUSTFLAGS` (`-C prefer-dynamic -Z force-unstable-if-unmarked`) that the `test` and `typecheck` Makefile targets inject per invocation — see the note in [`.cargo/config.toml`](../.cargo/config.toml), which deliberately keeps that -flag out of workspace-wide configuration because it would break `cargo -install` for `whitaker-installer`. The shared mutation workflow has no -equivalent per-crate RUSTFLAGS step, so it cannot reproduce -`TEST_CARGO_FLAGS` faithfully across the whole workspace. Consequently this -adoption is **pin-only**: the caller declares the best approximation of the CI -scope it safely can, rather than a `--test-workspace` run that mirrors -`make test` crate-for-crate, and the contract test asserts only that this -declared configuration holds — not that a full workspace mutation baseline -passes. [ADR 004](adr-004-pin-only-mutation-testing-contract.md) records this -decision, its alternatives, and the accepted limitations. +flag out of workspace-wide configuration because it would break `cargo install` +for `whitaker-installer`. The shared mutation workflow has no equivalent +per-crate RUSTFLAGS step, so it cannot reproduce `TEST_CARGO_FLAGS` faithfully +across the whole workspace. Consequently this adoption is **pin-only**: the +caller declares the best approximation of the CI scope it safely can, rather +than a `--test-workspace` run that mirrors `make test` crate-for-crate, and the +contract test asserts only that this declared configuration holds — not that a +full workspace mutation baseline passes. +[ADR 004](adr-004-pin-only-mutation-testing-contract.md) records this decision, +its alternatives, and the accepted limitations. The `uses:` reference pins the shared workflow to a full 40-character commit SHA rather than a branch or tag, so a force-push upstream cannot silently @@ -353,17 +356,19 @@ without any accompanying test edit. Because the caller is configuration rather than code, a contract test suite, [`tests/workflow_contracts/mutation_testing_test.py`](../tests/workflow_contracts/mutation_testing_test.py), -pins the shape it must uphold, failing the pull request when the caller -drifts — repointing the pin at a branch, widening the token scope, or -dropping a configuration input — rather than letting the breakage surface -only in a scheduled run. Run it locally with: +pins the shape it must uphold, failing the pull request when the caller drifts +— repointing the pin at a branch, widening the token scope, or dropping a +configuration input — rather than letting the breakage surface only in a +scheduled run. Run it locally with: ```sh make test-workflow-contracts ``` -which wraps `uv run --with 'pytest>=8' --with 'pyyaml>=6' pytest -tests/workflow_contracts -q`. The suite validates: +which wraps +`uv run --with 'pytest>=8' --with 'pyyaml>=6' pytest +tests/workflow_contracts -q`. +The suite validates: - the `uses:` reference targets `mutation-cargo.yml` pinned to a full commit SHA; @@ -1749,20 +1754,20 @@ fixture-based test setup used by the staged-suite coverage. #### Shared UI harness environment overrides -Workspace-level UI harness tests that mutate process-wide environment -variables must use the scoped helpers in `whitaker_common::test_support`: -`with_env_var` installs a temporary value, `with_env_var_removed` makes a -variable absent, and `with_locale` overrides (or clears) `DYLINT_LOCALE` for -the duration of a callback. The helpers delegate to `temp_env`, whose -re-entrant global lock serializes scoped mutations while permitting nesting -from the same thread, and every prior value is restored when the callback -returns or panics. The workspace forbids `unsafe` code, so tests must never -call `std::env::set_var`/`remove_var` directly. - -`whitaker::testing::ui::run_with_runner` wraps the Dylint UI runner in the -same scoped helpers. On every platform it clears `RUSTC_WRAPPER` while the -runner needs bare `rustc` invocations for `dylint_testing::Test::example`. On -Windows it also sets `VCPKG_ROOT` to `C:\vcpkg` when that directory exists +Workspace-level UI harness tests that mutate process-wide environment variables +must use the scoped helpers in `whitaker_common::test_support`: `with_env_var` +installs a temporary value, `with_env_var_removed` makes a variable absent, and +`with_locale` overrides (or clears) `DYLINT_LOCALE` for the duration of a +callback. The helpers delegate to `temp_env`, whose re-entrant global lock +serializes scoped mutations while permitting nesting from the same thread, and +every prior value is restored when the callback returns or panics. The +workspace forbids `unsafe` code, so tests must never call +`std::env::set_var`/`remove_var` directly. + +`whitaker_lint_core::testing::ui::run_with_runner` wraps the Dylint UI runner +in the same scoped helpers. On every platform it clears `RUSTC_WRAPPER` while +the runner needs bare `rustc` invocations for `dylint_testing::Test::example`. +On Windows it also sets `VCPKG_ROOT` to `C:\vcpkg` when that directory exists and the variable is otherwise absent. Example-based UI tests in `rstest_helper_should_be_fixture` also use a @@ -1951,11 +1956,39 @@ libraries = [ This skips building entirely, providing faster lint runs during development. +### Root `whitaker` CLI boundary + +The root package owns the `whitaker` command. Its application policy is kept in +`src/cli/`, ports are declared in `src/ports.rs`, and system-backed adapters +live in `src/adapters/`. The CLI decides which operation to run and when; +`whitaker-installer` owns how dependency installation and lint staging work. +Keep filesystem discovery and installer orchestration behind those ports so the +routing and exit policy can be tested without invoking system tools. + +`installer/src/main.rs` remains the small composition root for +`whitaker-installer`. Shared installation behaviour lives in +`installer/src/orchestration/mod.rs`; the root CLI calls +`run_install_for_whitaker` so it does not create a `whitaker` wrapper that +would overwrite the executable. The legacy installer continues using `run`, +which retains its existing wrapper policy. Installed-lint scanning is shared +through `whitaker_installer::list::collect_installed`, while each executable +chooses its own presentation. + +The root CLI tests are split by contract: unit tests cover pure routing and +exit policy, the BDD feature exercises process behaviour, and +`tests/property_arg_parity.rs` checks root/installer option parity. Keep +`#[gtest]` before `#[rstest]` when combining the macros; reversing them makes +the parameterized test register twice. The Kani harnesses in +`src/cli/dispatch.rs` cover bounded routing totality, disjointness, and class +reachability. The Verus sidecar is `verus/whitaker_cli_dispatch.rs`; its +unbounded routing claim and verifier outcome are recorded in the ExecPlan. + ### Installer internal architecture -`installer/src/main.rs` coordinates the installation workflow through a small -set of focused private helpers. Understanding them is useful when extending the -installation pipeline. +`installer/src/orchestration/mod.rs` coordinates the installation workflow +through a small set of focused private helpers. Understanding them is useful +when extending the installation pipeline. `installer/src/main.rs` only parses +the legacy CLI, constructs adapters, and maps the result to a process exit code. #### `resolve_additional_components` @@ -2090,14 +2123,14 @@ It can be promoted to standard by: ### Suite entry point -Dylint resolves a library's lints through an exported `register_lints` -symbol, which requires `#[unsafe(no_mangle)]`. The workspace forbids in-crate -`unsafe` code, so the suite declares its entry point through +Dylint resolves a library's lints through an exported `register_lints` symbol, +which requires `#[unsafe(no_mangle)]`. The workspace forbids in-crate `unsafe` +code, so the suite declares its entry point through `whitaker_common::declare_dylint_register_entry!`, which expands the unsafe attribute from an external macro — the same escape hatch `dylint_linting::impl_late_lint!` relies on for single-lint crates. The macro -is reserved for Dylint driver crates; it must not be used for any other -symbol export. +is reserved for Dylint driver crates; it must not be used for any other symbol +export. ## Creating a New Lint diff --git a/docs/execplans/3-5-1-root-whitaker-binary.md b/docs/execplans/3-5-1-root-whitaker-binary.md new file mode 100644 index 000000000..4198ea794 --- /dev/null +++ b/docs/execplans/3-5-1-root-whitaker-binary.md @@ -0,0 +1,1653 @@ +# Add a root `whitaker` binary behind an internal library boundary + +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, `Decision log`, +`Outcomes & retrospective`, `Conformance basis`, and `Verification plan` must +be kept up to date as work proceeds. + +Status: IN PROGRESS + +## Purpose / big picture + +The product is called Whitaker, but there is no program called `whitaker` — +only a binary called `whitaker-installer` and a shell script the installer +generates. After this change there is a real one: + +```console +whitaker --help +whitaker install +whitaker ls +whitaker ls --json +``` + +These do exactly what `whitaker-installer` and `whitaker-installer list` do +today. Crucially, the existing workflow keeps working too: + +```console +whitaker --all -- -p whitaker-common --all-targets +``` + +That form is not a subcommand. It is the contract of the shell script the +installer writes to `~/.local/bin/whitaker`, and it is what this repository's +own `make lint` runs. The new binary forwards it to `cargo dylint`, so a user +whose `PATH` resolves to the new binary sees no regression. See `Decision log` +entry D-2 and requirement `CLI-REQ-FWD`. + +The invisible half of the outcome is the reason the work is worth doing, and it +is larger than it first appears. Two structural problems block a root binary +today, and both are fixed here: + +1. **The root package cannot be published.** It depends on four + `publish = false` compiler-shim crates, and its manifest has no + `description`, `license`, or `repository`. So `cargo install whitaker` + cannot resolve — the headline outcome is unreachable until this is fixed. +2. **The installer's orchestration is trapped inside a binary target.** Ten + functions in `installer/src/main.rs` and two binary-private modules cannot + be called by any other program or tested directly. + +The commands `whitaker check` and `whitaker doctor` are **not** in this plan; +they are separate roadmap items that depend on this one. + +## Definitions + +Defined here so no prior knowledge is assumed. + +**Dylint.** A tool that runs custom Rust lints compiled as dynamic libraries. +Whitaker's lints are Dylint lints. `cargo-dylint` and `dylint-link` are the +helper binaries it needs. + +**Dylint driver.** Code that links the private `rustc_*` compiler crates and +therefore needs `#![feature(rustc_private)]` and a nightly toolchain. It is +contagious: anything depending on it inherits the constraint. + +**Lint bundle / staged library.** A compiled lint library copied into a known +directory under a filename encoding the toolchain it was built for. + +**Prebuilt artefact.** A `.tar.zst` archive of already-compiled lint libraries +published on GitHub Releases, so users need not compile lints locally. + +**`cargo-binstall`.** Installs a Rust binary by downloading a prebuilt release +archive rather than compiling. It reads `[package.metadata.binstall]` from the +package's manifest **as published on crates.io**. + +**Wrapper script.** The executable file named `whitaker` that +`installer/src/wrapper.rs` writes into the user's binary directory. Its whole +body sets `DYLINT_LIBRARY_PATH` and runs `exec cargo dylint "$@"`. + +**Port / adapter (hexagonal architecture).** A port is a trait, owned by the +policy layer, describing something it needs from the outside world. An adapter +implements it. A _driving_ adapter calls into the policy (the CLI parser); a +_driven_ adapter is called by it (the installer). + +**Composition root.** The one place — `src/main.rs` — where concrete adapters +are constructed. Nothing else selects implementations. + +**Plateau.** A milestone leaving the repository correct, coherent, and safe to +stop at. + +## Progress + +- [x] `EP-M0` — extracted the compiler-private Dylint driver into + `whitaker_lint_core`; the root package now packages and tests independently. +- [x] `EP-M1` — moved installer orchestration behind the installer library API + and protected the legacy dry-run output with a snapshot. +- [x] `EP-M2a` — root commands, forwarding, Kani proof, and bounded property + checks are implemented. +- [ ] `EP-M2b` — CLI Verus proof and its negative control remain unverified. +- [x] `EP-M3a` — binstall and platform package metadata implemented. +- [ ] `EP-M3b` — crates.io name reservation remains unauthorized. +- [x] `EP-M4` — architecture and user/developer documentation. + +Resumed 2026-10-15; rebased onto `origin/harden-lint-config` +(`afb1e4ac1387aff8e51fa7bbeb62d94f0f6c8970`), still an ancestor. D-11/D-12 +approve 70 files and 7,000 net added lines for this plan only. + +The CLI delivers `install`, transitional `ls`, localized help/version, and +legacy forwarding. Installer records retain canonical staging paths per +toolchain and respect explicit `DYLINT_LIBRARY_PATH`; binstall metadata is +present. Kani passed 360 checks and reachability controls; Verus exceeded +tolerance without a verdict (D-19). Name reservation is unauthorized. Roadmap +3.5.1 is checked per direction; the plan stays open for these two criteria. + +Final review and required gates passed. The earlier coverage run timed out on +trybuild; the target now clears workspace data before rerunning. + +## Constraints + +Hard invariants. Violation requires escalation, not a workaround. + +1. **`whitaker-installer` keeps working unchanged.** Its command-line surface, + exit codes, and output must be identical before and after. It is a released + binary documented in `docs/users-guide.md`, published to GitHub Releases, + exercised by `make install-smoke` and `installer/tests/`. Its deprecation is + roadmap 3.9.1, not this plan. This is not a shim invented to make a + milestone viable; it is the shipping product. +2. **`whitaker --all` keeps working.** The wrapper script's contract is + documented at `docs/users-guide.md:25-27` and consumed by `Makefile:185` and + `.github/workflows/ci.yml:154`. Named consumers: existing users, and this + repository's own lint gate. +3. **The public library surface of `whitaker_installer` must not shrink.** + Integration tests under `installer/tests/` compile against it as an external + crate and see only `pub` items; `installer/Cargo.toml:29-55` gates + `StubExecutor` and `InstallerError::StubMismatch` behind the `test-support` + feature, which roadmap 3.2.2 declares supported. Additions are fine; + removals and signature narrowings are not. +4. **The root package stays named `whitaker` at version `0.2.7`**, and the new + binary is named `whitaker`. +5. **The Dylint lint crates must keep building and their UI tests passing** + throughout `EP-M0`. They are the oracle for that milestone. +6. **No new lint suppressions.** `Cargo.toml [workspace.lints]` sets + `unsafe_code = "forbid"`, `missing_docs = "deny"`, + `allow_attributes = "deny"`, clippy `pedantic` at warn with `-D warnings`. + Adding `#[allow(…)]` to pass a gate is a tolerance breach. Adding an + `excluded_crates` entry to `dylint.toml` counts as a suppression for this + purpose and requires the same escalation. +7. **No file may exceed 400 lines** (`AGENTS.md`). Every module needs a `//!` + doc comment. +8. **No direct environment mutation in tests** (`AGENTS.md`). Use `temp-env` + or inject through a port. +9. **The prebuilt-artefact path must not change behaviour.** Governed by + `docs/adr-001-prebuilt-dylint-libraries.md`. +10. **Caret dependency requirements only**; no `*` or `>=`. + +## Tolerances (exception triggers) + +Stop and escalate; do not improvise. + +- **Scope.** More than 70 files changed, or more than 7,000 net added lines. + The current 80-file diff is accepted by D-11; the 7,000-line cap is set by + D-12 for this plan only. `EP-M0` alone touches roughly 40 files, almost all + mechanically. +- **`EP-M0` gate.** If `cargo package -p whitaker --no-verify` still fails + after the extraction, stop. That command is the milestone's whole point. +- **Interface.** If anything requires removing or narrowing an existing `pub` + item in `whitaker_installer`, stop (Constraint 3). Additive `*_for` functions + are the sanctioned pattern. +- **Behaviour drift.** If any existing test under `installer/tests/` or any + Dylint UI fixture needs its _assertions_ changed, stop. Relocation is fine; + changed expectations are evidence of a Constraint 1 or 5 breach. +- **Dependencies.** Three new dependencies are pre-authorized: `ortho_config`, + `googletest`, `pretty_assertions`. A fourth triggers escalation. +- **Iterations.** Three failed fix attempts on one root cause; report the log + path. +- **Verification.** Kani verifier execution over 15 minutes for this plan's + harnesses in aggregate, or Verus verifier execution over 10 minutes, or more + than one working day spent authoring either. Cargo compilation and shared + admission time are recorded separately and do not count toward verifier + execution limits. These are the repository's first sequence-shaped proofs; + budget overrun is the expected failure mode. +- **Ambiguity.** If `docs/whitaker-cli-design.md` and `docs/roadmap.md` + disagree on whether a behaviour belongs to 3.5.1, stop and present both + readings. This fired twice during planning; see `Decision log` D-2 and D-3. + +## Risks + +**R1 — `EP-M0` is a wide mechanical rename that can break the lint suite.** +Severity: high. Likelihood: medium. Eleven lint-crate manifests and roughly 25 +`use whitaker::` sites move. Note +`crates/test_must_not_have_example/Cargo.toml:34` uses a literal path +dependency, not the `workspace = true` alias, so a global search-and-replace on +the alias misses it. Mitigation: the Dylint UI suite already exercises every +affected crate. Run it before and after and require identical results. Do the +extraction as one commit so bisection is clean. + +**R2 — Argument forwarding can swallow a real subcommand.** Severity: high. +Likelihood: medium. The binary must decide, from argv alone, whether to +dispatch a subcommand or forward to `cargo dylint`. Get it wrong and +`whitaker install` silently runs `cargo dylint install`, or `whitaker --all` +prints a usage error. Mitigation: this is the plan's one genuinely new decision +procedure, and it is the subject of both verification obligations — +`EP-INV-DISPATCH` (Kani) and `EP-LEM-DISPATCH` (Verus). + +**R3 — Index-based BDD scenario bindings break silently.** Severity: medium. +Likelihood: medium. `#[scenario(path = "…", index = N)]` binds by position; +inserting a scenario mid-file rebinds every later one, often still passing. See +the warning at `installer/tests/behaviour_cli/scenarios.rs:7`. Mitigation: new +scenarios go only in new feature files. + +**R4 — A new test binary named `behaviour_cli` is skipped by default.** +Severity: medium. Likelihood: high if unaddressed. `.config/nextest.toml` sets +`default-filter = "not (binary(behaviour_toolchain) | binary(behaviour_cli) | kind(example))"`, +and nextest's `binary()` matches by binary _name_, not package-qualified name. +Mitigation: name the new file `behaviour_whitaker.rs`, and run +`make test NEXTEST_PROFILE=ci` at every milestone boundary. + +**R5 — `--help` and `--version` may exit non-zero.** Severity: medium. +Likelihood: medium. clap reports both as `Err`. Routing every `Err` to a +failure code makes `whitaker --help` fail. Mitigation: `EP-INV-EXIT`, asserted +end-to-end on the real process. + +**R6 — Publishing instructions may precede name reservation.** Severity: +medium. Likelihood: low, but severe if it lands. The name `whitaker` is +unregistered on crates.io. If `EP-M4` publishes `cargo install whitaker` before +the name is claimed, and 3.5.3 slips, users following official documentation +install someone else's crate. Mitigation: `EP-M3` reserves the name; `EP-M4` is +explicitly gated behind it. + +**R7 — `ortho_config` brings a second `toml` stack.** Severity: low. +Likelihood: certain. `ortho_config` 0.9.0 reaches `figment` → `toml 0.8` + +`toml_edit 0.22` alongside the workspace's existing `toml 1.x` + +`toml_edit 0.25`, and enables `figment`'s `test` feature (pulling `tempfile` and +`parking_lot`) into the production graph. Roughly 26 genuinely new lock +entries; about 80% of the closure is already present. Mitigation: after `EP-M0` +the root package is _not_ a dependency of the lint crates, so this cost is +confined to the CLI binary. Record the stripped release size in `EP-M3` rather +than guessing. + +**R8 — Removing `--exclude whitaker` switches on six never-run test binaries.** +Severity: medium. Likelihood: high. +`tests/{build_config,config_loading,lint_template,locale_resolution,nextest_ui_filter,ui_harness}.rs` +total roughly 1,045 lines and have never run under `make test`, because +`Makefile:23` excludes the package. `tests/ui_harness.rs` drives the Dylint UI +harness under +`RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked -D warnings"`. +Mitigation: treat this as its own step inside `EP-M0` with its own validation, +not a one-line edit. Note `Makefile:64-68` `DOCTEST_EXCLUDES` carries a _second_ +`--exclude whitaker`; decide both explicitly. `make coverage` (`Makefile:151`) +reuses the same recipe, so a bad landing reddens two CI jobs. + +**R9 — The rebased baseline has an unresolved full-test gate failure.** +Severity: high. Likelihood: confirmed on the current pre-M2 head. The +CI-profile suite reported Dylint 6.0.4's `Found no rustc invocations` under the +managed compiler wrapper and shared Cargo-lock contention. Do not bypass +compiler admission or exclude tests. Reassess the exact failure after M2 +changes and require a green current-candidate test gate before its CodeRabbit +review. + +## Context and orientation + +Work from this repository, using this plan as the task record. Run commands +from the repository root, which `git rev-parse --show-toplevel` identifies. + +### The root package today + +The repository root is itself a Cargo package: + +```toml +[package] +name = "whitaker" +version = "0.2.7" +edition = "2024" +``` + +It has **no binary**, only `src/lib.rs`, which is the shared support library +for the Dylint lint crates. Its second line is the crux: + +```rust +#![cfg_attr(feature = "dylint-driver", feature(rustc_private))] +``` + +Under that feature the library links `rustc_driver`. A comment in the same file +records the consequence — "duplicated `std`/`core` link errors seen during +all-features test runs" — and that is why `Makefile:23` excludes the package +from the test run. + +Two things follow, and this plan exists to fix both. First, the package cannot +be published: + +```console +cargo package -p whitaker --no-verify --allow-dirty +``` + +```plaintext +warning: manifest has no description, license, license-file, documentation, + homepage or repository +error: failed to prepare local package for uploading +Caused by: + no matching package named `rustc_ast` found + location searched: crates.io index + required by package `whitaker v0.2.7` +``` + +`crates/rustc_{ast,hir,lint,middle,session,span,attr_data_structures}` are all +`publish = false`. Second, eleven lint crates depend on this package, so +anything added to its `[dependencies]` propagates to all of them — Cargo has no +per-target dependency tables. + +### The installer + +`installer/` is the package `whitaker-installer`. It already has a library +(`installer/src/lib.rs`) exposing about 25 public modules, and four binaries +declared with `autobins = false` (`installer/Cargo.toml:11-27`). Users install +`whitaker-installer`, built from `installer/src/main.rs`; the other three are +release-packaging utilities and are out of scope. + +`installer/src/main.rs` is 402 lines and dispatches: + +```rust +fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { + match &cli.command { + Some(Command::List(args)) => run_list(args, stdout), + Some(Command::Install(args)) => run_install(args, stderr), + None => run_install(cli.install_args(), stderr), + } +} +``` + +Note the `None` arm: bare `whitaker-installer` installs. `Cli` flattens +`InstallArgs` alongside the optional subcommand (`installer/src/cli.rs:55-63`, +with `install_args()` at `:283-288`). + +What matters here is what else lives in that binary and cannot be reached from +anywhere else. `installer/src/main.rs:7-8` declares `mod install_flow;` and +`mod staged_suite;` — neither appears in `installer/src/lib.rs`. Together with +ten functions in `main.rs` (`run_install`, `run_dry`, +`try_fast_path_installation`, `finish_install`, +`finish_install_and_record_metrics`, `resolve_requested_crates`, +`generate_and_report_wrapper`, `ensure_whitaker_workspace`, `resolve_toolchain`, +`ensure_toolchain_installed`) they hold the real orchestration. + +Useful detail for `EP-M1`: items inside `installer/src/install_flow/mod.rs` are +already `pub(crate)`, and `PrebuiltInstallationHooks` at `:136` is fully +private. Once the module sits inside the library, `pub(crate)` already means +library-internal, so only a small named facade needs to become `pub`. Do not +over-promote. + +### The wrapper script — the contested name + +`installer/src/wrapper.rs:105` writes `bin_dir.join("whitaker")`, an executable +whose body is: + +```bash +export DYLINT_LIBRARY_PATH="{library_path}" +exec cargo dylint "$@" +``` + +This is not a convenience. It is currently the only way to run Whitaker's +lints. It is documented (`docs/users-guide.md:25-27, 51-57`), wired into +`Makefile:74-75` and `:185`, and run by `.github/workflows/ci.yml:154`. + +`Makefile:9` prepends `$(HOME)/.cargo/bin` to `PATH` while `Makefile:6` appends +`$HOME/.local/bin`. So a `cargo install`ed binary at `~/.cargo/bin/whitaker` +**deterministically wins** over the script at `~/.local/bin/whitaker`. +`Makefile:99-139` wraps the whole test run in a `trap`-based backup and restore +of that file, failing loudly on modification — somebody has already been burned +by it. + +That is why this plan forwards unrecognized arguments rather than rejecting them +(`Decision log` D-2). + +### Existing dependency-injection seams + +The installer already has seams, in three styles for one concern: + +| Trait or seam | Defined at | Style | +| ----------------------------------------- | --------------------------------------- | ---------------------------------------- | +| `deps::CommandExecutor` | `installer/src/deps/mod.rs:36` | public trait, `mockall` | +| `toolchain::CommandRunner` | `installer/src/toolchain/mod.rs:44` | **private** trait, `mockall`, same shape | +| `dirs::BaseDirs` | `installer/src/dirs.rs:41` | public trait, `mockall` | +| `builder::CrateBuilder` | `installer/src/builder.rs:58` | public trait, `mockall` | +| `artefact::download::ArtefactDownloader` | `installer/src/artefact/download.rs:29` | public trait, `mockall` | +| `install_flow::PrebuiltInstallationHooks` | `installer/src/install_flow/mod.rs:136` | bare `fn` pointers | + +_Table 1: Existing dependency-injection seams in the installer._ + +Three places spawn processes with no seam at all: `installer/src/git.rs`, +`Builder::build_crate` (`installer/src/builder.rs:83`), and +`install_flow::detect_host_target`. + +This plan does **not** unify them (`Decision log` D-5). + +### Where tests live + +- Unit tests: colocated, inline `#[cfg(test)] mod tests` or a sibling + `_tests.rs` declared with `#[cfg(test)] mod foo_tests;`. +- Behavioural tests: `/tests/behaviour_*.rs` with Gherkin files in + `/tests/features/*.feature`, bound by `#[scenario(path, index)]`. + Shared state uses a "World" fixture; see + `installer/tests/behaviour_cli/support.rs`. +- End-to-end CLI tests spawn `env!("CARGO_BIN_EXE_")`. **Cargo sets + that variable only for integration tests of the package that declares the + binary.** There is no `assert_cmd` in this workspace. +- Snapshots: a `snapshots/` directory beside the test, `insta`'s default + `____.snap` naming. +- Kani: `#[cfg(kani)] mod verification` colocated with the code — see + `docs/developers-guide.md:737` — run by `scripts/run-kani.sh` via `make kani`. +- Verus: standalone files in `verus/` at the repository root, run by + `scripts/run-verus.sh` via `make verus`. They are _models_ of the + implementation, not proofs of the literal Rust source; the trust boundary is + documented in `docs/developers-guide.md`. + +### The gates + +```console +make check-fmt +make typecheck +make lint +make test +make test NEXTEST_PROFILE=ci +make markdownlint +``` + +Capture output, because long transcripts are truncated: + +```console +make test 2>&1 | tee /tmp/test-whitaker-3-5-1.out +``` + +Run them **sequentially** — this environment relies on build caching and +concurrent Cargo jobs contend on the package-cache lock. Do not run `make fmt` +for a targeted documentation edit; it reflows every Markdown file. Note that +`make markdownlint` also runs a `typos` spelling gate enforcing Oxford spelling +(`-ize`, but `behaviour` keeps `-our`), and `typos` only inspects git-tracked +files — an untracked draft passes and then fails once committed. + +## Conformance basis + +No Terms of Reference document exists. Upstream artefacts: + +- **Design:** `docs/whitaker-cli-design.md` at the revision in the working + tree, §Public CLI surface and §Compatibility and migration. Cited as + `CLI-DESIGN`. +- **Roadmap:** `docs/roadmap.md` item 3.5.1 (line 145). Its prerequisite, + 3.2.1 (line 108), is done. +- **ADRs:** `docs/adr-001-prebuilt-dylint-libraries.md` constrains the + prebuilt path. No ADR covers the CLI boundary; this plan creates + `docs/adr-005-whitaker-cli-boundary.md`. +- **Standards:** `AGENTS.md`, `docs/documentation-style-guide.md`. + +| ID | Statement | Source | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `CLI-REQ-BIN` | "Add a real `whitaker` binary at the root package" | `CLI-DESIGN` §Compatibility and migration, step 1 | +| `CLI-REQ-LIB` | "move the current installer logic behind an internal library boundary" | ditto | +| `CLI-REQ-BINSTALL` | "copy the working `cargo-binstall` metadata pattern from `whitaker-installer` onto `whitaker`" | ditto | +| `CLI-REQ-LS` | "`whitaker-ls` disappears in favour of `whitaker ls`" | `CLI-DESIGN` §Public CLI surface | +| `CLI-REQ-L10N` | "Every human-facing string, including `--help` … should be localizable"; command names are never translated | `CLI-DESIGN` §Accessibility and localization | +| `CLI-REQ-FWD` | Derived from Constraint 2; no upstream text. Recorded as a deviation in `Decision log` D-2 and added to `CLI-DESIGN` in `EP-M4`. | this plan | + +_Table 2: Upstream requirements traced by this plan._ + +```plaintext +CLI-REQ-BIN -> EP-M0, EP-M2 -> tests::behaviour_whitaker::help_lists_subcommands +CLI-REQ-LIB -> EP-M1 -> installer/tests/ unchanged + EP-INV-PARITY +CLI-REQ-BINSTALL -> EP-M3 -> tests::behaviour_binstall::whitaker_package_metadata +CLI-REQ-LS -> EP-M2 -> tests::behaviour_whitaker::ls_text_and_json +CLI-REQ-L10N -> EP-M2 -> localized parse entry point; NoOpLocalizer +CLI-REQ-FWD -> EP-M2 -> EP-INV-DISPATCH (Kani) + EP-LEM-DISPATCH (Verus) +``` + +Explicitly **not** discharged here, with owning roadmap items: `whitaker check` +(3.5.2); release-artefact publishing (3.5.3); rule codes and selector +precedence (3.6.1, 3.6.2); `whitaker.toml`, `dylint.toml` bridging and +`DYLINT_*` migration (3.6.3); `--locale`/`--colour`/`--progress` (3.6.4); the +`--build-only` → `--build-from-source` rename and `--offline` (3.7.1); bundle +manifests (3.7.3); `doctor` and failure recording (3.8.x); the full `ls` +surface (3.8.1, 3.8.2); the `whitaker-installer` deprecation shim and `list` +alias (3.9.1); wrapper-script and `--skip-wrapper` removal (3.9.3). + +## Verification plan + +The first draft proposed a Verus proof of release-asset-name injectivity and a +Kani harness proving that a routing function rejects two conflicting flag +pairs. Both were **vacuous** and have been cut. Nothing in this repository ever +inverts an asset name — `cargo-binstall` composes by literal substitution, and +a grep for `rsplit_once|splitn|strip_prefix` across `installer/src/` finds +nothing — so injectivity of a never-inverted function is load-bearing for +nothing. And both conflict pairs are enforced by clap attributes +(`installer/src/cli.rs:108`, `:113`, `:131`), which reject the input during +parsing, so a routing function can never receive one; proving it rejects them +proves a property of an unreachable branch. Recording this is required by the +ExecPlan discipline: a passing check that cannot fail is not evidence. + +What replaced them is a genuinely new obligation. The forwarding behaviour +(`CLI-REQ-FWD`) introduces a decision procedure that did not previously exist +and that no third-party library owns: given argv, dispatch a subcommand or +forward to `cargo dylint`. Getting it wrong silently breaks either the new CLI +or every existing user. That is worth verifying properly. + +### Axioms (assumed, not verified) + +- `clap` 4.5 parses argv per its documented derive semantics. +- `ortho_config` 0.9.0's `LocalizedParse::try_parse_localized_from` and + `is_display_request` behave as documented. Repository-owned logic built on + them **is** verified, against the real interface. +- `cargo-binstall` resolves `pkg-url` by literal substitution of `{name}`, + `{target}`, `{version}`, `{archive-format}`. +- The wrapper script's contract is `exec cargo dylint "$@"` with + `DYLINT_LIBRARY_PATH` set (`installer/src/wrapper.rs:107-113`). +- Kani sequentializes concurrency; no obligation here concerns concurrency. + +### `EP-INV-DISPATCH` — argv classification is total and disjoint (bounded) + +- **Obligation.** For every argv, `classify(argv)` returns exactly one of + `Subcommand(_)`, `Forward`, or `Display`. No argv whose first non-global + token names a Whitaker subcommand is ever classified `Forward`, and no argv + the wrapper script would have accepted is classified as a usage error. +- **Method.** Bounded model check (Kani), plus parameterized `rstest` cases. +- **Rationale.** Totality and disjointness over a combinatorial token space is + what bounded exhaustive exploration is for, and `classify` is small and pure. +- **Domain.** argv modelled as a bounded sequence of _token tags_, not + strings: an enum over + `{Install, Ls, Help, Version, DoubleDash, GlobalFlag, DylintFlag, Other}`, + length bounded at 6 — under 10^5 states. `#[kani::unwind(7)]`, one greater + than the maximum iteration count. +- **Siting.** `classify` and its token enum live in `src/cli/dispatch.rs` in + the root package and take the token enum rather than `clap::Cli`, so the + harness compiles a small pure module. This is deliberate: siting Kani where + it must codegen `clap` plus `ortho_config` plus `whitaker_installer` would be + a roughly 250-crate graph, and a symbolic `Vec` hits the documented + heap cliff. Tokenizing real argv is a separate, testable function that is + _not_ part of the harness. +- **Artefact.** `src/cli/dispatch.rs`, `#[cfg(kani)] mod verification`. + Register a new `whitaker-cli` group in `scripts/run-kani.sh`, adding both a + group function and a `case` arm. **Note the `*)` fallback at + `scripts/run-kani.sh:97` routes unrecognized arguments into the + _decomposition_ group**, so a mistyped filter silently verifies the wrong + package and reports success. Add the arm before the fallback and confirm the + harness names appear in the output. +- **Evidence.** `make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1.out`. Expect + `VERIFICATION:- SUCCESSFUL` for `verify_classify_is_total_and_disjoint`. +- **Non-vacuity.** Three separate reachability harnesses each assert `false` + under a constraint selecting one outcome; each **must report a + counterexample**, proving that outcome reachable. A harness that verifies + successfully here is a failure. Negative control: remove `Ls` from the + subcommand table; `verify_classify_is_total_and_disjoint` must fail with a + concrete argv that names `ls` yet classifies as `Forward`. Restore and record + the transcript. + +### `EP-LEM-DISPATCH` — classification is total for unbounded argv + +- **Obligation.** For every finite token sequence of _any_ length, exactly one + classification applies. Kani bounds length at 6; this closes the tail. +- **Method.** Formal proof (Verus) over `Seq`. +- **Rationale.** The guarantee must hold for all admissible inputs — a user's + `cargo dylint` invocation has no length bound. Bounded checking cannot + establish it. The proof is a genuine mutual-exclusivity and exhaustiveness + argument over the guard predicates, not a restatement. +- **Domain.** Unbounded `Seq`. This would be the repository's first + sequence-shaped proof; every file in `verus/` today is numeric or + vector-algebraic. Budget accordingly and respect the verification tolerance. +- **Artefact.** `verus/whitaker_cli_dispatch.rs`, added as a new `cli` group + in `scripts/run-verus.sh` — which needs **both** the `case` at `:10-26` and + the second `case` at `:53-54` edited, not one. +- **Evidence.** `make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1.out`. Expect + `verification results:: N verified, 0 errors`. +- **Non-vacuity.** No `assume` in the final proof. A witness lemma must + exhibit an inhabiting sequence for each of the three classes _before_ the + disjointness theorem is stated, or the theorem is vacuously true over an + empty domain. Negative control: widen one guard so two overlap; the + disjointness proof must fail. Record that transcript before restoring. + +### `EP-INV-PARITY` — install-argument parity + +- **Obligation.** For every argv `v` that `whitaker-installer` accepts as an + install invocation, `whitaker install v` produces an equal `InstallRequest`; + and every `v` the installer rejects, `whitaker install v` rejects with the + same exit code. +- **Method.** Differential property test (`proptest`). +- **Rationale.** This is the precise formal content of "move the behaviour + without changing it". Fourteen options with two conflict pairs is far too + large to enumerate and too string-shaped for a model checker. +- **Domain.** Generated argv over the 14 options declared across `InstallArgs` + (`installer/src/cli.rs:77-123`) and its three `#[command(flatten)]` groups + `LintSelectionFlags` (`:129`), `ExecutionFlags` (`:143`), `SkipFlags` + (`:157`). **Must include** short forms (`-t`, `-l`, `-j`, `-v`, `-q`), the + long alias `--verbosity` (`:107`), repeated `--lint`, repeated `-v`, both + conflict pairs, paths with spaces and non-ASCII characters, and the explicit + `whitaker-installer install …` form as well as the bare form. +- **Artefact.** `tests/property_arg_parity.rs` in the root package. +- **Evidence.** `cargo nextest run -p whitaker property_arg_parity`. Red: a + deliberately incomplete mapping that drops `--jobs` must fail naming it. +- **Non-vacuity.** Classify on **pairs**, not single flags: 2^14 is 16,384 + subsets, so marginal per-flag coverage says nothing about the combinations + where the conflicts live. Require every unordered pair of options to co-occur + in at least one case, and every option to appear set in at least 5% of cases. + Run 4,096 cases — clap parsing is tens of microseconds, so this costs well + under a second. A run where any pair is never exercised is a **failure**. + Negative control: drop `--no-update` from the mapping; the test must fail + naming it. + +### `EP-INV-EXIT` — exit-code policy + +- **Obligation.** Exit `0` for success and for a clap display request + (`--help`, `--version`); **`2` for an argument-parsing or usage error**; `1` + for an operational failure. Forwarded invocations propagate `cargo dylint`'s + exit code unchanged. +- **Method.** Parameterized `rstest` over the finite partition, plus + end-to-end assertions on the real spawned process. +- **Rationale.** A small finite partition is exactly what parameterized tests + are for. The end-to-end case is what makes it non-vacuous: the unit mapping + can be right while `main` discards it. +- **Ground truth, not a free choice.** `installer/src/main.rs:42` calls + `Cli::parse()`, whose `Error::exit()` terminates with clap's code — **2** — + never reaching `exit_code_for_run_result` at `:391-398`, the only source of + `1`. So `whitaker-installer --lint x --individual-lints` exits 2 today, and + Constraint 1 plus `EP-INV-PARITY` require `whitaker install` to match. The + localized-parse arm must therefore use clap's own code, not a blanket failure + code. +- **Domain.** `Ok(())`; each variant class of `InstallerError`; clap + `DisplayHelp` and `DisplayVersion`; a genuine usage error; a forwarded + invocation returning a non-zero code. +- **Artefact.** `src/cli/exit.rs` unit tests and `tests/e2e_exit_codes.rs`, + **both in the root package** — because `env!("CARGO_BIN_EXE_whitaker")` is + defined only for integration tests of the package declaring the binary. This + is why `EP-M0` must remove `--exclude whitaker` from `TEST_EXCLUDES`; the + end-to-end obligation is otherwise unrunnable. +- **Evidence.** `cargo nextest run -p whitaker exit`. +- **Non-vacuity.** The end-to-end test spawns the real binary and reads + `ExitStatus::code()`. Negative control: drop the `is_display_request` branch; + `whitaker --help` must then exit non-zero and the test must fail. + +### Not verified, deliberately + +- Internals of `clap`, `ortho_config`, `figment`, or `cargo-binstall`. +- The installer's existing behaviour beyond parity — already covered by + `installer/tests/`. +- Localization catalogue content: `EP-M2` wires `NoOpLocalizer`, so there is + no translation logic yet. Catalogues arrive with 3.6.4. + +## Plan of work + +### Stage A — extract the driver library (`EP-M0`) + +This is the precondition, not a spike. Do it first and completely. + +1. Create `crates/whitaker_lint_core` and move `src/config.rs`, `src/hir/`, + `src/lints/`, `src/testing/`, the `dylint-driver` feature, and the root + `tests/` files that exercise them (`build_config.rs`, `config_loading.rs`, + `lint_template.rs`, `locale_resolution.rs`, `nextest_ui_filter.rs`, + `ui_harness.rs`, plus `tests/features/` and `tests/support/`). +2. Repoint the eleven lint-crate manifests and `suite/`. Do not miss the + literal path dependency at `crates/test_must_not_have_example/Cargo.toml:34`. +3. Strip the root `[package]` to a CLI package and add what crates.io + requires: `description`, `license.workspace = true`, + `repository.workspace = true`, `homepage.workspace = true`, + `documentation.workspace = true`. Remove the now-unused optional `rustc_*` + dependencies and the `dylint-driver` feature. **Also remove the unused + `whitaker-installer` dependency at `Cargo.toml:71`** — grep confirms zero + `whitaker_installer::` references under `src/` or `tests/` today — and + re-add it deliberately in `EP-M1`, so the before-and-after dependency + measurement is honest. +4. Remove `--exclude whitaker` from `TEST_EXCLUDES` (`Makefile:23`) **and** + decide `DOCTEST_EXCLUDES` (`Makefile:64-68`) explicitly. Expect R8: six test + binaries begin running for the first time. Fix what they surface; if any + fails for a pre-existing reason unrelated to this plan, record it in + `Surprises & discoveries` and escalate rather than papering over it. +5. Add `-p whitaker` to `WHITAKER_PACKAGES` (`Makefile:81`) so the project's + own lint suite covers the new code. Check `dylint.toml:35-52` + `excluded_crates`: it currently names `whitaker`, and the meaning of that + entry changes once the package changes character. Under Constraint 6, any + new entry needs escalation. + +**Gate — the whole point of the milestone:** + +```console +cargo package -p whitaker --no-verify +``` + +must succeed. Then +`make check-fmt && make typecheck && make lint && make test NEXTEST_PROFILE=ci`, +and the Dylint UI suite must be unchanged. + +Then answer the link question the first draft got wrong. `cargo check` does +**not** invoke the linker, so it cannot observe duplicate `std`/`core` symbols. +Use: + +```console +cargo build --workspace --bins --all-features +cargo test -p whitaker --all-features --no-run +``` + +under the same +`RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked -D warnings"` the +test recipe uses. After the extraction the root package no longer enables +`rustc_private` at all, so this should be clean; if it is not, stop and +escalate. + +### Stage B — red tests and feature specifications + +No production behaviour yet. + +1. `tests/features/whitaker_cli.feature` — reproduced in full under + `Artefacts and notes`. +2. `tests/behaviour_whitaker.rs` with a `CliWorld` fixture modelled on + `installer/tests/behaviour_cli/support.rs`. Named to avoid R4. +3. `tests/e2e_exit_codes.rs` (`EP-INV-EXIT`). +4. `tests/property_arg_parity.rs` (`EP-INV-PARITY`). +5. `src/cli/dispatch.rs` with the token enum, `classify`, and the + `#[cfg(kani)] mod verification` harnesses including the three reachability + harnesses (`EP-INV-DISPATCH`). +6. `verus/whitaker_cli_dispatch.rs`, witness lemmas first + (`EP-LEM-DISPATCH`). + +Every one must **fail**, for the expected reason. Record each red transcript. + +### Stage C — implementation + +1. **Promote the installer orchestration.** Add `pub mod install_flow;` to + `installer/src/lib.rs`, move `installer/src/install_flow/` into the library, + and move the ten `main.rs` orchestration functions into a new + `installer/src/orchestration/` tree, each file under 400 lines. + `installer/src/main.rs` becomes a thin composition root. + `try_fast_path_installation` calls + `staged_suite::try_test_staged_suite_installation` + (`installer/src/main.rs:87`), so the fast path and the staged-suite hook + move together; keep the hook behind its existing `#[cfg(debug_assertions)]` + and `test_support` gating and expose only the facade the fast path needs. + `installer/src/main.rs` also carries `#[cfg(test)] mod tests;` at `:402`; + those unit tests move with their subjects. That is relocation, not an + assertion change, and does not trip the behaviour-drift tolerance. **No + behaviour changes.** Validate with `make test NEXTEST_PROFILE=ci` — not plain + `make test`, which skips `behaviour_cli` and `behaviour_toolchain`, the + very binaries that would detect a dispatch regression. + +2. **Wrapper generation becomes a parameter.** Give + `generate_and_report_wrapper` an argument naming which scripts to write. + `whitaker-installer` passes `{whitaker, whitaker-ls}`, preserving Constraint + 1 exactly. `whitaker install` passes `{whitaker-ls}` — it must not overwrite + the binary the user just invoked. + +3. **Build the CLI in the root package.** `src/cli/` holds the clap types; + `src/cli/dispatch.rs` the classifier; `src/cli/exit.rs` the exit policy; + `src/adapters/` the driven adapters over `whitaker_installer`. The root + `Cli` carries `command: Command` (not `Option`) plus the shared `-q`/`-v` + flags that `CLI-DESIGN` declares common options; `whitaker install` + **re-exports** `whitaker_installer::cli::InstallArgs` rather than mirroring + it field for field, so parity is true by construction and `EP-INV-PARITY` + guards the request mapping rather than struct shape. + +4. **Argument forwarding.** When `classify` returns `Forward`, resolve the + staged library directory the way the wrapper script does, set + `DYLINT_LIBRARY_PATH`, and run `cargo dylint` with the original arguments, + propagating its exit code. Keep this in one small adapter behind + `LintRunner`. + +5. **Composition root** at `src/main.rs`: construct adapters, parse via + `ortho_config`, dispatch, map to an exit code. Target under 60 lines. Declare + `[[bin]] name = "whitaker"` with `autobins = false`, matching + `installer/Cargo.toml:11-27`. + +6. **binstall metadata, additively.** Do **not** change the signatures of + `expand_pkg_url` or `expand_bin_dir` + (`installer/src/binstall_metadata.rs:45`, `:73`) — they are consumed by + `installer/tests/behaviour_installer_release.rs:155`, + `installer/tests/behaviour_binstall.rs:10-18`, + `installer/src/installer_packaging_tests.rs:274`, and their own doctests, so + narrowing them trips the interface tolerance. Add + `expand_pkg_url_for(package, version, target)` and + `expand_bin_dir_for(package, version, target)`, and reimplement the existing + two-argument forms as delegates. `load_cargo_toml` resolves the manifest + through `env!("CARGO_MANIFEST_DIR")`, baked to `installer/` at compile time, + so give it a `&Path` parameter for the root package's test to use. Then add + `[package.metadata.binstall]` to the root `Cargo.toml` mirroring + `installer/Cargo.toml:95-101`, including the + `overrides.x86_64-pc-windows-msvc` entry with `pkg-fmt = "zip"`. + `installer/src/installer_packaging.rs:31` has the same hardcoding and is + deliberately **not** touched; it belongs to 3.5.3. + +Validate after each step: +`make check-fmt && make typecheck && make lint && make test NEXTEST_PROFILE=ci`, +captured with `tee`. Commit after each. + +### Stage D — verification, reservation, documentation + +1. Turn the Kani harnesses green; confirm the three reachability harnesses + each report a counterexample; run the negative control and record it. +2. Turn the Verus proof green; run the negative control and record it. +3. Run the proptest pairwise classification report; confirm every pair is + exercised. +4. Reserve the crates.io name (`EP-M3`) **before** any documentation tells + users to install it. +5. Write `docs/adr-005-whitaker-cli-boundary.md` using the template at + `docs/documentation-style-guide.md:414`, with the content required by + `EP-M4`. +6. Update `docs/users-guide.md`, `docs/developers-guide.md`, + `docs/whitaker-cli-design.md`, `docs/whitaker-dylint-suite-design.md`, + `docs/publishing.md`, `docs/repository-layout.md`, and `docs/roadmap.md`. +7. `make markdownlint` and `make nixie`. + +## Milestones and plateaus + +### `EP-M0` — root package is a publishable, testable CLI package + +- **Outcome.** The Dylint driver library lives in `crates/whitaker_lint_core`. + The root `whitaker` package has no `rustc_private`, carries crates.io + metadata, is covered by `make test`, and + `cargo package -p whitaker --no-verify` succeeds. No binary yet. +- **Requirements.** Precondition for `CLI-REQ-BIN`. +- **Acceptance evidence.** `cargo package -p whitaker --no-verify` exits 0; + the Dylint UI suite is unchanged; six previously-orphaned test binaries now + run under `make test`; `cargo build --workspace --bins --all-features` links + cleanly. +- **Conformance check.** Lint crates build and lint unchanged; no public + behaviour changed; `dylint.toml` reviewed. +- **Recovery.** One commit; `git revert` is clean. +- **Remaining gaps.** No binary, no CLI. +- **Compatibility decision.** None. `whitaker_lint_core` is a new + application-internal crate; the root library's consumers are all in-tree and + updated in the same change. The root `whitaker` library was never published, + so nothing external depends on its shape. + +### `EP-M1` — installer orchestration behind the library boundary + +- **Outcome.** `installer/src/main.rs` is a thin composition root; all + orchestration is in the `whitaker_installer` library; `whitaker-installer` + behaves identically. +- **Requirements.** `CLI-REQ-LIB`. +- **Acceptance evidence.** `make test NEXTEST_PROFILE=ci` passes with every + existing `installer/tests/` test **unmodified**. Additionally, capture + `whitaker-installer --dry-run` output as an `insta` snapshot _before_ the + move and assert it after — a five-minute change that converts a vague parity + claim into a hard oracle. +- **Conformance check.** Public surface grew, never shrank; prebuilt path + untouched. +- **Recovery.** Revert the milestone's commits. +- **Remaining gaps.** No `whitaker` binary yet. +- **Compatibility decision.** None. Pre-1.0, application-internal; callers + updated in the same change. + +### `EP-M2` — the `whitaker` binary + +- **Outcome.** `whitaker --help`, `whitaker install`, `whitaker ls`, + `whitaker ls --json`, and `whitaker --all -- …` all work. +- **Requirements.** `CLI-REQ-BIN`, `CLI-REQ-LS`, `CLI-REQ-L10N`, + `CLI-REQ-FWD`; `EP-INV-DISPATCH`, `EP-LEM-DISPATCH`, `EP-INV-PARITY`, + `EP-INV-EXIT` all discharged. +- **Acceptance evidence.** The BDD scenarios pass under `NEXTEST_PROFILE=ci`; + `whitaker --help` exits 0; `whitaker --all --version` reaches `cargo dylint`; + `whitaker install` does not write a `whitaker` script. +- **Conformance check.** Command names untranslated; `--json` on `ls` only, + not global, as `CLI-DESIGN` requires. +- **Recovery.** The binary is additive in the Cargo graph — but **not** in the + `PATH` namespace. Reverting removes the binary; a user who has already + installed it must `cargo uninstall whitaker` to restore the script's + precedence. Say so in the ADR. +- **Remaining gaps.** `check` and `doctor` absent by design. `ls` output is + the installer's current shape and will be replaced wholesale by 3.8.1 and + 3.8.2 once bundle manifests (3.7.3) exist — it is **not** a stable contract. +- **Compatibility decision.** Two, both with named consumers. (i) + `whitaker-installer` remains, per Constraint 1 — consumers: users following + `docs/users-guide.md`, published release assets; removal is 3.9.1. (ii) + Argument forwarding, per Constraint 2 — consumers: users invoking + `whitaker --all`, `Makefile:185`, `ci.yml:154`; removal is 3.9.3, once + `whitaker check` (3.5.2) supersedes it. + +### `EP-M3` — installable, and the name is ours + +- **Outcome.** Root package carries binstall metadata; the crates.io name + `whitaker` is reserved. +- **Requirements.** `CLI-REQ-BINSTALL`. +- **Acceptance evidence.** A behavioural test asserts the root package's + binstall table matches the shared template constants and that its expanded + `pkg-url` differs from `whitaker-installer`'s for **every** target in the + release matrix (`release.yml:30-38`) — a finite disjointness check over the + ten-element published namespace, which is what the cut Verus proof should + have been. Record the stripped release binary size. +- **Conformance check.** ADR-001 still holds; no published asset changes. +- **Recovery.** Metadata-only. Name reservation is not reversible; that is the + point. +- **Remaining gaps.** CI does not yet _publish_ a `whitaker` release artefact + — roadmap 3.5.3. Until it does, `cargo binstall whitaker` will 404 and fall + back to a source build, or to quickinstall for users who have it enabled. + State this plainly in the ADR and the user guide; do not imply binstall works + end to end. +- **Compatibility decision.** None. + +### `EP-M4` — documented + +- **Outcome.** ADR and documentation reflect reality. +- **Acceptance evidence.** `make markdownlint` and `make nixie` pass; roadmap + item 3.5.1 ticked. +- **Required ADR content** (`docs/adr-005-whitaker-cli-boundary.md`): the + `PATH` namespace decision and why forwarding was chosen over the + alternatives; the promoted `whitaker_installer` public surface; the rule that + `whitaker_installer` owns _how_ to install while the CLI owns _whether and + when_, so 3.5.2's lazy repair has an unambiguous home; and the plain + statement that no `whitaker` artefact is published until 3.5.3. +- **Conformance check.** Every `Surprises & discoveries` entry reconciled + against `CLI-DESIGN`; `CLI-REQ-FWD` added to the design document, since it + has no upstream source today. +- **Recovery.** Documentation-only. +- **Compatibility decision.** None. + +## Interfaces and dependencies + +### New dependencies + +Add to `[workspace.dependencies]`, caret-pinned: + +```toml +ortho_config = "0.9.0" +googletest = "0.14.3" +pretty_assertions = "1.4.1" +``` + +`ortho_config` is a normal dependency of the root package only. After `EP-M0` +the lint crates depend on `whitaker_lint_core`, not on `whitaker`, so this +subtree does not propagate into the fifty cross-compiled lint-crate builds in +`rolling-release.yml`. That containment is a direct benefit of `EP-M0` and +should be stated in the ADR. + +`insta` is already a workspace dependency; add it as a root dev-dependency. + +**`googletest` ordering rule.** `#[gtest]` must come **before** `#[rstest]`, or +the test registers and runs twice. Document in `docs/developers-guide.md`: + +```rust +#[gtest] +#[rstest] +#[case::install(&["install", "--lint", "module_max_lines"])] +fn classifies_as_subcommand(#[case] argv: &[&str]) -> googletest::Result<()> { + verify_that!(classify(&tokenize(argv)), matches_pattern!(Class::Subcommand(_))) +} +``` + +Also record how `verify_that!` interacts with the workspace's +`clippy::missing_assert_message` policy, and whether `googletest::Result<()>` +return types survive the `unwrap_used`/`expect_used` denials. + +### Required signatures + +`src/cli/dispatch.rs` — pure, the subject of both verification obligations: + +```rust +/// How an argument vector should be handled. +pub enum Class { + /// Dispatch to a Whitaker subcommand. + Subcommand(Subcommand), + /// Forward verbatim to `cargo dylint`. + Forward, + /// Print help or version and exit successfully. + Display, +} + +/// Classifies a tokenized argument vector. +#[must_use] +pub fn classify(tokens: &[TokenTag]) -> Class; +``` + +`src/cli/exit.rs` — pure, the subject of `EP-INV-EXIT`: + +```rust +/// Maps an outcome onto a process exit code. +#[must_use] +pub const fn exit_code_for(outcome: &Outcome) -> u8; +``` + +`src/ports.rs`: + +```rust +/// Performs an installation on behalf of the command layer. +pub trait InstallService { + /// Runs an installation and reports what happened. + /// + /// # Errors + /// + /// Returns an error when the installation cannot complete. + fn install(&self, request: &InstallRequest) -> Result; +} + +/// Reports the lints currently staged on this machine. +pub trait LintInventory { + /// Lists staged lints in the given staging directory. + /// + /// # Errors + /// + /// Returns an error when the staging directory cannot be scanned. + fn list(&self, request: &ListRequest) -> Result; +} + +/// Runs Dylint on behalf of a forwarded invocation. +pub trait LintRunner { + /// Forwards arguments to `cargo dylint` and returns its exit code. + /// + /// # Errors + /// + /// Returns an error when the subprocess cannot be started. + fn forward(&self, args: &[std::ffi::OsString]) -> Result; +} +``` + +`LintInventory` returns a `StagedInventory` struct rather than a bare `Vec`, so +3.7.3 and 3.8.1 can add manifest fields without replacing the trait. +`LintRunner` exists now because forwarding needs it, and 3.5.2's `check` will +reuse it. + +### Localized parsing + +```rust +use ortho_config::{LocalizedParse as _, NoOpLocalizer, is_display_request}; + +let localizer = NoOpLocalizer::new(); +let cli = match Cli::try_parse_localized_from(std::env::args_os(), &localizer) { + Ok(cli) => cli, + Err(err) if is_display_request(&err) => { err.print()?; return Ok(0); } + Err(err) => { err.print()?; return Ok(err.exit_code() as u8); } +}; +``` + +Note `NoOpLocalizer::new()`, not a bare unit-struct reference, and +`err.exit_code()` rather than a blanket failure code — otherwise usage errors +exit 1 where the installer exits 2, breaking `EP-INV-PARITY`. Confirm the exact +signature of `is_display_request` against +`docs/ortho-config-users-guide.md:333` before Stage B writes red tests against +it: if it takes `&OrthoError` rather than `&clap::Error`, the guard needs +adjusting. + +This establishes the seam that 3.6.4 fills with a `FluentLocalizer` and an +`en-GB` catalogue, without adopting a configuration model this plan does not +own. + +## Concrete steps + +Run everything from `$(git rev-parse --show-toplevel)`. + +```console +git branch --show-current +``` + +```plaintext +3-5-1-root-whitaker-binary +``` + +The `EP-M0` gate, which decides whether the milestone succeeded: + +```console +cargo package -p whitaker --no-verify +``` + +Focused iteration: + +```console +cargo nextest run -p whitaker 2>&1 | tee /tmp/nextest-whitaker-3-5-1.out +``` + +The full gate, sequentially, before every commit: + +```console +make check-fmt 2>&1 | tee /tmp/check-fmt-whitaker-3-5-1.out +make typecheck 2>&1 | tee /tmp/typecheck-whitaker-3-5-1.out +make lint 2>&1 | tee /tmp/lint-whitaker-3-5-1.out +make test NEXTEST_PROFILE=ci 2>&1 | tee /tmp/test-whitaker-3-5-1.out +``` + +Verification: + +```console +make kani 2>&1 | tee /tmp/kani-whitaker-3-5-1.out +make verus 2>&1 | tee /tmp/verus-whitaker-3-5-1.out +``` + +Smoke-test the binary: + +```console +cargo run --bin whitaker -- --help +cargo run --bin whitaker -- ls --json +cargo run --bin whitaker -- --all -- -p whitaker-common --all-targets +``` + +Expected shape of the first: + +```plaintext +Usage: whitaker + +Commands: + install Install or repair Whitaker dependencies and lint bundles + ls Show installed lints and bundle metadata + help Print this message or the help of the given subcommand(s) +``` + +Consider adding a `whitaker-smoke` Makefile target alongside `install-smoke`, +installing the root package into a temporary root _positioned to shadow_ a +generated wrapper script, and asserting that `--help`, `ls --json`, and `--all` +all behave. That is the only mechanism that would catch a forwarding regression +before users do. + +## Validation and acceptance + +### Red-Green-Refactor evidence to record + +**Red.** Before Stage C production code: + +```console +cargo nextest run -p whitaker 2>&1 | tail -20 +``` + +The BDD scenarios and `e2e_exit_codes` must fail, naming a missing binary or a +wrong exit code — not a compile error in the test itself. + +**Green.** Each Stage C step's focused command passes. + +**Refactor.** After splitting any file approaching 400 lines, re-run the +focused command and then the full gate. + +### Behaviour to observe + +1. `whitaker --help` lists `install` and `ls`; `echo $?` prints `0`. +2. `whitaker ls --json` prints the same JSON as + `whitaker-installer list --json`. Ordering is deterministic — + `installer/src/list.rs:50` sorts by crate name and + `InstalledLints.by_toolchain` is a `BTreeMap` — so the snapshot is stable. + The snapshot is a **regression guard, not a compatibility promise**; note + that inline. +3. `whitaker install --dry-run` prints what `whitaker-installer --dry-run` + prints, and writes nothing to stdout — stdout carries command output only; + diagnostics, progress, and errors go to stderr. +4. `whitaker install --lint module_max_lines --individual-lints` is rejected + and `echo $?` prints `2`, matching `whitaker-installer`. +5. `whitaker --all -- -p whitaker-common --all-targets` runs the lint suite, + exactly as the wrapper script does, and propagates its exit code. +6. `whitaker install` does not create or overwrite a `whitaker` script. +7. `whitaker-installer --help` is unchanged. + +### Quality criteria + +- **Tests.** `make test NEXTEST_PROFILE=ci` passes. Every existing + `installer/tests/` test and every Dylint UI fixture passes **without + assertion changes**. +- **Publishability.** `cargo package -p whitaker --no-verify` succeeds. +- **Verification.** `EP-INV-DISPATCH`, `EP-LEM-DISPATCH`, `EP-INV-PARITY`, + `EP-INV-EXIT` discharged, each with its recorded negative-control failure + transcript and, for the Kani reachability harnesses, its recorded + counterexample. An obligation without a recorded negative control is not + discharged. +- **Lint/typecheck.** `make check-fmt`, `make typecheck`, `make lint` pass + with no new suppressions and no new `dylint.toml` exclusions. +- **Documentation.** `make markdownlint` and `make nixie` pass. +- **Cost.** Record + `cargo tree -e normal -p module_max_lines --features dylint-driver | wc -l` + before and after; after `EP-M0` it should _fall_. Record the stripped size of + the `whitaker` binary. + +## Idempotence and recovery + +Every step is re-runnable. `EP-M0` is one large mechanical commit; keep it +self-contained so bisection is clean. Stage C steps are additive except the +relocations in step 1, which are verifiable by the unchanged test suite. + +`make test` backs up and restores `~/.local/bin/whitaker` (`Makefile:99-139`) +and fails loudly if a test modifies it. If a run is interrupted, confirm that +file was restored before re-running. + +Do not create an isolated Cargo cache; use the shared default and let the +package-cache lock serialize access. + +## Artefacts and notes + +### Feature specification (`tests/features/whitaker_cli.feature`) + +New file only — never insert into an existing feature file (`Risk R3`). + +```gherkin +Feature: The root whitaker command-line interface + + Scenario: The root command lists its subcommands + Given the whitaker binary is available + When I run whitaker with "--help" + Then the command succeeds + And the output lists the subcommand "install" + And the output lists the subcommand "ls" + + Scenario: Requesting help exits successfully + Given the whitaker binary is available + When I run whitaker with "--help" + Then the exit code is 0 + + Scenario: Listing staged lints as text + Given a staging directory containing a staged suite library + When I run whitaker with "ls" + Then the command succeeds + And the output names the staged suite + + Scenario: Listing staged lints as JSON + Given a staging directory containing a staged suite library + When I run whitaker with "ls --json" + Then the command succeeds + And the output is valid JSON + And stdout contains no diagnostic text + + Scenario: A dry-run install reports its configuration without building + Given a Whitaker workspace checkout + When I run whitaker with "install --dry-run" + Then the command succeeds + And no lint library is staged + And stdout is empty + + Scenario: Conflicting lint selection flags are rejected + Given the whitaker binary is available + When I run whitaker with "install --lint module_max_lines --individual-lints" + Then the command fails + And the exit code is 2 + And the error names both conflicting options + + Scenario: Legacy lint invocations are forwarded to cargo dylint + Given the whitaker binary is available + When I run whitaker with "--all -- -p whitaker-common --all-targets" + Then the invocation reaches cargo dylint + And the exit code is the exit code cargo dylint returned + + Scenario: Installing does not overwrite the whitaker binary + Given a Whitaker workspace checkout + When I run whitaker with "install --dry-run" + Then no wrapper script named "whitaker" is reported + + Scenario: The legacy installer binary is unaffected + Given the whitaker-installer binary is available + When I run whitaker-installer with "--help" + Then the command succeeds + And the output is unchanged from the recorded snapshot +``` + +### Verus proof skeleton (`verus/whitaker_cli_dispatch.rs`) + +Witness lemmas first, so the disjointness theorem is not vacuous: + +```rust +use vstd::prelude::*; + +verus! { + +/// Token kinds the classifier distinguishes. +pub enum TokenTag { Install, Ls, Help, Version, DoubleDash, GlobalFlag, DylintFlag, Other } + +pub open spec fn is_subcommand(tokens: Seq) -> bool; +pub open spec fn is_display(tokens: Seq) -> bool; +pub open spec fn is_forward(tokens: Seq) -> bool; + +/// Each class is inhabited, so disjointness is not vacuously true. +proof fn lemma_classes_are_inhabited() + ensures + exists|t: Seq| is_subcommand(t), + exists|t: Seq| is_display(t), + exists|t: Seq| is_forward(t), +{ /* witnesses: [Install], [Help], [DylintFlag] */ } + +/// Exactly one class applies to any sequence, of any length. +proof fn lemma_classification_is_total_and_disjoint(tokens: Seq) + ensures + is_subcommand(tokens) || is_display(tokens) || is_forward(tokens), + !(is_subcommand(tokens) && is_forward(tokens)), + !(is_display(tokens) && is_forward(tokens)), + !(is_subcommand(tokens) && is_display(tokens)), + decreases tokens.len(), +{ /* induction on the leading token */ } + +} // verus! +``` + +No `assume` in the final version. Per `docs/developers-guide.md`, Verus proofs +here model the implementation rather than the literal Rust source; the Kani +harness in `EP-INV-DISPATCH` is what checks the real code against the same +property within bounds. + +## Signposts + +| Document | Why | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `docs/whitaker-cli-design.md` | The specification. §Public CLI surface and §Compatibility and migration are normative. | +| `docs/roadmap.md` | Items 3.5.1 to 3.9.3 — what belongs here and what does not. | +| `docs/users-guide.md` | The `whitaker --all` contract this plan must preserve; updated in `EP-M4`. | +| `docs/developers-guide.md` | Installer architecture, Kani harness conventions, the Verus trust boundary. | +| `docs/publishing.md` | Publish order, updated in `EP-M4` once the root package is publishable. | +| `docs/repository-layout.md` | Updated for `crates/whitaker_lint_core`. | +| `docs/ortho-config-users-guide.md` | Layering, subcommand merging, the localization API. | +| `docs/rstest-bdd-users-guide.md` | `#[scenario]` bindings and step functions. | +| `docs/rust-testing-with-rstest-fixtures.md` | Fixture patterns for the `CliWorld` fixture. | +| `docs/rust-doctest-dry-guide.md` | Doctests are gated; keep them DRY. | +| `docs/complexity-antipatterns-and-refactoring-strategies.md` | The suite lints this repository against itself; keep `classify` flat. | +| `docs/whitaker-dylint-suite-design.md` | Workspace layout, updated in `EP-M4`. | +| `docs/whitaker-clone-detector-design.md` | Confirms `whitaker_clones_core`/`whitaker_sarif` naming so new crate names do not collide. | +| `docs/documentation-style-guide.md` | ADR template and naming; Oxford spelling. | +| `docs/adr-001-prebuilt-dylint-libraries.md` | The prebuilt path this plan must not disturb. | +| `AGENTS.md` | Gates, commit rules, the 400-line limit, test-environment rules. | + +_Table 3: Documentation to read before starting._ + +Skills: `leta` for symbol navigation instead of grep; `hexagonal-architecture` +for the port boundaries; `kani` for `EP-INV-DISPATCH`; `verus` for +`EP-LEM-DISPATCH`; `proptest` for `EP-INV-PARITY`; `rust-unit-testing` for +`googletest` and `insta` style; `execplans` for keeping this document current. + +## Surprises & discoveries + +- Observation: the checked baseline reproduces the plan's packaging failure. + Evidence: `cargo package -p whitaker --no-verify --allow-dirty` exited + non-zero on 2026-08-24 with `no matching package named rustc_ast`; the + baseline normal dependency tree for `module_max_lines` has 342 entries. + Impact: confirms that `EP-M0` remains the required first milestone and gives + the before-measurement for its dependency-containment objective. + +- Observation: Cargo's workspace-wide `--all-features` unifies the core + crate's `dylint-driver` feature into its integration-test executables. + Evidence: `lint_template` links without the feature but fails with it, + reproducing the `rustc_driver` duplicate `std`/`core` diagnostics; the two + focused commands and logs are recorded in + `docs/debugging/debugging-plan-2026-08-24-m0-core-test-linkage.md`. Impact: + the all-feature workspace pass excludes only the compiler-private core test + target, then `make test` and `publish-check` run its tests and doctests + separately without that feature. All six formerly excluded test targets + remain covered. + +- Observation: the formerly excluded `build_config` test asserted dynamic + linker flags in `.cargo/config.toml`, although that file deliberately keeps + them out of workspace configuration and the Make recipes inject them only for + Dylint-aware commands. Evidence: the current configuration comment states + this policy and the test failed because `build.rustflags` is absent. Impact: + the test now protects the actual scoped-flag contract; this is a pre-existing + assertion correction exposed by `EP-M0`, not a behaviour change. + +- Observation: the moved template behaviour tests still asserted the old root + `whitaker` dev-dependency and root-relative path. Evidence: three scenarios + failed after the extraction despite the template correctly rendering + `whitaker_lint_core = { path = "../whitaker_lint_core" }`. Impact: + expectations now validate the new internal boundary; no generated template + behaviour changed after the relocation. + +- Observation: the root `whitaker` package cannot be published at all. + Evidence: `cargo package -p whitaker --no-verify --allow-dirty` fails with + "no matching package named `rustc_ast`"; the four `rustc_*` shim crates are + `publish = false`; the manifest has no `description`, `license`, or + `repository`. Impact: decisive. Turned the driver-library extraction from a + contingency into `EP-M0`, the plan's precondition, and made a separate CLI + crate unnecessary. + +- Observation: the installer writes an executable named `whitaker` into the + user's binary directory, and a `cargo install`ed binary deterministically + shadows it. Evidence: `installer/src/wrapper.rs:105`; `Makefile:9` prepends + `~/.cargo/bin` while `Makefile:6` appends `~/.local/bin`; `Makefile:99-139` + backs the script up around every test run; `Makefile:185` and `ci.yml:154` + invoke `whitaker --all`. Impact: without forwarding, this change would break + this repository's own lint gate and every user's primary workflow, with no + replacement until 3.5.2. Drove `CLI-REQ-FWD` and Constraint 2. + +- Observation: Clap's bare `help` subcommand is not equivalent to `--help` in + the application's routing classifier. Evidence: dispatch selects forwarding + before Clap parses arguments, and `token_tag` previously tagged only `-h` and + `--help`. Impact: `whitaker help` reached `cargo dylint` instead of printing + Whitaker help. The classifier now recognizes bare `help`, with an end-to-end + regression in `tests/e2e_exit_codes.rs`. + +- Observation: `env!("CARGO_BIN_EXE_")` is defined only for integration + tests of the package declaring the binary. Evidence: all four existing uses + are package-local, for example + `installer/tests/behaviour_cli/support.rs:206`. Impact: end-to-end tests must + live in the root package, which is why removing `--exclude whitaker` is a + required part of `EP-M0` rather than an optional tidy-up. + +- Observation: `cargo check` does not invoke the linker. + Evidence: the risk being tested is a _link_ error, per `src/lib.rs:4-6`. + Impact: the first draft's feasibility spike used `cargo check` and so could + never observe the failure it existed to detect. Replaced with + `cargo build --bins` and `cargo test --no-run`. + +- Observation: both documented flag conflicts are enforced by clap, not by + repository-owned code. Evidence: `conflicts_with` at + `installer/src/cli.rs:108`, `:113`, `:131`. Impact: a routing function can + never receive a conflicting input, so the first draft's Kani obligation was + vacuous. Cut. + +- Observation: nothing in the repository ever parses a release-asset name. + Evidence: `installer/src/binstall_metadata.rs` composes with `.replace()`; no + `rsplit_once`, `splitn`, or `strip_prefix` under `installer/src/`. Impact: + the first draft's Verus injectivity proof was load-bearing for nothing. Cut + and replaced with a finite disjointness test over the ten-element published + namespace. + +- Observation: nextest's `binary()` filter matches by binary name, not + package-qualified name. Evidence: `.config/nextest.toml` `default-filter` + excludes `binary(behaviour_cli)`. Impact: a new `behaviour_cli.rs` in any + package would be silently skipped. Drove the `behaviour_whitaker.rs` naming + and the `NEXTEST_PROFILE=ci` discipline. + +- Observation: `#[gtest]` must precede `#[rstest]` or the test runs twice. + Evidence: `googletest` 0.14.3 documentation. Impact: recorded as a convention + for `docs/developers-guide.md`. + +- Observation: the post-rebase repository test gate is still blocked by the + managed compiler wrapper and shared Cargo lock contention. Evidence: the + 2026-10-15 `make test` run completed 1,633 of 1,653 tests, with five failures + and one timeout. Dylint 6.0.4 reported `Found no rustc invocations` for UI + fixtures while using the managed `build-limits-rustc` wrapper; another UI + build timed out after waiting for shared Cargo locks. See + `/tmp/test-0c485c79-c21a-486e-b126-29c3ef23084f-3-5-1-root-whitaker-binary.out`. + Impact: formatting, typecheck, and lint passed, but the full test gate is + not green. The failure does not justify bypassing compiler admission or + hiding affected tests. No code changes were made during this validation. + +- Observation: `ortho_config` 0.9.0 exposes localized parsing and display + request detection with the signatures assumed by the M2 sketch. Evidence: + Firecrawl retrieved the pinned + [`LocalizedParse` API](https://docs.rs/ortho_config/0.9.0/ortho_config/trait.LocalizedParse.html) + and + [`is_display_request`](https://docs.rs/ortho_config/0.9.0/ortho_config/fn.is_display_request.html) + on 2026-10-15. `try_parse_localized_from(iter, &dyn Localizer)` returns + `Result` and `is_display_request(&clap::Error)` returns + `bool`. Impact: the planned error guard can be implemented and tested against + the library's real signature; no API adjustment is needed. + +- Observation: Kani 0.67.0 does not print the literal `Counterexample:` marker + for these reachability harnesses. Evidence: all three harness runs report + `VERIFICATION:- FAILED` and the expected named failed check, preceded by a + successful `assertion.1` containing the concrete selected token sequence. + Impact: `scripts/run-kani.sh` now checks that verifier-owned evidence instead + of relying on a version-specific marker; all three reachability cases execute + and the focused group exits successfully. The positive proof verified 360 + checks. Total verifier execution was 3.345 seconds; the 2,633-second command + elapsed time was Cargo compilation/admission and is tracked separately under + D-12. The updated runners pass `bash -n` and ShellCheck. + +- Observation: Kani's text report for the Ls-routing negative control names + the failed `Subcommand(Ls)` assertion but does not print the satisfying + model. Evidence: with `TokenTag::Ls` temporarily mapped to `Forward`, check + 64 of 361 fails at the Ls branch in `verify_classify_is_total_and_disjoint`; + evaluating the harness mapping gives the minimal witness + `raw = [1, 0, 0, 0, 0, 0]` and `length = 1`, i.e. `[Ls]`. Impact: the control + is non-vacuous and the regression is detectable, while the exact solver + assignment must be recorded as derived rather than quoted as emitted output. + The source was restored and its pre/post SHA-256 hashes match. + +- Observation: Verus sidecar inputs in this repository are compiled as binary + crates, so each file needs an empty `main()` inside `verus!`. Evidence: all + existing proof files define one, while the first CLI proof invocation failed + with `E0601` before verification. Adding the same entry point let the solver + start. It then remained CPU-bound for 650 seconds without output, exceeding + the plan's ten-minute verifier tolerance; it was stopped and produced no + proof verdict. Impact: the missing-entry-point failure is fixed, but the + unbounded proof is not validated. Further work requires an approved tolerance + decision or a separately authorized change to the verification plan. + +## Decision log + +- **D-1: Extract the Dylint driver library into `crates/whitaker_lint_core` as + `EP-M0`, and put the CLI in the root package rather than a new crate.** + Rationale: `cargo package -p whitaker` fails today, so the plan's headline + outcome is unreachable without it. The extraction also lets + `--exclude whitaker` come out of `TEST_EXCLUDES`, which is what makes + `CARGO_BIN_EXE_whitaker` usable and the end-to-end obligation runnable; it + removes the `rustc_private` hazard permanently; and it stops `ortho_config` + propagating into eleven lint crates and fifty cross-compiled builds. Putting + the CLI in the binary's own package is also the universal Rust idiom — + `cargo`, `rustup`, `ruff`, `uv`, and `cargo-dylint` all do it, and + `cargo-dylint` solves the same contamination the same way. Approved by the + requester before drafting. Date/Author: 2026-08-21. + +- **D-2: Resolve the `whitaker` name collision by forwarding unrecognized + arguments to `cargo dylint`.** The ambiguity tolerance fired here: the design + document anticipates wrapper removal but assigns it to 3.9.3, while a root + binary shadows the wrapper immediately. Alternatives considered — removing + wrapper generation now, renaming the script, or pulling `whitaker check` + forward from 3.5.2 — each either strands existing users or is a large scope + increase. Forwarding costs roughly thirty lines, breaks nobody, and keeps + this repository's own `make lint` working. It is a compatibility behaviour + with named consumers (Constraint 2) and a named removal point (3.9.3), not + compatibility theatre. Approved by the requester. Date/Author: 2026-08-21. + +- **D-3: Include `whitaker ls`, explicitly unstable.** The ambiguity tolerance + fired: `docs/roadmap.md:194` places `ls` at 3.8.1 ("Requires 3.6.2 and + 3.7.3") and `--json` at 3.8.2, while `CLI-DESIGN` §Public CLI surface + describes the end state. Both readings were presented. Decision: ship it now + as a rename of `whitaker-installer list`, with its output marked a regression + guard rather than a compatibility promise, and `EP-M2` recording that 3.8.1 + and 3.8.2 will replace it wholesale. Approved by the requester. Date/Author: + 2026-08-21. + +- **D-4: Exit codes are `0` success and display, `2` usage error, `1` + operational failure.** This is not a free choice: `Cli::parse()` in + `installer/src/main.rs:42` already exits 2 via clap on usage errors, and + Constraint 1 plus `EP-INV-PARITY` require matching it. The first draft left + this undecided and its sample code would have produced 1. Date/Author: + 2026-08-21, planning agent. + +- **D-5: Do not unify the installer's three dependency-injection styles.** + Rationale: a large refactor with its own risk profile and no requirement + driving it. Folding it in would blur the parity evidence `EP-INV-PARITY` + depends on. Date/Author: 2026-08-21, planning agent. + +- **D-6: Adopt `ortho_config` for localized parsing only.** The task brief asks + for `ortho_config` with localized help; `CLI-DESIGN` sequences the + configuration switch as migration step 3 and `docs/roadmap.md` gives it item + 3.6.3, which _requires_ 3.5.1. Wiring `whitaker.toml` discovery here would + take work from 3.6.3 and half-activate a model this plan cannot finish. + Counter-argument recorded: with `NoOpLocalizer` there is no observable + localization benefit at this milestone, so the dependency's roughly 26 new + lock entries buy a one-line call-site difference. It is retained because the + brief asks for it and because `EP-M0` confines the cost to the CLI binary. + Verify in `EP-M2` with a twenty-line spike that `#[derive(OrthoConfig)]` + composes with the clap-derived arguments, rather than leaving 3.6.3 to + discover that it does not. Date/Author: 2026-08-21, planning agent. + +- **D-7: Use `googletest` and `pretty_assertions` in new test files only.** + The brief authorizes both despite a sibling plan's opposite preference; + existing tests stay unchanged. Date/Author: 2026-08-21, planning agent. + +- **D-8: Cut the release-asset-name Verus proof and the routing-conflict Kani + harness; verify argv classification instead.** Both original obligations were + vacuous — see `Verification plan` and `Surprises & discoveries` for the + evidence. The forwarding behaviour introduced by D-2 creates a genuinely new + repository-owned decision procedure whose failure modes are severe in both + directions, which is what the two verification tools now target. Date/Author: + 2026-08-21, planning agent. + +- **D-9: Name the plan `3-5-1-root-whitaker-binary.md`.** The brief filename + said 6.5.1, but its task, branch, and PR title identify 3.5.1; confirmed by + the requester. Date/Author: 2026-08-21. + +- **D-10: Test the compiler-private core outside the workspace all-feature + invocation.** Cargo unifies `dylint-driver` through every lint crate, and its + compiler-private runtime cannot link into an ordinary integration-test + executable. A separate feature-free package invocation preserves each moved + test target and doctest without restoring the old `whitaker` exclusion or + weakening the Dylint driver pass. Date/Author: 2026-08-24, implementation + agent; supported by the H1 falsification experiment. + +- **D-11: Accept the 70-file tolerance breach for this plan.** The user resumed + implementation with the 80-file rebase delta and unfinished milestones + documented. This accepts the file-count overrun only; D-12 later changes the + net-line ceiling. PR #356 and branch ownership were verified in + `leynos/whitaker`. Date/Author: 2026-10-15, implementation supervisor. + +- **D-12: Raise the net-line ceiling to 7,000 for this plan only.** The user + approved the increase at 4,869 lines and reconfirmed it on 2026-10-17. This + changes no product scope or D-11 decision. Verifier tolerances measure Kani + or Verus execution, separately from compilation and admission; one recorded + Kani run took 4.73 seconds. Date/Author: 2026-10-16, implementation + supervisor, following explicit user approval. + +- **D-13: Keep installer metadata helpers source-compatible.** The root package + needs to expand cargo-binstall templates for its own package name and inspect + the root manifest from an integration test. Preserve the existing + `expand_pkg_url`, `expand_bin_dir`, and `load_cargo_toml` signatures; add + `expand_pkg_url_for`, `expand_bin_dir_for`, and `load_cargo_toml_from` for + the new callers. This avoids changing existing installer test and packaging + call-sites while keeping one template implementation. Date/Author: + 2026-10-17, implementation agent. + +- **D-14: Serialize the remaining nested-build UI tests.** The existing + `serial-dylint-ui` Nextest group omitted the parameterized + `rstest_helper_should_be_fixture` fixture and call-site tests, as well as two + `no_unwrap_or_else_panic` examples added after its filter was written. Under + the recorded shared Cargo target-lock contention, those tests ran + concurrently, held the cross-process fixture lock for more than 31 minutes, + and caused Dylint capture failures. Add only the observed test names to the + existing group so one workspace's UI builds do not queue behind sibling UI + tests that hold the same build lock. Keep the existing timeout and retry + policy unchanged; the full test gate will establish whether this resolves the + self-contention. Date/Author: 2026-10-17, implementation supervisor. + +- **D-15: Use the conventional module directory facade for installer + orchestration.** After extracting dry-run and exit adapters, the public + facade still exceeded the 400-line Dylint limit. Moving it under + `installer/src/orchestration/` resolved the size concern but Clippy's + `self_named_module_files` rule requires the facade to be named `mod.rs`. The + module is now `installer/src/orchestration/mod.rs`; child modules remain + adjacent and callers retain the same `whitaker_installer::orchestration` + path. Its explicit `#[path]` to the test tree is adjusted to + `../tests/mod.rs`, since child module paths resolve from the directory + facade. Date/Author: 2026-10-17, implementation supervisor. + +- **D-16: Split root CLI parsing from command execution.** The `cli` module + exceeded the 400-line Dylint limit, and the combined install/list handling in + `run_command` exceeded the conditional-cluster threshold. Keep + `whitaker::cli` as the stable in-workspace facade while moving parser types + and helpers to `cli/parser.rs` and process-facing execution to + `cli/runner.rs`. Use command-specific helpers and a single result mapping per + command rather than nested matches. This preserves routes, output, and exit + behaviour while giving each responsibility an independently sized module. + Date/Author: 2026-10-17, implementation supervisor. + +- **D-17: Patch Dylint test capture without bypassing compiler admission.** + Dylint 6.0.4 checks only the first command token for `rustc`, so its UI + harness loses builds that begin with the managed wrapper. Upstream issue 1696 + remains open. Patch the vendored 6.0.4 parser to find the compiler token and + retain the command suffix; keep the dependency scoped outside the workspace + members and remove the patch when upstream supports wrappers. This restores + existing UI tests without excluding them or disabling the managed compiler + wrapper. Date/Author: 2026-10-17, implementation supervisor. + +- **D-18: Stream installation output and resolve Dylint paths by precedence.** + Pass stderr through the install service so progress remains visible during + downloads and builds. For forwarded Dylint invocations, preserve a non-empty + `DYLINT_LIBRARY_PATH`; otherwise use an existing local build directory, then + an existing host-specific prebuilt directory. Do not inject a path when no + candidate exists. This preserves user overrides and prebuilt installs while + avoiding nonexistent or empty build paths. Date/Author: 2026-10-17, + implementation supervisor, following CodeRabbit review. + +- **D-19: Keep the unverified CLI Verus proof out of the default group.** The + CLI proof exceeded the approved verifier-time tolerance without a verdict. + Keep it available through the focused `cli` group, but omit it from `all` so + the default `make verus` run does not imply that the unverified proof is an + accepted gate. Restore it to `all` only after it verifies within the agreed + tolerance and its negative control is recorded. Date/Author: 2026-10-17, + implementation supervisor, following CodeRabbit review. + +- **D-20: Persist exact install paths for forwarded Dylint commands.** A root + CLI invoked outside the Whitaker checkout cannot infer a custom target + directory from the active toolchain or default staging roots. Record the + canonical staging path per toolchain at the installer's shared successful + completion point. Preserve a non-empty explicit `DYLINT_LIBRARY_PATH`; when + absent, pass every extant recorded path so Dylint selects the matching + toolchain, then fall back to the existing default candidates. Lock the JSON + record during read-modify-write. Date/Author: 2026-10-17, implementation + supervisor, following CodeRabbit review. + +- **D-21: Make record failures mode-sensitive.** The legacy wrapper embeds its + staging path and warns on record failure; root install needs its record for + forwarding and fails instead. Date/Author: 2026-10-17, following review. + +- **D-22: Recover corrupt records only on successful install.** Keep + `installed_paths` strict; successful install replaces malformed JSON. Derive + snapshots from the pinned toolchain, gate XDG tests, and ship the vendored + helper's selected MIT notice. Date/Author: 2026-10-17, following review. + +## Outcomes & retrospective + +- **EP-M0, 2026-08-24.** Complete. Extracted the compiler-private driver into + `whitaker_lint_core`; root metadata and a feature-free core test pass make + the CLI package publishable and tested. +- **EP-M1, 2026-08-24.** Complete. Installer orchestration is library-owned; + the old binary is a thin composition root, protected by its dry-run snapshot. +- **Status, 2026-10-17.** CLI, binstall metadata, ADR, and user/developer docs + are implemented; Kani passed. Verus exceeded tolerance and name reservation + remains unauthorized, so the plan stays `IN PROGRESS`. + +To be completed at each milestone boundary and at completion. Before setting +this plan to `COMPLETE`, reconcile every entry in `Surprises & discoveries` +against `docs/whitaker-cli-design.md`. In particular, `CLI-REQ-FWD` has no +upstream source today and **must** be added to the design document, since +forwarding is a public behaviour carrying a compatibility commitment. Do not +mark the plan `COMPLETE` while any upstream change or deviation is unrecorded. + +## Revision note + +Revisions 2–3 and D-6–D-12 record design decisions and the package baseline. diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md new file mode 100644 index 000000000..5f917964d --- /dev/null +++ b/docs/ortho-config-users-guide.md @@ -0,0 +1,573 @@ +# OrthoConfig user's guide + +Configuration should not be the hardest part of writing a command-line +application. OrthoConfig describes settings as a Rust struct, then loads that +struct from defaults, a configuration file, environment variables, and +command-line arguments. + +This guide starts with a small working CLI and grows it one practical task at a +time. Stop as soon as the application has what it needs. + +## Install OrthoConfig + +OrthoConfig needs Serde to turn merged values into the application's +configuration type. Add `clap` when the application defines its own command or +subcommand parser: + + +```toml +[dependencies] +clap = { version = "4.5", features = ["derive"] } +ortho_config = "0.9.0" +serde = { version = "1.0", features = ["derive"] } +``` + +The default features support TOML and the JSON-backed merge machinery used by +the derive. Optional `json5`, `yaml`, and `metrics` features are covered later. + +## Build the first layered CLI + +Start with one struct. The `prefix` is used for environment variables and for +the default file-discovery names. A trailing underscore is conventional and +keeps names such as `ACME_PORT` easy to read. + + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Config { + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, + + #[ortho_config(default = 8080, cli_short = 'p')] + port: u16, + + #[ortho_config(default = String::from("info"))] + log_level: String, +} + +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!( + "host={} port={} log_level={}", + config.host, config.port, config.log_level + ); + Ok(()) +} +``` + +That one definition provides three spellings for each field: + +| Rust field | Command line | Environment | TOML | +| ----------- | ---------------- | ---------------- | ----------- | +| `host` | `--host` | `ACME_HOST` | `host` | +| `port` | `--port` or `-p` | `ACME_PORT` | `port` | +| `log_level` | `--log-level` | `ACME_LOG_LEVEL` | `log_level` | + +_Table 1: Rust fields and their command-line, environment, and TOML names._ + +Values are merged from lowest to highest precedence: + +1. `#[ortho_config(default = ...)]` values; +2. configuration files; +3. environment variables; and +4. command-line arguments. + +This means a checked-in file can provide team defaults, an environment variable +can adapt them for a deployment, and a one-off CLI option can override both. + +## See the configuration surface + +Different sources suit different moments. Start with durable team settings in +`.acme.toml`: + + +```toml +host = "0.0.0.0" +port = 9000 +log_level = "debug" +``` + +At deployment time, an environment variable can change the host without +rewriting the file. For a one-off run, a CLI option can change the port again: + + +```console +$ ACME_HOST=api.internal cargo run -- --port 3000 +host=api.internal port=3000 log_level=debug +``` + +The result shows all three surfaces working together: `log_level` comes from +TOML, `ACME_HOST` supplies `host`, and `--port` wins for `port`. The command +uses POSIX shell syntax; in PowerShell, set `$env:ACME_HOST = "api.internal"` +before running the same Cargo command. + +TOML is available by default. Enable the `yaml` or `json5` crate feature when +those formats are a better fit for application users; the +[file-format section](#enable-another-file-format) covers the details. + +By default, discovery checks an explicit `--config-path`, the +`ACME_CONFIG_PATH` environment variable, project and home dotfiles, and the +platform configuration directory. Explicitly requested files are required: a +missing `--config-path` is an error rather than a silent fallback. + +TOML naturally handles lists and nested values. For example, an application +could add `workers: Vec` and `labels: BTreeMap` to its +configuration struct, then use: + + +```toml +[[workers]] +name = "queue-a" +concurrency = 4 + +[[workers]] +name = "queue-b" +concurrency = 2 + +[labels] +region = "eu-west" +tier = "worker" +``` + +For vectors, `merge_strategy = "append"` appends higher-precedence values; +`merge_strategy = "replace"` replaces the collection. Use +`merge_strategy = "keyed"` for keyed collection merging. Choose the policy +deliberately when operators may combine file, environment, and CLI values. + +Configuration files can also contain `extends` entries. Relative paths are +resolved from the file that declares them, and parent layers are merged before +the child. OrthoConfig reports a missing parent with its absolute path and the +referencing file so the failure is actionable. + +## Make discovery match the application + +Application names do not need to bend around OrthoConfig's defaults. Put the +discovery contract beside the struct when the public flag or filenames are part +of the CLI design: + + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config( + prefix = "ACME_", + discovery( + app_name = "acme-server", + config_file_name = "server.toml", + dotfile_name = ".acme-server.toml", + project_file_name = ".acme-server.toml", + config_cli_long = "config", + config_cli_short = 'c', + config_cli_visible = true + ) +)] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} + +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!("port={}", config.port); + Ok(()) +} +``` + +This application accepts `--config` and `-c`, reads `ACME_CONFIG_PATH`, looks +for `.acme-server.toml` in project locations, and uses `server.toml` in +platform configuration directories. Keeping these choices in the derive also +exposes them through `OrthoConfigDocs`. + +### Handle every `load_first` outcome + +`ConfigDiscovery::load_first` distinguishes three outcomes. `Ok(Some(...))` +contains the first successfully parsed candidate. `Ok(None)` means discovery +had no candidates to try. `Err(...)` means candidates existed but none loaded +successfully; surface or map that error rather than treating it as absence: + + +```rust +use ortho_config::{ConfigDiscovery, OrthoResult}; + +fn load_discovered_config(discovery: &ConfigDiscovery) -> OrthoResult<()> { + match discovery.load_first() { + Ok(Some(_config)) => { + // Merge or deserialize the discovered Figment value. + println!("discovery=loaded"); + Ok(()) + } + Ok(None) => { + // Continue with application defaults. + println!("discovery=absent"); + Ok(()) + } + Err(error) => Err(error), + } +} + +fn main() -> OrthoResult<()> { + let discovery = ConfigDiscovery::builder("acme").build(); + load_discovered_config(&discovery) +} +``` + +## Test discovery without changing the process environment + +Tests that mutate environment variables interfere with one another. Build a +`ConfigDiscovery` with `MapEnv` instead. Each test owns its values and can run +in parallel: + + +```rust +use ortho_config::{ConfigDiscovery, MapEnv}; +use std::sync::Arc; + +fn main() { + let environment = Arc::new( + MapEnv::new() + .with_var("ACME_CONFIG", "/srv/acme/server.toml") + .with_var("HOME", "/home/tester"), + ); + + let discovery = ConfigDiscovery::builder("acme") + .env_var("ACME_CONFIG") + .env_source(environment) + .clear_project_roots() + .build(); + + assert_eq!( + discovery.candidates().first().map(|path| path.as_path()), + Some(std::path::Path::new("/srv/acme/server.toml")) + ); + println!("candidate=/srv/acme/server.toml"); +} +``` + +`ProcessEnv` remains the default, so production applications do not need to +change. `EnvSource` deliberately supports lookup by name but not enumeration; +discovery cannot accidentally scan or log unrelated environment values. + +## Give each subcommand its own settings + +Many CLIs have global options plus commands with different configuration. Derive +`OrthoConfig` for each subcommand's argument struct and merge only the +selected command: + + +```rust +use clap::{Parser, Subcommand}; +use ortho_config::{OrthoConfig, OrthoResult, SubcmdConfigMerge}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Parser)] +#[command(name = "acme")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Serve(ServeConfig), +} + +#[derive(Debug, Default, Parser, Deserialize, Serialize, OrthoConfig)] +#[command(name = "serve")] +#[ortho_config(prefix = "ACME_SERVE_")] +struct ServeConfig { + #[arg(long)] + port: Option, +} + +fn main() -> OrthoResult<()> { + match Cli::parse().command { + Command::Serve(cli) => { + let config = cli.load_and_merge()?; + println!("port={:?}", config.port); + } + } + Ok(()) +} +``` + +For an enum with many variants, derive `SelectedSubcommandMerge` and use +`load_globals_and_merge_selected_subcommand`. The generated match keeps the +entry point small. Add `#[ortho_config(cli_default_as_absent)]` to a field when +a `clap` default should not override a value supplied by a file or environment +variable. + +## Handle errors at the application boundary + +Library APIs return `OrthoResult`, whose error is an `Arc`. +Propagate it while loading, then render or map it where the application owns +the user experience: + + +```rust +use ortho_config::{OrthoConfig, OrthoError}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, OrthoConfig)] +struct Config { + port: u16, +} + +fn main() { + match Config::load_from_iter(["acme", "--port", "not-a-number"]) { + Ok(config) => println!("port={}", config.port), + Err(error) => match error.as_ref() { + OrthoError::CliParsing(clap_error) => eprintln!("{clap_error}"), + other => eprintln!("configuration error: {other}"), + }, + } +} +``` + +Preserve `clap`'s display-only exits for `--help` and `--version`; use +`is_display_request` when a wider application error layer needs to distinguish +them. `OrthoError::try_aggregate` combines independent validation failures +without inventing an error for an empty collection. The result extension traits +`OrthoResultExt`, `OrthoMergeExt`, and `ResultIntoFigment` keep conversions +explicit at integration boundaries. + +## Localize help and parse failures together + +Localization is most reliable when the command metadata is translated before +parsing and any resulting error goes through the same localizer. +`LocalizedParse` provides that path for the common case: + + +```rust +use clap::Parser; +use ortho_config::{LocalizedParse, NoOpLocalizer}; + +#[derive(Debug, Parser)] +#[command(name = "acme", bin_name = "acme")] +struct Cli { + #[arg(long)] + verbose: bool, +} + +fn main() -> Result<(), clap::Error> { + let localizer = NoOpLocalizer::new(); + let cli = Cli::try_parse_localized_from( + ["acme", "--verbose"], + &localizer, + )?; + assert!(cli.verbose); + println!("verbose={}", cli.verbose); + Ok(()) +} +``` + +Use `FluentLocalizer` for translated catalogues. Use `LocalizeCmd::with_base` +with `parse_localized_command` when catalogue identifiers must use an explicit +root rather than the binary name. Missing translations fall back to the original +`clap` text and emit a warning event, so users still receive a useful error. + +## Add production diagnostics + +OrthoConfig emits structured `tracing` events for discovery attempts, selected +files, skips, and failures. The library does not install a subscriber; the +binary should do that once during start-up. Add the subscriber with its +environment-filter support: + + +```toml +[dependencies] +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +``` + + +```rust +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::Deserialize; + +#[derive(Deserialize, OrthoConfig)] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} + +fn main() -> OrthoResult<()> { + tracing_subscriber::fmt() + .with_env_filter("ortho_config=debug") + .with_writer(std::io::stderr) + .try_init() + .ok(); + + let config = Config::load()?; + println!("port={}", config.port); + Ok(()) +} +``` + +Do not log configuration values or candidate paths around these events. The +crate's own diagnostics avoid path and value fields because configuration +locations and values may be sensitive. + +Metrics are a low-cost opt-in when the application already has a `metrics` +recorder: + + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["metrics"] } +``` + +The feature emits bounded counters such as discovery attempts, outcomes, and +failures. OrthoConfig never installs a recorder, and enabling the feature does +nothing visible until the application installs one. + +## Generate help from the same metadata + +`#[derive(OrthoConfig)]` also implements `OrthoConfigDocs`. The metadata +records fields, source names, precedence, discovery, defaults, and nested +subcommands. Derive `OrthoConfigSubcommandDocs` on a `clap::Subcommand` enum so +the generated tree includes every variant. + +Inspect the metadata in code: + + +```rust +use ortho_config::{OrthoConfig, OrthoConfigDocs}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Config { + /// Address on which the service listens. + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, +} + +fn main() { + let metadata = Config::get_doc_metadata(); + assert_eq!(metadata.fields.len(), 1); + assert_eq!(metadata.fields[0].name, "host"); + println!("field={}", metadata.fields[0].name); +} +``` + +Or use `cargo-orthohelp` to emit intermediate representation (IR), Unix man +pages, PowerShell help, compact agent context, or all formats: + + +```console +cargo orthohelp --package hello_world --format agent-context +``` + +The tool builds a small bridge against the selected package. Keep the root +configuration type public and ensure its documentation metadata is available +from the selected library or binary target. `--format all` includes agent +context as well as IR, man pages, and PowerShell output. + +## Offer a compact contract to automation + +Agent context complements human help with a small, stable JSON description of +commands, inputs, output modes, interaction, and mutation boundaries. A common +application convention is `context --json`; `cargo-orthohelp` uses +`--format agent-context` for generation. + +The smallest valid context created by `AgentContext::new("acme")` serializes to +this shape: + + +```json +{ + "schema_version": "1", + "kind": "acme.agent_context", + "package": "acme", + "commands": [], + "profiles": { "supported": false }, + "feedback": { "supported": false }, + "policy": { "agent_native": "warn" }, + "skill_manifests": [] +} +``` + +Fill `AgentCommand` entries only with claims the executable honours. +`SkillManifest` and `SkillCommandRef` link skills to real commands. They do not +replace command validation or grant an agent capabilities that the CLI does not +have. + +## Use an aliased dependency + +Cargo permits dependency aliases. In v0.9.0 the derive macros can generate +paths through that alias, which is useful in workspaces that reserve the +canonical crate name: + + +```toml +[dependencies] +config_layer = { package = "ortho_config", version = "0.9.0" } +serde = { version = "1.0", features = ["derive"] } +``` + +Name the alias on every type that derives an OrthoConfig macro: + + +```rust +use config_layer::{OrthoConfig, OrthoResult}; +use serde::Deserialize; + +#[derive(Deserialize, OrthoConfig)] +#[ortho_config(crate = "config_layer", prefix = "ACME_")] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} + +fn main() -> OrthoResult<()> { + let config = Config::load_from_iter(["acme"])?; + assert_eq!(config.port, 8080); + println!("port={}", config.port); + Ok(()) +} +``` + +The same attribute is supported by `SelectedSubcommandMerge`. OrthoConfig +re-exports the dependencies used by generated code, so a derive-only consumer +does not need direct `figment`, `uncased`, `xdg`, or format-parser dependencies. + +## Enable another file format + +TOML is enabled by default. Enable `json5` or `yaml` when users already work in +that format. YAML uses YAML 1.2 semantics in v0.9.0: legacy words such as `yes` +and `on` remain strings, and duplicate mapping keys are rejected. + + +```yaml +enabled: yes +mode: on +port: 8080 +``` + +Enable YAML with `features = ["yaml"]`; this also requires the `serde_json` +feature, which is part of the default feature set. If defaults are disabled, +enable both explicitly. Treat a change from v0.8.0 YAML parsing as a data +migration and run representative production files through v0.9.0 before +deploying. + +## A practical path from here + +For a new CLI, begin with the first layered struct and add only the required +sections. A typical progression is: + +1. choose stable CLI and environment names; +2. add a project file for durable settings; +3. customize discovery if the defaults are not part of the public interface; +4. split independent commands into subcommand configurations; +5. localize help and initialize tracing at the application boundary; and +6. generate human and agent documentation once the command surface stabilizes. + +The [Whitaker CLI design](whitaker-cli-design.md) shows how these pieces fit +the project's command surface. The +[API documentation](https://docs.rs/ortho_config) provides complete type and +method signatures. diff --git a/docs/publishing.md b/docs/publishing.md index 6bca59f28..b1922132d 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,10 +1,9 @@ -# Publishing the Whitaker installer +# Publishing Whitaker crates -This guide explains how to publish the Whitaker installer to -[crates.io](https://crates.io). The installer is published under the crate name -[whitaker-installer](https://crates.io/crates/whitaker-installer) -and depends on the shared support crate -[whitaker-common](https://crates.io/crates/whitaker-common). +This guide explains how to publish Whitaker packages to +[crates.io](https://crates.io). The dependency order is `whitaker-common`, +`whitaker-installer`, then the root `whitaker` CLI. The root CLI uses the +installer library; the installer in turn uses the shared support crate. ## Preconditions @@ -15,11 +14,11 @@ and depends on the shared support crate ## Version and metadata -1. Bump the version in `installer/Cargo.toml`. -2. Bump the version in `common/Cargo.toml`. -3. Update the workspace dependency versions in `Cargo.toml` so the workspace - points to the same release for both published crates. -4. Regenerate the lockfile if needed. +1. Bump the versions in `common/Cargo.toml`, `installer/Cargo.toml`, and the + root `Cargo.toml` together for a coordinated release. +2. Update the workspace dependency versions in `Cargo.toml` to match the + published dependencies. +3. Regenerate the lockfile if needed. ## Pre-publish validation @@ -27,12 +26,12 @@ Run the project publish gate to ensure production-like builds and packaging succeed: ```sh -make publish-check PUBLISH_PACKAGES="whitaker-common whitaker-installer" +make publish-check PUBLISH_PACKAGES="whitaker-common whitaker-installer whitaker" ``` This target builds the workspace, runs tests with the pinned toolchain, and -packages the crates named in `PUBLISH_PACKAGES` for inspection, which here -means both `whitaker-common` and `whitaker-installer`. The target runs under +packages the crates named in `PUBLISH_PACKAGES` for inspection, which here means +`whitaker-common`, `whitaker-installer`, and `whitaker`. The target runs under `set -eu`, so any failed step aborts the gate immediately rather than continuing with a partially built or stale toolchain. @@ -72,12 +71,18 @@ Perform a dry run to see the exact artefacts that would be uploaded: ```sh cargo publish -p whitaker-common --dry-run cargo publish -p whitaker-installer --dry-run +cargo publish -p whitaker --dry-run ``` Review the package contents in the output. If files need to be excluded or -included, adjust `common/Cargo.toml` for `whitaker-common` and -`installer/Cargo.toml` for `whitaker-installer` with `include` or `exclude` -settings, then repeat the relevant dry run. +included, adjust `common/Cargo.toml` for `whitaker-common`, or the appropriate +package manifest for the other crates, then repeat the relevant dry run. + +Publishing the root `whitaker` package reserves that crates.io name. Perform +that first publication only after the release gates and package contents have +been reviewed. It does not publish GitHub release archives for the executable; +those are a separate delivery in roadmap item 3.5.3. Until then, +`cargo binstall whitaker` falls back to building the crate from source. ## Publish @@ -86,13 +91,14 @@ When ready, publish from the repository root: ```sh cargo publish -p whitaker-common cargo publish -p whitaker-installer +cargo publish -p whitaker ``` ## After publishing - Confirm the new releases appear on crates.io for the - [whitaker-common](https://crates.io/crates/whitaker-common) and - [whitaker-installer](https://crates.io/crates/whitaker-installer) crates. + [whitaker-common](https://crates.io/crates/whitaker-common), [whitaker-installer](https://crates.io/crates/whitaker-installer), + and [whitaker](https://crates.io/crates/whitaker) crates. - Tag the release if Git tags are maintained for published versions. - Announce the release through the agreed channels (team chat, mailing list, or social updates). diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 4b0c42627..2839c0bb7 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -26,9 +26,10 @@ formal verification helpers. | `installer/` | The `whitaker-installer` crate, packaging logic, and installer-specific behaviour tests. | | `scripts/` | Repository automation for checksums, Kani, and Verus setup and execution. | | `skills/` | Agent-facing skills that document repeatable maintenance and remediation workflows. | -| `src/` | Root library code that assembles shared lint registration and workspace-facing APIs. | +| `src/` | Root `whitaker` CLI, its command policy, installer adapters, and application ports. | | `suite/` | The suite crate used to package and expose the lint set coherently. | -| `tests/` | Workspace-level integration, behaviour, UI harness, and workflow tests. | +| `tests/` | Root CLI integration, behaviour, packaging, and workflow tests. | +| `vendor/` | Patched third-party crates, currently `dylint_testing` with compiler-wrapper support, until an upstream fix is available. | | `verus/` | Verus proof sidecars and formal verification experiments. | _Table 1: Top-level repository layout._ @@ -65,7 +66,8 @@ small set of support crates: `no_expect_outside_tests/`, `no_std_fs_operations/`, `no_unwrap_or_else_panic/`, `rstest_helper_should_be_fixture/`, and `test_must_not_have_example/`. -- Support crates such as `whitaker_clones_core/` and `whitaker_sarif/`. +- Support crates such as `whitaker_clones_core/`, `whitaker_sarif/`, and + `whitaker_lint_core/`, which owns the compiler-private driver and UI harness. - Vendored compatibility crates such as `rustc_ast/`, `rustc_hir/`, and other `rustc_*` crates used to align with the Rust compiler interfaces Whitaker targets. @@ -85,14 +87,18 @@ as a product rather than only as a library. ### `src/` and `suite/` -The repository root `src/` directory holds the top-level library entry points -and shared lint-registration glue. The `suite/` crate packages the lint set as -an integrated suite and carries its own source and tests. +The repository root `src/` directory contains the `whitaker` executable and the +CLI library. `src/cli/` owns parsing, routing, and exit policy; `src/ports.rs` +defines the application boundary; and `src/adapters/` connects that boundary to +installer and process operations. The compiler-private Dylint driver lives +separately in `crates/whitaker_lint_core/`. The `suite/` crate packages the +lint set as an integrated suite and carries its own source and tests. ## Testing and verification assets -- `tests/` holds workspace-level integration tests, behaviour-driven tests, - feature files, UI harness coverage, and workflow validation support. +- `tests/` holds behaviour-driven and end-to-end tests for the root CLI and its + packaging metadata, feature files, and workflow validation support. The + Dylint UI harness and fixtures live in `crates/whitaker_lint_core/tests/`. - `common/tests/` and the per-crate `tests/` directories hold crate-scoped behavioural and integration coverage. - Per-crate `ui/`, `ui-cy/`, `ui-gd/`, and similar directories store compiler diff --git a/docs/roadmap.md b/docs/roadmap.md index 12712933f..3c318899d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -142,7 +142,7 @@ ### 3.5. Unified CLI foundation -- [ ] 3.5.1. Add a root `whitaker` binary and move the current installer +- [x] 3.5.1. Add a root `whitaker` binary and move the current installer behaviour behind an internal library boundary. See [Whitaker CLI design](whitaker-cli-design.md) §Public CLI surface and §Compatibility and migration. Requires 3.2.1. diff --git a/docs/users-guide.md b/docs/users-guide.md index a84c83330..fa39db2fe 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -76,6 +76,19 @@ environment-variable workaround. `DYLINT_LIBRARY_PATH` instructions instead) - `--no-update` — Don't update existing repository clone +### Root CLI + +The root `whitaker` command provides `install` and transitional `ls` commands, +including `whitaker ls --json`; other arguments, such as `--all`, are forwarded +to `cargo dylint`. From a checkout, install it with `cargo install --path .`. +Crates.io packages and binstall archives are pending. When both `whitaker` +binaries are on `PATH`, the first wins; `install` creates only `whitaker-ls`. + +The root `whitaker` CLI also remembers each successful install's staging path +by Rust toolchain. This lets `whitaker --all` find libraries installed with a +custom `--target-dir`, including when run from another project. An explicitly +set `DYLINT_LIBRARY_PATH` continues to override the recorded paths. + ### Adding Whitaker to a project Add the following to the workspace `Cargo.toml`: diff --git a/docs/whitaker-cli-design.md b/docs/whitaker-cli-design.md index 1c50b7ceb..dc162e06d 100644 --- a/docs/whitaker-cli-design.md +++ b/docs/whitaker-cli-design.md @@ -15,7 +15,7 @@ while the user guide lists `bumpy_road_function` among shipped lints, and the installer currently reports that there are no experimental lints in the release.[^1][^2] The new CLI should remove that ambiguity entirely. -This document proposes a single public `whitaker` executable that manages +This document defines a single public `whitaker` executable that manages Whitaker lint bundles, Dylint dependencies, configuration discovery, and diagnostics directly. The design treats localization and accessibility as first-class concerns rather than optional polish. @@ -56,7 +56,7 @@ first-class concerns rather than optional polish. ## Public CLI surface -Whitaker should expose one public executable for the core suite: +The target public surface for the core suite is: ```plaintext $ whitaker --help @@ -70,10 +70,41 @@ Commands: help Print this message or the help of the given subcommand ``` -`whitaker` becomes a real Rust binary. `whitaker-ls` disappears in favour of -`whitaker ls`. `whitaker-installer` survives for one compatibility release as a -thin shim that prints a deprecation notice and dispatches to -`whitaker install`, then it is removed. +The first delivery in roadmap item 3.5.1 implements `install`, `ls`, root +help/version, and legacy Dylint argument forwarding. This transitional surface +is recorded in [ADR 005](adr-005-whitaker-cli-boundary.md). The staged status +of the other commands is explicit: + +| Command or behaviour | Status in 3.5.1 | +| -------------------- | ---------------------------------------------------------- | +| `whitaker install` | Implemented by the shared installer orchestration. | +| `whitaker ls` | Implemented with the installer's current inventory output. | +| `whitaker --all ...` | Forwarded to `cargo dylint` for compatibility. | +| `whitaker check` | Planned for roadmap item 3.5.2. | +| `whitaker doctor` | Planned for a later CLI phase. | + +The root package owns the real `whitaker` executable. The existing +`whitaker-installer` binary remains functional and keeps its current wrapper +generation behaviour during this migration. A root `whitaker install` call uses +the same installation flow but creates only `whitaker-ls`; it never creates a +wrapper named `whitaker` over the executable. The generated `whitaker-ls` +script remains available during the transition. + +### Legacy Dylint forwarding + +The compatibility requirement is to preserve existing calls such as +`whitaker --all`, including the repository's Makefile and CI invocations. When +the first non-global argument is not `install`, `ls`, root help, or root +version, the root executable passes every argument after its own executable +name to `cargo dylint` in order. This includes the `--` separator and any +arguments after it. The exit status from `cargo dylint` is returned to the +caller; a failure to start the subprocess is an operational error with exit +code 1. Root help and version are handled by Whitaker only when they precede a +Dylint option, so `whitaker --all --version` reaches Dylint. + +This is `CLI-REQ-FWD`, a compatibility requirement derived from the existing +user workflow rather than an upstream CLI specification. Removing forwarding is +deferred until roadmap item 3.9.3, after `whitaker check` supersedes it. ## Accessibility and localization requirements @@ -488,6 +519,19 @@ matches the installed CLI release, or an explicitly requested revision. That keeps installed metadata honest when the product later reports source revision and build date. +Installation progress and diagnostics are written to stderr as they are +produced, including during downloads and source builds. When forwarding a +legacy invocation to `cargo dylint`, the CLI preserves a non-empty +`DYLINT_LIBRARY_PATH` override. Otherwise it passes every extant recorded +staging path so Dylint can select the matching toolchain. Successful installs +persist the canonical staging directory per toolchain in Whitaker's data +directory, allowing custom `--target-dir` installs to work from projects +outside the checkout. If the record is absent or has no extant paths, the CLI +selects an existing source-build staging directory for the active toolchain, +then an existing prebuilt bundle directory for the host target. If neither +exists, it leaves the environment unset so Dylint can report its usual +missing-library error. + `--build-only` should be renamed to `--build-from-source`. The old flag may survive as a hidden compatibility alias for one or two releases. `--skip-wrapper` should be removed because wrapper generation disappears. diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index af0e75b02..f88cd0d5c 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -37,7 +37,9 @@ lint crate, a feasibility study for a **Bumpy Road** detector, and a phased ├─ Cargo.toml # workspace + dylint metadata ├─ rust-toolchain.toml # nightly pinned for lints ├─ dylint.toml # optional defaults per lint +├─ src/ # root `whitaker` CLI and application ports ├─ crates/ +│ ├─ whitaker_lint_core/ # internal compiler-private Dylint driver │ ├─ function_attrs_follow_docs/ │ ├─ no_expect_outside_tests/ │ ├─ public_fn_must_have_docs/ @@ -47,7 +49,7 @@ lint crate, a feasibility study for a **Bumpy Road** detector, and a phased │ ├─ module_max_lines/ │ └─ no_unwrap_or_else_panic/ # separate crate ├─ suite/ # aggregated dylint library (optional) -├─ installer/ # optional convenience binary +├─ installer/ # installer library and legacy binary ├─ common/ # shared helpers for lints └─ ci/ # CI workflows ``` @@ -1034,8 +1036,8 @@ declare_combined_late_lint_pass!(SuitePass => [ NoStdFsOperations, ]); -#[no_mangle] -pub extern "C" fn register_lints(sess: &Session, store: &mut LintStore) { +#[no_mangle] pub extern "C" fn register_lints(sess: &Session, store: &mut +LintStore) { dylint_linting::init_config(sess); store.register_lints(&[ function_attrs_follow_docs::FUNCTION_ATTRS_FOLLOW_DOCS, diff --git a/dylint.toml b/dylint.toml index 1b3a1a140..a15bd0f34 100644 --- a/dylint.toml +++ b/dylint.toml @@ -10,8 +10,8 @@ # The common crate contains test support utilities that require ambient access # for fixture management. Use the Rust crate identifier here: `tcx.crate_name()` # reports `whitaker_common`, not the hyphenated Cargo package name. The -# whitaker crate contains the UI test harness which needs ambient access to -# copy compiled lint libraries during test execution. +# `whitaker_lint_core` contains the UI test harness which needs ambient access +# to copy compiled lint libraries during test execution. # # Integration-test targets compile as their own crates named after the test # file, so they are not covered by the `whitaker_common` entry above. @@ -35,7 +35,7 @@ excluded_crates = [ "whitaker_installer", "whitaker_common", - "whitaker", + "whitaker_lint_core", "i18n_packaging", "whitaker_package_lints", "whitaker_package_installer", diff --git a/installer/Cargo.toml b/installer/Cargo.toml index c6e4107a1..396eee854 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -78,6 +78,7 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] +insta = { workspace = true } whitaker_test_macros = { workspace = true } rustix = { version = "1.1.4", features = ["process"] } mockall = { workspace = true } diff --git a/installer/src/binstall_metadata.rs b/installer/src/binstall_metadata.rs index 8bc1797cc..04189d5bb 100644 --- a/installer/src/binstall_metadata.rs +++ b/installer/src/binstall_metadata.rs @@ -43,13 +43,31 @@ pub const WINDOWS_OVERRIDE_TARGET: &str = "x86_64-pc-windows-msvc"; /// ``` #[must_use] pub fn expand_pkg_url(version: &str, target: &str) -> String { + expand_pkg_url_for("whitaker-installer", version, target) +} + +/// Expand the `pkg-url` template for any package name, version, and target. +/// +/// The archive format follows the same target override as +/// [`expand_pkg_url`]. +/// +/// # Examples +/// +/// ``` +/// use whitaker_installer::binstall_metadata::expand_pkg_url_for; +/// +/// let url = expand_pkg_url_for("whitaker", "0.2.7", "x86_64-unknown-linux-gnu"); +/// assert!(url.contains("/whitaker-x86_64-unknown-linux-gnu-v0.2.7.tgz")); +/// ``` +#[must_use] +pub fn expand_pkg_url_for(package: &str, version: &str, target: &str) -> String { let archive_format = if target == WINDOWS_OVERRIDE_TARGET { WINDOWS_PKG_FMT } else { DEFAULT_PKG_FMT }; PKG_URL_TEMPLATE - .replace("{name}", "whitaker-installer") + .replace("{name}", package) .replace("{version}", version) .replace("{target}", target) .replace("{archive-format}", archive_format) @@ -71,16 +89,33 @@ pub fn expand_pkg_url(version: &str, target: &str) -> String { /// ``` #[must_use] pub fn expand_bin_dir(version: &str, target: &str) -> String { + expand_bin_dir_for("whitaker-installer", version, target) +} + +/// Expand the `bin-dir` template for any package name, version, and target. +/// +/// The binary name is the package name, with `.exe` appended for Windows. +/// +/// # Examples +/// +/// ``` +/// use whitaker_installer::binstall_metadata::expand_bin_dir_for; +/// +/// let dir = expand_bin_dir_for("whitaker", "0.2.7", "x86_64-unknown-linux-gnu"); +/// assert_eq!(dir, "whitaker-x86_64-unknown-linux-gnu-v0.2.7/whitaker"); +/// ``` +#[must_use] +pub fn expand_bin_dir_for(package: &str, version: &str, target: &str) -> String { let bin = if target == WINDOWS_OVERRIDE_TARGET { - "whitaker-installer.exe" + format!("{package}.exe") } else { - "whitaker-installer" + package.to_owned() }; BIN_DIR_TEMPLATE - .replace("{name}", "whitaker-installer") + .replace("{name}", package) .replace("{version}", version) .replace("{target}", target) - .replace("{bin}", bin) + .replace("{bin}", &bin) } // --------------------------------------------------------------------------- @@ -101,15 +136,38 @@ pub fn expand_bin_dir(version: &str, target: &str) -> String { #[cfg(any(test, feature = "test-support"))] pub fn load_cargo_toml() -> Result { let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let cargo_toml_path = manifest_dir.join("Cargo.toml"); - let content = std::fs::read_to_string(&cargo_toml_path) - .map_err(|err| format!("failed to read {}: {err}", cargo_toml_path.display()))?; - content.parse::().map_err(|err| { - format!( - "failed to parse {} as TOML: {err}", - cargo_toml_path.display() - ) - }) + load_cargo_toml_from(&manifest_dir.join("Cargo.toml")) +} + +/// Load and parse a Cargo manifest from an explicit path. +/// +/// This variant lets package-level integration tests inspect another workspace +/// member's manifest while keeping [`load_cargo_toml`] compatible for existing +/// installer tests. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// use whitaker_installer::binstall_metadata::load_cargo_toml_from; +/// +/// let manifest = load_cargo_toml_from(Path::new("Cargo.toml"))?; +/// assert!(manifest.contains_key("package")); +/// # Ok::<(), String>(()) +/// ``` +/// +/// # Errors +/// +/// Returns a description of the failure if the manifest cannot be read or +/// does not parse as TOML. +#[cfg(any(test, feature = "test-support"))] +pub fn load_cargo_toml_from(manifest_path: &std::path::Path) -> Result { + let content = std::fs::read_to_string(manifest_path) + .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?; + content + .parse::() + .map_err(|err| format!("failed to parse {} as TOML: {err}", manifest_path.display())) } /// Extract the `[package.metadata.binstall]` sub-table from a parsed diff --git a/installer/src/cli.rs b/installer/src/cli.rs index a2807936b..f2071e53e 100644 --- a/installer/src/cli.rs +++ b/installer/src/cli.rs @@ -73,7 +73,7 @@ pub enum Command { } /// Arguments for the install command. -#[derive(Parser, Debug, Clone)] +#[derive(Parser, Debug, Clone, PartialEq, Eq)] pub struct InstallArgs { /// Staging directory for built libraries [default: platform-specific]. #[arg(short, long, value_name = "DIR")] @@ -125,7 +125,7 @@ pub struct InstallArgs { /// Flags selecting which lint crates are built. /// /// Flattened into [`InstallArgs`] so the command-line surface is unchanged. -#[derive(clap::Args, Debug, Clone, Default)] +#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)] pub struct LintSelectionFlags { /// Build all individual lint crates instead of the aggregated suite. #[arg(long, conflicts_with = "lint")] @@ -139,7 +139,7 @@ pub struct LintSelectionFlags { /// Flags adjusting how the installer executes the build. /// /// Flattened into [`InstallArgs`] so the command-line surface is unchanged. -#[derive(clap::Args, Debug, Clone, Default)] +#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)] pub struct ExecutionFlags { /// Install rustc-codegen-cranelift via rustup. #[arg(long, default_value_t = false)] @@ -153,7 +153,7 @@ pub struct ExecutionFlags { /// Flags that skip individual installation steps. /// /// Flattened into [`InstallArgs`] so the command-line surface is unchanged. -#[derive(clap::Args, Debug, Clone, Default)] +#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)] pub struct SkipFlags { /// Skip installation of cargo-dylint and dylint-link. #[arg(long)] diff --git a/installer/src/error.rs b/installer/src/error.rs index c8b207a4d..8e8859cfd 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -141,6 +141,13 @@ pub enum InstallerError { #[error("wrapper script generation failed: {0}")] WrapperGeneration(String), + /// Persisting the successful installation path failed. + #[error("failed to record installed lint path: {message}")] + InstallationRecord { + /// Description of the persistence failure. + message: String, + }, + /// Failed to scan the staging directory for installed lints. #[error("failed to scan staging directory")] ScanFailed { @@ -221,6 +228,9 @@ impl Clone for InstallerError { Self::StagingFailed { reason } => Self::StagingFailed { reason: reason.clone(), }, + Self::InstallationRecord { message } => Self::InstallationRecord { + message: message.clone(), + }, Self::TargetNotWritable { path, reason } => Self::TargetNotWritable { path: path.clone(), reason: reason.clone(), diff --git a/installer/src/install_flow/mod.rs b/installer/src/install_flow/mod.rs index 595346dff..906eef7f3 100644 --- a/installer/src/install_flow/mod.rs +++ b/installer/src/install_flow/mod.rs @@ -6,9 +6,10 @@ use std::{collections::HashSet, fs, io, io::Write, time::Duration}; use camino::{Utf8Path, Utf8PathBuf}; + #[cfg(test)] -use whitaker_installer::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; -use whitaker_installer::{ +use crate::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; +use crate::{ builder::{library_extension, library_prefix}, cli::InstallArgs, crate_name::CrateName, diff --git a/installer/src/installation_record.rs b/installer/src/installation_record.rs new file mode 100644 index 000000000..c9a8b12dc --- /dev/null +++ b/installer/src/installation_record.rs @@ -0,0 +1,347 @@ +//! Persistence for the staging directories of successful installations. +//! +//! The root CLI may run outside a Whitaker checkout, so it cannot always infer +//! a custom installation target from the current directory or default target. +//! This module records one absolute staging path per toolchain in Whitaker's +//! data directory. The installer owns writes; CLI adapters may read the paths +//! to configure Dylint forwarding. + +use std::{ + collections::BTreeMap, + fs::{File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + path::PathBuf, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use fs2::FileExt; + +use crate::dirs::BaseDirs; + +const RECORD_FILENAME: &str = "installed_lint_paths.json"; + +/// Errors returned when installed staging paths cannot be persisted or read. +#[derive(Debug, thiserror::Error)] +pub enum InstallationRecordError { + /// Whitaker's platform data directory could not be resolved. + #[error("could not determine Whitaker data directory")] + MissingDataDirectory, + + /// The installation record is not valid JSON. + #[error("failed to parse installation record {path}: {source}")] + Parse { + /// Record path. + path: PathBuf, + /// Underlying deserialization error. + #[source] + source: serde_json::Error, + }, + + /// An operation on the installation record failed. + #[error("failed to {operation} installation record {path}: {source}")] + Io { + /// Operation being performed. + operation: &'static str, + /// Record or staging path involved in the operation. + path: PathBuf, + /// Underlying filesystem or serialization error. + #[source] + source: std::io::Error, + }, +} + +/// Records the absolute staging directory for a successfully installed toolchain. +/// +/// A later install for the same toolchain replaces its previous staging path; +/// paths for other toolchains remain available to Dylint's toolchain selector. +/// +/// # Examples +/// +/// ```no_run +/// use camino::Utf8Path; +/// use whitaker_installer::{dirs::SystemBaseDirs, installation_record::record_installation}; +/// +/// let dirs = SystemBaseDirs::new().ok_or("platform data directory unavailable")?; +/// record_installation(&dirs, "nightly-2025-01-01", Utf8Path::new("target"))?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error if the staging path cannot be canonicalized or the record +/// cannot be created, locked, read, or written. +pub fn record_installation( + dirs: &dyn BaseDirs, + toolchain: &str, + staging_path: &Utf8Path, +) -> Result<(), InstallationRecordError> { + let record_path = record_path(dirs)?; + let absolute_staging_path = staging_path.canonicalize_utf8().map_err(|source| { + record_io_error( + "canonicalize staging directory", + staging_path.as_std_path(), + source, + ) + })?; + ensure_parent(&record_path)?; + let mut file = open_record(&record_path)?; + file.lock_exclusive() + .map_err(|source| record_io_error("lock", &record_path, source))?; + let mut paths = match read_paths(&record_path, &mut file) { + Ok(paths) => paths, + Err(InstallationRecordError::Parse { path, source }) => { + tracing::warn!(path = %path.display(), error = %source, "discarding paths"); + BTreeMap::new() + } + Err(error) => return Err(error), + }; + paths.insert(toolchain.to_owned(), absolute_staging_path.into_string()); + write_paths(&record_path, &mut file, &paths) +} + +/// Returns recorded staging directories that still exist. +/// +/// Missing records yield an empty vector. Stale entries and relative paths are +/// ignored so a moved or deleted install cannot poison forwarding. +/// +/// # Examples +/// +/// ```no_run +/// use whitaker_installer::{dirs::SystemBaseDirs, installation_record::installed_paths}; +/// +/// let dirs = SystemBaseDirs::new().ok_or("platform data directory unavailable")?; +/// for path in installed_paths(&dirs)? { +/// println!("{}", path); +/// } +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error if the record exists but cannot be locked, read, or parsed. +pub fn installed_paths(dirs: &dyn BaseDirs) -> Result, InstallationRecordError> { + let record_path = record_path(dirs)?; + let mut file = match OpenOptions::new().read(true).open(&record_path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => return Err(record_io_error("open", &record_path, source)), + }; + file.lock_shared() + .map_err(|source| record_io_error("lock", &record_path, source))?; + let paths = read_paths(&record_path, &mut file)?; + Ok(paths + .into_values() + .map(Utf8PathBuf::from) + .filter(|path| path.is_absolute() && path.is_dir()) + .collect()) +} + +fn record_path(dirs: &dyn BaseDirs) -> Result { + dirs.whitaker_data() + .map(|directory| directory.join(RECORD_FILENAME)) + .ok_or(InstallationRecordError::MissingDataDirectory) +} + +fn ensure_parent(path: &std::path::Path) -> Result<(), InstallationRecordError> { + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(parent) + .map_err(|source| record_io_error("create parent directory for", path, source)) +} + +fn open_record(path: &std::path::Path) -> Result { + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|source| record_io_error("open", path, source)) +} + +fn read_paths( + path: &std::path::Path, + file: &mut File, +) -> Result, InstallationRecordError> { + file.seek(SeekFrom::Start(0)) + .map_err(|source| record_io_error("read", path, source))?; + let mut content = String::new(); + file.read_to_string(&mut content) + .map_err(|source| record_io_error("read", path, source))?; + if content.trim().is_empty() { + return Ok(BTreeMap::new()); + } + serde_json::from_str::>(&content).map_err(|source| { + InstallationRecordError::Parse { + path: path.to_path_buf(), + source, + } + }) +} + +fn write_paths( + path: &std::path::Path, + file: &mut File, + paths: &BTreeMap, +) -> Result<(), InstallationRecordError> { + file.set_len(0) + .and_then(|()| file.seek(SeekFrom::Start(0)).map(|_| ())) + .map_err(|source| record_io_error("truncate", path, source))?; + serde_json::to_writer(&mut *file, paths) + .map_err(|error| record_io_error("serialize", path, std::io::Error::other(error)))?; + file.write_all(b"\n") + .and_then(|()| file.sync_all()) + .map_err(|source| record_io_error("write", path, source)) +} + +fn record_io_error( + operation: &'static str, + path: &std::path::Path, + source: std::io::Error, +) -> InstallationRecordError { + InstallationRecordError::Io { + operation, + path: path.to_path_buf(), + source, + } +} + +#[cfg(test)] +mod tests { + //! Tests for exact installed-path persistence. + + use std::path::PathBuf; + + use camino::Utf8Path; + use tempfile::TempDir; + + use super::{InstallationRecordError, installed_paths, record_installation}; + use crate::dirs::{BaseDirs, MockBaseDirs}; + + fn mock_dirs(data: PathBuf) -> MockBaseDirs { + let mut dirs = MockBaseDirs::new(); + dirs.expect_whitaker_data().return_const(Some(data)); + dirs + } + + #[test] + fn installation_paths_preserve_toolchains_and_replace_the_same_channel() { + let temporary = TempDir::new().expect("temporary directory should be created"); + let stable = temporary.path().join("stable"); + let nightly = temporary.path().join("nightly"); + let replacement = temporary.path().join("stable-replacement"); + std::fs::create_dir_all(&stable).expect("stable staging directory should be created"); + std::fs::create_dir_all(&nightly).expect("nightly staging directory should be created"); + std::fs::create_dir_all(&replacement) + .expect("replacement staging directory should be created"); + let dirs = mock_dirs(temporary.path().join("data")); + + record_installation( + &dirs, + "stable", + Utf8Path::from_path(&stable).expect("stable path should be UTF-8"), + ) + .expect("stable path should be recorded"); + record_installation( + &dirs, + "nightly", + Utf8Path::from_path(&nightly).expect("nightly path should be UTF-8"), + ) + .expect("nightly path should be recorded"); + record_installation( + &dirs, + "stable", + Utf8Path::from_path(&replacement).expect("replacement path should be UTF-8"), + ) + .expect("replacement path should be recorded"); + + let recorded = installed_paths(&dirs).expect("recorded paths should load"); + assert_eq!(recorded.len(), 2); + assert!( + recorded.contains( + &Utf8Path::from_path(&replacement) + .expect("replacement path should be UTF-8") + .to_owned() + ) + ); + assert!( + recorded.contains( + &Utf8Path::from_path(&nightly) + .expect("nightly path should be UTF-8") + .to_owned() + ) + ); + } + + #[test] + fn installed_paths_ignore_stale_records_and_missing_files() { + let temporary = TempDir::new().expect("temporary directory should be created"); + let staging = temporary.path().join("staging"); + std::fs::create_dir_all(&staging).expect("staging directory should be created"); + let dirs = mock_dirs(temporary.path().join("data")); + assert!( + installed_paths(&dirs) + .expect("a missing record should be an empty inventory") + .is_empty() + ); + record_installation( + &dirs, + "gone", + Utf8Path::from_path(&staging).expect("stale path should be UTF-8"), + ) + .expect("existing staging directory should be recorded"); + std::fs::remove_dir(&staging).expect("staging directory should be removed"); + assert!( + installed_paths(&dirs) + .expect("stale records should be ignored") + .is_empty() + ); + } + + #[test] + fn successful_install_replaces_a_malformed_record() { + let temporary = TempDir::new().expect("temporary directory should be created"); + let data = temporary.path().join("data"); + let staging = temporary.path().join("staging"); + std::fs::create_dir_all(&data).expect("data directory should be created"); + std::fs::create_dir_all(&staging).expect("staging directory should be created"); + std::fs::write(data.join(super::RECORD_FILENAME), "not JSON") + .expect("malformed record should be written"); + let dirs = mock_dirs(data); + + assert!(matches!( + installed_paths(&dirs), + Err(InstallationRecordError::Parse { .. }) + )); + record_installation( + &dirs, + "nightly", + Utf8Path::from_path(&staging).expect("staging path should be UTF-8"), + ) + .expect("successful install should replace malformed record"); + + assert_eq!( + installed_paths(&dirs) + .expect("new record should load") + .len(), + 1 + ); + } + + #[test] + fn the_record_api_uses_only_the_whitaker_data_directory() { + let temporary = TempDir::new().expect("temporary directory should be created"); + let staging = temporary.path().join("staging"); + std::fs::create_dir_all(&staging).expect("staging directory should be created"); + let dirs = mock_dirs(temporary.path().join("data")); + + record_installation( + &dirs, + "nightly", + Utf8Path::from_path(&staging).expect("staging path should be UTF-8"), + ) + .expect("staging path should be recorded"); + + let data_dir = dirs.whitaker_data().expect("data directory should exist"); + assert!(data_dir.join("installed_lint_paths.json").is_file()); + } +} diff --git a/installer/src/lib.rs b/installer/src/lib.rs index 2e0c1afce..c850286e2 100644 --- a/installer/src/lib.rs +++ b/installer/src/lib.rs @@ -45,16 +45,22 @@ pub mod dirs; pub mod error; pub mod git; mod hex; +pub mod install_flow; pub mod install_metrics; +/// Persists successful installation staging paths for root CLI forwarding. +pub mod installation_record; pub mod installer_packaging; pub mod list; pub mod list_output; +/// Coordinates installer commands without parsing arguments or exiting. +pub mod orchestration; pub mod output; pub mod pipeline; pub mod prebuilt; pub mod prebuilt_path; pub mod resolution; pub mod scanner; +mod staged_suite; pub mod stager; /// Test-only hooks shared by installer behavioural and integration tests. /// diff --git a/installer/src/list.rs b/installer/src/list.rs index c148ab8fd..8beb77ada 100644 --- a/installer/src/list.rs +++ b/installer/src/list.rs @@ -13,7 +13,7 @@ use crate::{ dirs::{BaseDirs, SystemBaseDirs}, error::{InstallerError, Result}, list_output::{format_human, format_json}, - scanner::{InstalledLints, scan_installed}, + scanner::InstalledLints, stager::default_target_dir, toolchain::Toolchain, }; @@ -26,6 +26,19 @@ use crate::{ /// /// Output is written to stdout (human-readable by default, JSON with `--json`). /// +/// # Examples +/// +/// ```no_run +/// use std::io; +/// +/// use clap::Parser as _; +/// use whitaker_installer::{cli::ListArgs, list::run_list}; +/// +/// let arguments = ListArgs::try_parse_from(["whitaker-installer"])?; +/// run_list(&arguments, &mut io::stdout())?; +/// # Ok::<(), Box>(()) +/// ``` +/// /// # Errors /// /// Returns an error if: @@ -35,19 +48,48 @@ pub fn run_list(args: &ListArgs, stdout: &mut dyn Write) -> Result<()> { run_list_with(args, stdout, detect_active_toolchain) } -/// Internal implementation with injectable toolchain detection for testability. -fn run_list_with(args: &ListArgs, stdout: &mut dyn Write, detect_toolchain: F) -> Result<()> -where - F: FnOnce() -> Option, -{ +/// Collects installed lint metadata without formatting or writing output. +/// +/// This is the inventory boundary used by the root `whitaker ls` command. +/// Callers can select their own stable or evolving presentation while sharing +/// the installer's scan-root and ordering rules. +/// +/// # Examples +/// +/// ```no_run +/// use clap::Parser; +/// use whitaker_installer::{cli::ListArgs, list::collect_installed}; +/// +/// let arguments = ListArgs::try_parse_from(["whitaker"])?; +/// let installed = collect_installed(&arguments)?; +/// println!( +/// "{} toolchains have staged lints", +/// installed.by_toolchain.len() +/// ); +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error if a selected staging directory cannot be scanned. +pub fn collect_installed(args: &ListArgs) -> Result { let scan_roots = determine_scan_roots(args.target_dir.as_deref())?; - let mut installed = InstalledLints::default(); + let mut installed = crate::scanner::InstalledLints::default(); for root in scan_roots { - let discovered = - scan_installed(&root).map_err(|e| InstallerError::ScanFailed { source: e })?; + let discovered = crate::scanner::scan_installed(&root) + .map_err(|source| InstallerError::ScanFailed { source })?; merge_installed(&mut installed, discovered); } sort_installed_libraries(&mut installed); + Ok(installed) +} + +/// Internal implementation with injectable toolchain detection for testability. +fn run_list_with(args: &ListArgs, stdout: &mut dyn Write, detect_toolchain: F) -> Result<()> +where + F: FnOnce() -> Option, +{ + let installed = collect_installed(args)?; let active_toolchain = detect_toolchain(); diff --git a/installer/src/main.rs b/installer/src/main.rs index c9688ed60..6b24808a4 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -1,402 +1,17 @@ -//! Whitaker installer CLI entrypoint. -//! -//! This binary builds, links, and stages Dylint lint libraries for local use. -//! After installation, it prints shell configuration snippets for enabling -//! library discovery. +//! Composition root for the legacy `whitaker-installer` command. -mod install_flow; -mod staged_suite; - -use std::{io::Write, time::Instant}; - -use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; use whitaker_installer::{ - cli::{Cli, Command, InstallArgs}, - crate_name::CrateName, - deps::SystemCommandExecutor, - dirs::{BaseDirs, SystemBaseDirs}, - error::{InstallerError, Result}, - install_metrics::InstallMode, - list::{determine_target_dir, run_list}, - output::{DryRunInfo, DryRunSkips, ShellSnippet, write_stderr_line}, - pipeline::{PipelineContext, perform_build, stage_libraries}, - prebuilt_path::prebuilt_library_dir, - resolution::{CrateResolutionOptions, resolve_crates, validate_crate_names}, - toolchain::Toolchain, - wrapper::{generate_wrapper_scripts, path_instructions}, -}; - -#[cfg(test)] -use crate::install_flow::ensure_dylint_tools_with_options; -use crate::install_flow::{ - MetricsWriteContext, - PrebuiltInstallationContext, - detect_host_target, - ensure_dylint_tools_with_executor, - try_prebuilt_installation, - write_install_metrics, + cli::Cli, + orchestration::{exit_code_for_run_result, run}, }; fn main() { let cli = Cli::parse(); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - let run_result = run(&cli, &mut stdout, &mut stderr); - let exit_code = exit_code_for_run_result(run_result, &mut stderr); + let exit_code = exit_code_for_run_result(run(&cli, &mut stdout, &mut stderr), &mut stderr); if exit_code != 0 { std::process::exit(exit_code); } } - -/// Routes CLI commands to their respective handlers. -fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { - match &cli.command { - Some(Command::List(args)) => run_list(args, stdout), - Some(Command::Install(args)) => run_install(args, stderr), - None => run_install(cli.install_args(), stderr), - } -} - -/// Returns the set of additional rustup components requested by the CLI flags. -const fn resolve_additional_components(args: &InstallArgs) -> &'static [&'static str] { - if args.execution.cranelift { - &["rustc-codegen-cranelift"] - } else { - &[] - } -} - -/// Attempts prebuilt download and staged-suite fast paths. -/// -/// Returns `Some((staging_path, mode))` if either succeeds, or `None` if -/// the caller should proceed to a full build. -fn try_fast_path_installation( - context: &FastPathContext<'_>, - stderr: &mut dyn Write, -) -> Result> { - let prebuilt_context = PrebuiltInstallationContext { - args: context.args, - dirs: context.dirs, - requested_crates: context.requested_crates, - toolchain_channel: context.toolchain.channel(), - }; - if let Some(staging_path) = try_prebuilt_installation(&prebuilt_context, stderr) { - return Ok(Some((staging_path, InstallMode::Download))); - } - if let Some(staging_path) = staged_suite::try_test_staged_suite_installation( - context.requested_crates, - context.toolchain, - context.target_dir, - )? { - return Ok(Some((staging_path, InstallMode::Build))); - } - Ok(None) -} - -/// Runs the install command to build and stage lint libraries. -/// -/// Workflow: (1) check/install Dylint dependencies, (2) locate/clone workspace, -/// (3) resolve crates from CLI flags, (4) build in release mode, (5) stage -/// libraries with toolchain-suffixed names, (6) generate wrapper script. -/// -/// # Errors -/// -/// Returns an error if any step fails. -fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { - let dirs = SystemBaseDirs::new().ok_or_else(|| InstallerError::WorkspaceNotFound { - reason: "could not determine platform directories".to_owned(), - })?; - if args.execution.dry_run { - return run_dry(args, &dirs, stderr); - } - let install_started = Instant::now(); - // Step 1: Check and install Dylint dependencies if needed - if !args.skip.skip_deps { - ensure_dylint_tools(args.quiet, stderr)?; - } - // Step 2: Ensure workspace is available (clone if needed) - let workspace_root = ensure_whitaker_workspace(args, &dirs, stderr)?; - // Step 3: Resolve crates and toolchain - let requested_crates = resolve_requested_crates(args)?; - let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; - ensure_toolchain_installed( - &toolchain, - resolve_additional_components(args), - args.quiet, - stderr, - )?; - let target_dir = determine_target_dir(args.target_dir.as_deref())?; - // Step 3.5: Attempt prebuilt download or staged-suite fast path. - let fast_path_context = FastPathContext { - args, - dirs: &dirs, - requested_crates: &requested_crates, - toolchain: &toolchain, - target_dir: &target_dir, - }; - if let Some((staging_path, install_mode)) = - try_fast_path_installation(&fast_path_context, stderr)? - { - let finish_context = FinishInstallContext { - args, - dirs: &dirs, - staging_path: &staging_path, - install_mode, - install_started, - }; - return finish_install_and_record_metrics(&finish_context, stderr); - } - let context = PipelineContext { - workspace_root: &workspace_root, - toolchain: &toolchain, - target_dir: &target_dir, - jobs: args.jobs, - verbosity: args.verbosity, - experimental: args.lint_selection.experimental, - quiet: args.quiet, - }; - // Step 4: Build and stage - let build_results = perform_build(&context, &requested_crates, stderr)?; - let staging_path = stage_libraries(&context, &build_results, stderr)?; - // Step 5: Generate wrapper scripts if requested - let finish_context = FinishInstallContext { - args, - dirs: &dirs, - staging_path: &staging_path, - install_mode: InstallMode::Build, - install_started, - }; - finish_install_and_record_metrics(&finish_context, stderr) -} - -/// Runs in dry-run mode, showing configuration without side effects. -fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> Result<()> { - use whitaker_installer::workspace::resolve_workspace_path; - - let workspace_root = resolve_workspace_path(dirs)?; - let requested_crates = resolve_requested_crates(args)?; - let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; - toolchain.verify_installed()?; - let target_dir = determine_dry_run_target_dir(args, dirs, &toolchain, &requested_crates)?; - let info = DryRunInfo { - workspace_root: &workspace_root, - toolchain: toolchain.channel(), - target_dir: &target_dir, - verbosity: args.verbosity, - quiet: args.quiet, - skips: DryRunSkips { - deps: args.skip.skip_deps, - wrapper: args.skip.skip_wrapper, - update: args.skip.no_update, - }, - jobs: args.jobs, - crates: &requested_crates, - }; - write_stderr_line(stderr, info.display_text()); - Ok(()) -} - -fn determine_dry_run_target_dir( - args: &InstallArgs, - dirs: &dyn BaseDirs, - toolchain: &Toolchain, - requested_crates: &[CrateName], -) -> Result { - let build_target_dir = determine_target_dir(args.target_dir.as_deref())?; - if !args.should_attempt_prebuilt(requested_crates) { - return Ok(build_target_dir); - } - let Ok(host_target) = detect_host_target() else { - return Ok(build_target_dir); - }; - Ok(prebuilt_library_dir(dirs, toolchain.channel(), &host_target).unwrap_or(build_target_dir)) -} - -/// Checks for and installs Dylint tools if missing. -fn ensure_dylint_tools(quiet: bool, stderr: &mut dyn Write) -> Result<()> { - let executor = SystemCommandExecutor; - ensure_dylint_tools_with_executor(&executor, quiet, stderr) -} - -/// Ensures a Whitaker workspace is available. -fn ensure_whitaker_workspace( - args: &InstallArgs, - dirs: &dyn BaseDirs, - stderr: &mut dyn Write, -) -> Result { - use whitaker_installer::workspace::{ - WorkspaceAction, - clone_directory, - decide_workspace_action, - ensure_workspace, - }; - - if !args.quiet - && let Some(clone_dir) = clone_directory(dirs) - { - let utf8_cwd = std::env::current_dir() - .ok() - .and_then(|p| Utf8PathBuf::try_from(p).ok()); - - let Some(cwd) = utf8_cwd else { - return ensure_workspace(dirs, !args.skip.no_update); - }; - - match decide_workspace_action(&cwd, &clone_dir, !args.skip.no_update) { - WorkspaceAction::CloneTo(dir) => { - write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); - } - WorkspaceAction::UpdateAt(dir) => { - write_stderr_line(stderr, format!("Updating Whitaker repository at {dir}...")); - } - WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} - } - } - - ensure_workspace(dirs, !args.skip.no_update) -} - -/// Detects or overrides the toolchain, then verifies it is installed. -fn resolve_toolchain( - workspace_root: &Utf8Path, - override_channel: Option<&str>, -) -> Result { - override_channel.map_or_else( - || Toolchain::detect(workspace_root), - |channel| Ok(Toolchain::with_override(workspace_root, channel)), - ) -} - -fn ensure_toolchain_installed( - toolchain: &Toolchain, - additional_components: &[&str], - quiet: bool, - stderr: &mut dyn Write, -) -> Result<()> { - let status = toolchain.ensure_installed(additional_components)?; - if status.installed_toolchain() && !quiet { - write_stderr_line( - stderr, - format!("Toolchain {} installed successfully.", toolchain.channel()), - ); - write_stderr_line(stderr, ""); - } - Ok(()) -} - -/// Common final steps: generate wrapper scripts or print shell snippet. -fn finish_install( - args: &InstallArgs, - dirs: &dyn BaseDirs, - staging_path: &Utf8Path, - stderr: &mut dyn Write, -) -> Result<()> { - if args.skip.skip_wrapper { - write_stderr_line(stderr, ""); - write_stderr_line(stderr, ShellSnippet::new(staging_path).display_text()); - } else { - generate_and_report_wrapper(dirs, staging_path, stderr)?; - } - Ok(()) -} - -/// Aggregates final-step inputs required after a successful install run. -struct FinishInstallContext<'a> { - args: &'a InstallArgs, - dirs: &'a dyn BaseDirs, - staging_path: &'a Utf8Path, - install_mode: InstallMode, - install_started: Instant, -} - -/// Aggregates the immutable inputs for fast-path installation attempts. -struct FastPathContext<'a> { - args: &'a InstallArgs, - dirs: &'a dyn BaseDirs, - requested_crates: &'a [CrateName], - toolchain: &'a Toolchain, - target_dir: &'a Utf8PathBuf, -} - -/// Finalize installation and record aggregate installer metrics. -fn finish_install_and_record_metrics( - context: &FinishInstallContext<'_>, - stderr: &mut dyn Write, -) -> Result<()> { - finish_install(context.args, context.dirs, context.staging_path, stderr)?; - let metrics_context = MetricsWriteContext { - quiet: context.args.quiet, - dirs: context.dirs, - install_mode: context.install_mode, - elapsed: context.install_started.elapsed(), - }; - write_install_metrics(&metrics_context, stderr); - Ok(()) -} - -/// Resolves requested crates from the CLI flags. -fn resolve_requested_crates(args: &InstallArgs) -> Result> { - let lint_crates: Vec = args - .lint - .iter() - .map(|name| CrateName::from(name.as_str())) - .collect(); - - let options = CrateResolutionOptions { - individual_lints: args.lint_selection.individual_lints, - experimental: args.lint_selection.experimental, - }; - if !lint_crates.is_empty() { - validate_crate_names(&lint_crates, &options)?; - } - - Ok(resolve_crates(&lint_crates, &options)) -} - -/// Generates wrapper scripts and reports the result. -fn generate_and_report_wrapper( - dirs: &dyn BaseDirs, - staging_path: &Utf8Path, - stderr: &mut dyn Write, -) -> Result<()> { - let result = generate_wrapper_scripts(dirs, staging_path)?; - write_stderr_line(stderr, ""); - write_stderr_line(stderr, "Wrapper scripts created:"); - write_stderr_line(stderr, format!(" - {}", result.whitaker_path.display())); - write_stderr_line(stderr, format!(" - {}", result.whitaker_ls_path.display())); - write_stderr_line(stderr, ""); - - if result.in_path { - write_stderr_line(stderr, "You can now run:"); - write_stderr_line(stderr, " whitaker --all"); - write_stderr_line(stderr, " whitaker-ls"); - } else { - let bin_dir = - result - .whitaker_path - .parent() - .ok_or_else(|| InstallerError::StagingFailed { - reason: "wrapper script path has no parent directory".to_owned(), - })?; - write_stderr_line(stderr, path_instructions(bin_dir)); - write_stderr_line(stderr, ""); - write_stderr_line(stderr, "Then run:"); - write_stderr_line(stderr, " whitaker --all"); - write_stderr_line(stderr, " whitaker-ls"); - } - Ok(()) -} - -fn exit_code_for_run_result(result: Result<()>, stderr: &mut dyn Write) -> i32 { - match result { - Ok(()) => 0, - Err(err) => { - write_stderr_line(stderr, err); - 1 - } - } -} - -#[cfg(test)] -mod tests; diff --git a/installer/src/orchestration/dry_run.rs b/installer/src/orchestration/dry_run.rs new file mode 100644 index 000000000..f61af0bf3 --- /dev/null +++ b/installer/src/orchestration/dry_run.rs @@ -0,0 +1,66 @@ +//! Renders installer configuration without performing installation side effects. + +use std::io::Write; + +use camino::Utf8PathBuf; + +use super::{resolve_requested_crates, resolve_toolchain}; +use crate::{ + cli::InstallArgs, + crate_name::CrateName, + dirs::BaseDirs, + error::Result, + install_flow::detect_host_target, + list::determine_target_dir, + output::{DryRunInfo, DryRunSkips, write_stderr_line}, + prebuilt_path::prebuilt_library_dir, + toolchain::Toolchain, + workspace::resolve_workspace_path, +}; + +/// Runs in dry-run mode, showing configuration without side effects. +pub(super) fn run_dry( + args: &InstallArgs, + dirs: &dyn BaseDirs, + stderr: &mut dyn Write, +) -> Result<()> { + let workspace_root = resolve_workspace_path(dirs)?; + let requested_crates = resolve_requested_crates(args)?; + let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; + toolchain.verify_installed()?; + let target_dir = determine_dry_run_target_dir(args, dirs, &toolchain, &requested_crates)?; + let info = DryRunInfo { + workspace_root: &workspace_root, + toolchain: toolchain.channel(), + target_dir: &target_dir, + verbosity: args.verbosity, + quiet: args.quiet, + skips: DryRunSkips { + deps: args.skip.skip_deps, + wrapper: args.skip.skip_wrapper, + update: args.skip.no_update, + }, + jobs: args.jobs, + crates: &requested_crates, + }; + write_stderr_line(stderr, info.display_text()); + Ok(()) +} + +/// Selects the install target, falling back to the build directory when host +/// detection or prebuilt-path resolution fails. +fn determine_dry_run_target_dir( + args: &InstallArgs, + dirs: &dyn BaseDirs, + toolchain: &Toolchain, + requested_crates: &[CrateName], +) -> Result { + let build_target_dir = determine_target_dir(args.target_dir.as_deref())?; + if !args.should_attempt_prebuilt(requested_crates) { + return Ok(build_target_dir); + } + let Ok(host_target) = detect_host_target() else { + return Ok(build_target_dir); + }; + Ok(prebuilt_library_dir(dirs, toolchain.channel(), &host_target).unwrap_or(build_target_dir)) +} diff --git a/installer/src/orchestration/exit.rs b/installer/src/orchestration/exit.rs new file mode 100644 index 000000000..f3332ebee --- /dev/null +++ b/installer/src/orchestration/exit.rs @@ -0,0 +1,16 @@ +//! Converts an installer operation result into the legacy process exit status. + +use std::io::Write; + +use crate::{error::Result, output::write_stderr_line}; + +/// Writes an installation error and maps the result to a process exit code. +pub fn exit_code_for_run_result(result: Result<()>, stderr: &mut dyn Write) -> i32 { + match result { + Ok(()) => 0, + Err(err) => { + write_stderr_line(stderr, err); + 1 + } + } +} diff --git a/installer/src/orchestration/finish.rs b/installer/src/orchestration/finish.rs new file mode 100644 index 000000000..26146c874 --- /dev/null +++ b/installer/src/orchestration/finish.rs @@ -0,0 +1,113 @@ +//! Finalization shared by successful installer paths. + +use std::{io::Write, time::Instant}; + +use camino::Utf8Path; + +use crate::{ + cli::InstallArgs, + dirs::BaseDirs, + error::{InstallerError, Result}, + install_flow::{MetricsWriteContext, write_install_metrics}, + install_metrics::InstallMode, + installation_record::record_installation, + output::{ShellSnippet, write_stderr_line}, + toolchain::Toolchain, + wrapper::{WrapperScripts, generate_wrapper_scripts_with, path_instructions}, +}; + +/// Aggregates final-step inputs required after a successful install run. +pub(super) struct FinishInstallContext<'a> { + pub(super) args: &'a InstallArgs, + pub(super) dirs: &'a dyn BaseDirs, + pub(super) toolchain: &'a Toolchain, + pub(super) staging_path: &'a Utf8Path, + pub(super) wrapper_scripts: WrapperScripts, + pub(super) install_mode: InstallMode, + pub(super) install_started: Instant, +} + +/// Finalize installation and record aggregate installer metrics. +pub(super) fn finish_install_and_record_metrics( + context: &FinishInstallContext<'_>, + stderr: &mut dyn Write, +) -> Result<()> { + if let Err(record_error) = record_installation( + context.dirs, + context.toolchain.channel(), + context.staging_path, + ) { + let error = InstallerError::InstallationRecord { + message: record_error.to_string(), + }; + if context.wrapper_scripts == WrapperScripts::WhitakerLsOnly { + return Err(error); + } + write_stderr_line(stderr, format!("Warning: {error}")); + } + finish_install(context, stderr)?; + let metrics_context = MetricsWriteContext { + quiet: context.args.quiet, + dirs: context.dirs, + install_mode: context.install_mode, + elapsed: context.install_started.elapsed(), + }; + write_install_metrics(&metrics_context, stderr); + Ok(()) +} + +/// Common final steps: generate wrapper scripts or print shell snippet. +fn finish_install(context: &FinishInstallContext<'_>, stderr: &mut dyn Write) -> Result<()> { + if context.args.skip.skip_wrapper { + write_stderr_line(stderr, ""); + write_stderr_line( + stderr, + ShellSnippet::new(context.staging_path).display_text(), + ); + } else { + generate_and_report_wrapper( + context.dirs, + context.staging_path, + context.wrapper_scripts, + stderr, + )?; + } + Ok(()) +} + +/// Generates wrapper scripts and reports the result. +fn generate_and_report_wrapper( + dirs: &dyn BaseDirs, + staging_path: &Utf8Path, + scripts: WrapperScripts, + stderr: &mut dyn Write, +) -> Result<()> { + let result = generate_wrapper_scripts_with(dirs, staging_path, scripts)?; + write_stderr_line(stderr, ""); + write_stderr_line(stderr, "Wrapper scripts created:"); + if scripts == WrapperScripts::Both { + write_stderr_line(stderr, format!(" - {}", result.whitaker_path.display())); + } + write_stderr_line(stderr, format!(" - {}", result.whitaker_ls_path.display())); + write_stderr_line(stderr, ""); + + if result.in_path { + write_stderr_line(stderr, "You can now run:"); + write_stderr_line(stderr, " whitaker --all"); + write_stderr_line(stderr, " whitaker-ls"); + } else { + let bin_dir = + result + .whitaker_path + .parent() + .ok_or_else(|| InstallerError::StagingFailed { + reason: "wrapper script path has no parent directory".to_owned(), + })?; + write_stderr_line(stderr, path_instructions(bin_dir)); + write_stderr_line(stderr, ""); + write_stderr_line(stderr, "Then run:"); + write_stderr_line(stderr, " whitaker --all"); + write_stderr_line(stderr, " whitaker-ls"); + } + Ok(()) +} diff --git a/installer/src/orchestration/mod.rs b/installer/src/orchestration/mod.rs new file mode 100644 index 000000000..a9ae297ac --- /dev/null +++ b/installer/src/orchestration/mod.rs @@ -0,0 +1,317 @@ +//! Installer command orchestration shared by Whitaker binaries. +//! +//! This module coordinates parsed installer requests with the library's build, +//! staging, and wrapper adapters. It deliberately owns the operational flow, +//! while callers own command-line parsing and process termination. + +use std::{io::Write, time::Instant}; + +use camino::{Utf8Path, Utf8PathBuf}; + +mod dry_run; +mod exit; +mod finish; + +use dry_run::run_dry; +pub use exit::exit_code_for_run_result; +use finish::{FinishInstallContext, finish_install_and_record_metrics}; + +#[cfg(test)] +use crate::install_flow::ensure_dylint_tools_with_options; +use crate::{ + cli::{Cli, Command, InstallArgs}, + crate_name::CrateName, + deps::SystemCommandExecutor, + dirs::{BaseDirs, SystemBaseDirs}, + error::{InstallerError, Result}, + install_flow::{ + PrebuiltInstallationContext, + ensure_dylint_tools_with_executor, + try_prebuilt_installation, + }, + install_metrics::InstallMode, + list::{determine_target_dir, run_list}, + output::write_stderr_line, + pipeline::{PipelineContext, perform_build, stage_libraries}, + resolution::{CrateResolutionOptions, resolve_crates, validate_crate_names}, + staged_suite, + toolchain::Toolchain, + wrapper::WrapperScripts, +}; + +/// Routes CLI commands to their respective handlers. +/// +/// # Examples +/// +/// ```no_run +/// use std::io; +/// +/// use clap::Parser as _; +/// use whitaker_installer::{cli::Cli, orchestration::run}; +/// +/// let cli = Cli::try_parse_from(["whitaker-installer", "--dry-run"])?; +/// run(&cli, &mut io::sink(), &mut io::sink())?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error when the selected installer operation cannot complete. +pub fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { + match &cli.command { + Some(Command::List(args)) => run_list(args, stdout), + Some(Command::Install(args)) => run_install(args, stderr, WrapperScripts::Both), + None => run_install(cli.install_args(), stderr, WrapperScripts::Both), + } +} + +/// Runs a root-CLI install without replacing its `whitaker` executable. +/// +/// The legacy installer continues to use [`run`] and creates both scripts. +/// The root binary creates only `whitaker-ls`, preserving the executable name +/// that the user invoked. +/// +/// # Examples +/// +/// ```no_run +/// use std::io; +/// +/// use whitaker_installer::{cli::InstallArgs, orchestration::run_install_for_whitaker}; +/// +/// let arguments = InstallArgs::default(); +/// let mut diagnostics = io::stderr(); +/// run_install_for_whitaker(&arguments, &mut diagnostics)?; +/// # Ok::<(), whitaker_installer::error::InstallerError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error when the selected installation operation cannot complete. +pub fn run_install_for_whitaker(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { + run_install(args, stderr, WrapperScripts::WhitakerLsOnly) +} + +/// Returns the set of additional rustup components requested by the CLI flags. +const fn resolve_additional_components(args: &InstallArgs) -> &'static [&'static str] { + if args.execution.cranelift { + &["rustc-codegen-cranelift"] + } else { + &[] + } +} + +/// Attempts prebuilt download and staged-suite fast paths. +/// +/// Returns `Some((staging_path, mode))` if either succeeds, or `None` if +/// the caller should proceed to a full build. +fn try_fast_path_installation( + context: &FastPathContext<'_>, + stderr: &mut dyn Write, +) -> Result> { + let prebuilt_context = PrebuiltInstallationContext { + args: context.args, + dirs: context.dirs, + requested_crates: context.requested_crates, + toolchain_channel: context.toolchain.channel(), + }; + if let Some(staging_path) = try_prebuilt_installation(&prebuilt_context, stderr) { + return Ok(Some((staging_path, InstallMode::Download))); + } + if let Some(staging_path) = staged_suite::try_test_staged_suite_installation( + context.requested_crates, + context.toolchain, + context.target_dir, + )? { + return Ok(Some((staging_path, InstallMode::Build))); + } + Ok(None) +} + +/// Runs the install command to build and stage lint libraries. +/// +/// Workflow: (1) check/install Dylint dependencies, (2) locate/clone workspace, +/// (3) resolve crates from CLI flags, (4) build in release mode, (5) stage +/// libraries with toolchain-suffixed names, (6) generate wrapper script. +/// +/// # Errors +/// +/// Returns an error if any step fails. +fn run_install( + args: &InstallArgs, + stderr: &mut dyn Write, + wrapper_scripts: WrapperScripts, +) -> Result<()> { + let dirs = SystemBaseDirs::new().ok_or_else(|| InstallerError::WorkspaceNotFound { + reason: "could not determine platform directories".to_owned(), + })?; + if args.execution.dry_run { + return run_dry(args, &dirs, stderr); + } + let install_started = Instant::now(); + // Step 1: Check and install Dylint dependencies if needed + if !args.skip.skip_deps { + ensure_dylint_tools(args.quiet, stderr)?; + } + // Step 2: Ensure workspace is available (clone if needed) + let workspace_root = ensure_whitaker_workspace(args, &dirs, stderr)?; + // Step 3: Resolve crates and toolchain + let requested_crates = resolve_requested_crates(args)?; + let toolchain = resolve_toolchain(&workspace_root, args.toolchain.as_deref())?; + ensure_toolchain_installed( + &toolchain, + resolve_additional_components(args), + args.quiet, + stderr, + )?; + let target_dir = determine_target_dir(args.target_dir.as_deref())?; + // Step 3.5: Attempt prebuilt download or staged-suite fast path. + let fast_path_context = FastPathContext { + args, + dirs: &dirs, + requested_crates: &requested_crates, + toolchain: &toolchain, + target_dir: &target_dir, + }; + if let Some((staging_path, install_mode)) = + try_fast_path_installation(&fast_path_context, stderr)? + { + let finish_context = FinishInstallContext { + args, + dirs: &dirs, + toolchain: &toolchain, + staging_path: &staging_path, + wrapper_scripts, + install_mode, + install_started, + }; + return finish_install_and_record_metrics(&finish_context, stderr); + } + let context = PipelineContext { + workspace_root: &workspace_root, + toolchain: &toolchain, + target_dir: &target_dir, + jobs: args.jobs, + verbosity: args.verbosity, + experimental: args.lint_selection.experimental, + quiet: args.quiet, + }; + // Step 4: Build and stage + let build_results = perform_build(&context, &requested_crates, stderr)?; + let staging_path = stage_libraries(&context, &build_results, stderr)?; + // Step 5: Generate wrapper scripts if requested + let finish_context = FinishInstallContext { + args, + dirs: &dirs, + toolchain: &toolchain, + staging_path: &staging_path, + wrapper_scripts, + install_mode: InstallMode::Build, + install_started, + }; + finish_install_and_record_metrics(&finish_context, stderr) +} + +/// Checks for and installs Dylint tools if missing. +fn ensure_dylint_tools(quiet: bool, stderr: &mut dyn Write) -> Result<()> { + let executor = SystemCommandExecutor; + ensure_dylint_tools_with_executor(&executor, quiet, stderr) +} + +/// Ensures a Whitaker workspace is available. +fn ensure_whitaker_workspace( + args: &InstallArgs, + dirs: &dyn BaseDirs, + stderr: &mut dyn Write, +) -> Result { + use crate::workspace::{ + WorkspaceAction, + clone_directory, + decide_workspace_action, + ensure_workspace, + }; + + if !args.quiet + && let Some(clone_dir) = clone_directory(dirs) + { + let utf8_cwd = std::env::current_dir() + .ok() + .and_then(|p| Utf8PathBuf::try_from(p).ok()); + + let Some(cwd) = utf8_cwd else { + return ensure_workspace(dirs, !args.skip.no_update); + }; + + match decide_workspace_action(&cwd, &clone_dir, !args.skip.no_update) { + WorkspaceAction::CloneTo(dir) => { + write_stderr_line(stderr, format!("Cloning Whitaker repository to {dir}...")); + } + WorkspaceAction::UpdateAt(dir) => { + write_stderr_line(stderr, format!("Updating Whitaker repository at {dir}...")); + } + WorkspaceAction::UseCurrentDir(_) | WorkspaceAction::UseExisting(_) => {} + } + } + + ensure_workspace(dirs, !args.skip.no_update) +} + +/// Detect the workspace toolchain or apply an explicit channel override. +fn resolve_toolchain( + workspace_root: &Utf8Path, + override_channel: Option<&str>, +) -> Result { + override_channel.map_or_else( + || Toolchain::detect(workspace_root), + |channel| Ok(Toolchain::with_override(workspace_root, channel)), + ) +} + +/// Install the toolchain and extra components when absent. +fn ensure_toolchain_installed( + toolchain: &Toolchain, + additional_components: &[&str], + quiet: bool, + stderr: &mut dyn Write, +) -> Result<()> { + let status = toolchain.ensure_installed(additional_components)?; + if status.installed_toolchain() && !quiet { + write_stderr_line( + stderr, + format!("Toolchain {} installed successfully.", toolchain.channel()), + ); + write_stderr_line(stderr, ""); + } + Ok(()) +} + +/// Aggregates the immutable inputs for fast-path installation attempts. +struct FastPathContext<'a> { + args: &'a InstallArgs, + dirs: &'a dyn BaseDirs, + requested_crates: &'a [CrateName], + toolchain: &'a Toolchain, + target_dir: &'a Utf8PathBuf, +} + +/// Resolves requested crates from the CLI flags. +fn resolve_requested_crates(args: &InstallArgs) -> Result> { + let lint_crates: Vec = args + .lint + .iter() + .map(|name| CrateName::from(name.as_str())) + .collect(); + + let options = CrateResolutionOptions { + individual_lints: args.lint_selection.individual_lints, + experimental: args.lint_selection.experimental, + }; + if !lint_crates.is_empty() { + validate_crate_names(&lint_crates, &options)?; + } + + Ok(resolve_crates(&lint_crates, &options)) +} + +#[cfg(test)] +#[path = "../tests/mod.rs"] +mod tests; diff --git a/installer/src/staged_suite.rs b/installer/src/staged_suite.rs index ccc09a57b..298f436de 100644 --- a/installer/src/staged_suite.rs +++ b/installer/src/staged_suite.rs @@ -7,7 +7,8 @@ use std::fs; use camino::{Utf8Path, Utf8PathBuf}; -use whitaker_installer::{ + +use crate::{ crate_name::CrateName, error::{InstallerError, Result}, resolution::SUITE_CRATE, @@ -64,9 +65,9 @@ mod tests { use rstest::{fixture, rstest}; use temp_env::{with_var, with_var_unset}; use tempfile::TempDir; - use whitaker_installer::test_support::env_test_guard; use super::*; + use crate::test_support::env_test_guard; struct StagedSuiteSetup { _guard: std::sync::MutexGuard<'static, ()>, diff --git a/installer/src/tests/fast_path.rs b/installer/src/tests/fast_path.rs index 685a5c7e5..1485451f7 100644 --- a/installer/src/tests/fast_path.rs +++ b/installer/src/tests/fast_path.rs @@ -3,15 +3,15 @@ use camino::{Utf8Path, Utf8PathBuf}; use rstest::{fixture, rstest}; use temp_env::with_var_unset; -use whitaker_installer::{ + +use super::*; +use crate::{ cli::ExecutionFlags, crate_name::CrateName, test_support::{TEST_STAGE_SUITE_ENV, env_test_guard}, toolchain::Toolchain, }; -use super::*; - struct FastPathFixture { args: InstallArgs, dirs: TestBaseDirs, @@ -98,7 +98,8 @@ fn try_fast_path_installation_returns_some_build_path_when_staged_suite_enabled( mut fast_path_fixture: FastPathFixture, ) { use temp_env::with_var; - use whitaker_installer::test_support::TEST_STAGE_SUITE_ENV; + + use crate::test_support::TEST_STAGE_SUITE_ENV; let temp_dir = tempfile::tempdir().expect("create temp dir"); fast_path_fixture.target_dir = diff --git a/installer/src/tests/mod.rs b/installer/src/tests/mod.rs index 217766485..324112180 100644 --- a/installer/src/tests/mod.rs +++ b/installer/src/tests/mod.rs @@ -5,11 +5,15 @@ mod fast_path; use std::{path::PathBuf, time::Duration}; use rstest::{fixture, rstest}; -use whitaker_installer::{ + +use super::*; +use crate::{ cli::{InstallArgs, LintSelectionFlags}, dependency_binaries::DependencyBinaryInstaller, deps::DependencyInstallOptions, dirs::BaseDirs, + install_flow::{MetricsWriteContext, write_install_metrics}, + install_metrics::InstallMode, installer_packaging::TargetTriple, test_utils::{ dependency_binary_helpers::{ @@ -22,16 +26,11 @@ use whitaker_installer::{ }, }; -use super::*; - fn dependency_install_options<'a>( dirs: &'a TestBaseDirs, repository_installer: &'a dyn DependencyBinaryInstaller, quiet: bool, -) -> std::result::Result< - DependencyInstallOptions<'a>, - whitaker_installer::artefact::error::ArtefactError, -> { +) -> std::result::Result, crate::artefact::error::ArtefactError> { Ok(DependencyInstallOptions { dirs, repository_installer, diff --git a/installer/src/wrapper.rs b/installer/src/wrapper.rs index cc074a116..42efce9ae 100644 --- a/installer/src/wrapper.rs +++ b/installer/src/wrapper.rs @@ -17,14 +17,24 @@ use crate::{ /// Result of wrapper script generation. #[derive(Debug)] pub struct WrapperResult { - /// Path to the `whitaker` wrapper script. + /// Conventional path for the `whitaker` wrapper, if that script was + /// selected for creation. pub whitaker_path: std::path::PathBuf, - /// Path to the `whitaker-ls` wrapper script. + /// Path to the `whitaker-ls` wrapper script created by this invocation. pub whitaker_ls_path: std::path::PathBuf, /// Whether the bin directory is in PATH. pub in_path: bool, } +/// Selects which compatibility scripts an installer invocation may create. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrapperScripts { + /// Write both scripts used by the legacy `whitaker-installer` command. + Both, + /// Write only the lister script because the `whitaker` name is now a binary. + WhitakerLsOnly, +} + /// Generates wrapper scripts for invoking Whitaker lints. /// /// Creates `whitaker` and `whitaker-ls` scripts (shell on Unix, PowerShell on @@ -68,6 +78,41 @@ pub struct WrapperResult { pub fn generate_wrapper_scripts( dirs: &dyn BaseDirs, library_path: &Utf8Path, +) -> Result { + generate_wrapper_scripts_with(dirs, library_path, WrapperScripts::Both) +} + +/// Generates the selected compatibility scripts for invoking Whitaker lints. +/// +/// The returned paths name the conventional locations even when a script was +/// not selected for writing. This lets callers report a consistent PATH +/// location while preserving an executable installed at the `whitaker` path. +/// +/// # Examples +/// +/// ```no_run +/// use camino::Utf8Path; +/// use whitaker_installer::{ +/// dirs::SystemBaseDirs, +/// wrapper::{WrapperScripts, generate_wrapper_scripts_with}, +/// }; +/// +/// let dirs = SystemBaseDirs::new().expect("platform directories should exist"); +/// let library_path = Utf8Path::new("/home/user/.local/share/whitaker/lints"); +/// let result = +/// generate_wrapper_scripts_with(&dirs, library_path, WrapperScripts::WhitakerLsOnly)?; +/// assert!(result.whitaker_ls_path.exists()); +/// # Ok::<(), whitaker_installer::error::InstallerError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns a wrapper-generation error if the bin directory cannot be created +/// or a selected script cannot be written. +pub fn generate_wrapper_scripts_with( + dirs: &dyn BaseDirs, + library_path: &Utf8Path, + scripts: WrapperScripts, ) -> Result { let bin_dir = dirs.executables().ok_or_else(|| { InstallerError::WrapperGeneration("could not determine bin directory".to_owned()) @@ -78,10 +123,11 @@ pub fn generate_wrapper_scripts( })?; #[cfg(unix)] - let (whitaker_path, whitaker_ls_path) = generate_unix_scripts(&bin_dir, library_path)?; + let (whitaker_path, whitaker_ls_path) = generate_unix_scripts(&bin_dir, library_path, scripts)?; #[cfg(windows)] - let (whitaker_path, whitaker_ls_path) = generate_windows_scripts(&bin_dir, library_path)?; + let (whitaker_path, whitaker_ls_path) = + generate_windows_scripts(&bin_dir, library_path, scripts)?; #[cfg(not(any(unix, windows)))] return Err(InstallerError::WrapperGeneration( @@ -101,16 +147,19 @@ pub fn generate_wrapper_scripts( fn generate_unix_scripts( bin_dir: &Path, library_path: &Utf8Path, + scripts: WrapperScripts, ) -> Result<(std::path::PathBuf, std::path::PathBuf)> { let whitaker_path = bin_dir.join("whitaker"); - let whitaker_content = format!( - r#"#!/usr/bin/env bash + if scripts == WrapperScripts::Both { + let whitaker_content = format!( + r#"#!/usr/bin/env bash set -euo pipefail export DYLINT_LIBRARY_PATH="{library_path}" exec cargo dylint "$@" "# - ); - write_unix_script(&whitaker_path, &whitaker_content)?; + ); + write_unix_script(&whitaker_path, &whitaker_content)?; + } let whitaker_ls_path = bin_dir.join("whitaker-ls"); let whitaker_ls_content = format!( @@ -149,16 +198,20 @@ fn write_unix_script(path: &Path, content: &str) -> Result<()> { fn generate_windows_scripts( bin_dir: &Path, library_path: &Utf8Path, + scripts: WrapperScripts, ) -> Result<(std::path::PathBuf, std::path::PathBuf)> { let whitaker_path = bin_dir.join("whitaker.ps1"); - let whitaker_content = format!( - r#"$env:DYLINT_LIBRARY_PATH = "{library_path}" + if scripts == WrapperScripts::Both { + let whitaker_content = format!( + r#"$env:DYLINT_LIBRARY_PATH = "{library_path}" cargo dylint @args "# - ); + ); - std::fs::write(&whitaker_path, whitaker_content) - .map_err(|e| InstallerError::WrapperGeneration(format!("failed to write script: {e}")))?; + std::fs::write(&whitaker_path, whitaker_content).map_err(|e| { + InstallerError::WrapperGeneration(format!("failed to write script: {e}")) + })?; + } let whitaker_ls_path = bin_dir.join("whitaker-ls.ps1"); let whitaker_ls_content = format!( @@ -271,7 +324,8 @@ mod tests { let library_path = Utf8PathBuf::from("/tmp/dylint/lib"); let (whitaker_path, whitaker_ls_path) = - generate_unix_scripts(temp.path(), &library_path).expect("failed to generate scripts"); + generate_unix_scripts(temp.path(), &library_path, WrapperScripts::Both) + .expect("failed to generate scripts"); assert!(whitaker_path.exists()); assert!(whitaker_ls_path.exists()); @@ -286,6 +340,42 @@ mod tests { ); } + #[test] + fn lister_only_generation_preserves_an_existing_whitaker_binary() { + use camino::Utf8PathBuf; + + let temp = TempDir::new().expect("temporary bin parent should be created"); + let bin_dir = temp.path().join("bin"); + let whitaker_path = bin_dir.join(if cfg!(windows) { + "whitaker.ps1" + } else { + "whitaker" + }); + std::fs::create_dir_all(&bin_dir).expect("bin directory should be created"); + std::fs::write(&whitaker_path, "installed binary sentinel") + .expect("existing executable sentinel should be written"); + + let mut dirs = crate::dirs::MockBaseDirs::new(); + let expected_bin_dir = bin_dir.clone(); + dirs.expect_executables() + .return_once(move || Some(expected_bin_dir)); + let library_path = Utf8PathBuf::from("/tmp/dylint/lib"); + + let result = + generate_wrapper_scripts_with(&dirs, &library_path, WrapperScripts::WhitakerLsOnly) + .expect("lister wrapper should be generated"); + + assert_eq!( + std::fs::read(&whitaker_path).expect("existing executable should remain readable"), + b"installed binary sentinel", + "root install must not overwrite its own executable", + ); + assert!( + result.whitaker_ls_path.exists(), + "lister wrapper should be created" + ); + } + #[test] fn path_instructions_contains_directory() { let dir = std::path::PathBuf::from("/test/bin"); diff --git a/installer/tests/dry_run_snapshot.rs b/installer/tests/dry_run_snapshot.rs new file mode 100644 index 000000000..62487e751 --- /dev/null +++ b/installer/tests/dry_run_snapshot.rs @@ -0,0 +1,58 @@ +//! Regression snapshot for the legacy installer's dry-run output. + +use std::{error::Error, io, path::Path, process::Command}; + +use insta::assert_snapshot; + +fn pinned_toolchain_channel() -> Result> { + let manifest = include_str!("../../rust-toolchain.toml").parse::()?; + + manifest + .get("toolchain") + .and_then(toml::Value::as_table) + .and_then(|toolchain| toolchain.get("channel")) + .and_then(toml::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| io::Error::other("workspace toolchain channel is missing or invalid").into()) +} + +fn normalized_dry_run_output(output: &str) -> String { + output + .lines() + .map(|line| { + if line.starts_with("Workspace root: ") { + "Workspace root: [workspace]" + } else if line.starts_with("Target directory: ") { + "Target directory: [staging directory]" + } else if line.starts_with("Toolchain: ") { + "Toolchain: [pinned toolchain]" + } else { + line + } + }) + .collect::>() + .join("\n") +} + +#[test] +fn dry_run_output_matches_snapshot() { + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("installer must be nested under the workspace root"); + let toolchain = + pinned_toolchain_channel().expect("workspace toolchain channel should be valid"); + let output = Command::new(env!("CARGO_BIN_EXE_whitaker-installer")) + .args(["--dry-run", "--toolchain", &toolchain]) + .current_dir(workspace_root) + .output() + .expect("whitaker-installer should run"); + + assert!( + output.status.success(), + "dry-run must succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8(output.stderr).expect("dry-run stderr must be UTF-8"); + assert_snapshot!("dry_run_output", normalized_dry_run_output(&stderr)); +} diff --git a/installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap b/installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap new file mode 100644 index 000000000..74cbf9815 --- /dev/null +++ b/installer/tests/snapshots/dry_run_snapshot__dry_run_output.snap @@ -0,0 +1,17 @@ +--- +source: installer/tests/dry_run_snapshot.rs +expression: normalized_dry_run_output(&stderr) +--- +Dry run - no files will be modified + +Workspace root: [workspace] +Toolchain: [pinned toolchain] +Target directory: [staging directory] +Verbosity level: 0 +Quiet: false +Skip deps: false +Skip wrapper: false +No update: false + +Crates to build: + - whitaker_suite diff --git a/scripts/run-kani.sh b/scripts/run-kani.sh index 22b7abb69..9c3dc5387 100755 --- a/scripts/run-kani.sh +++ b/scripts/run-kani.sh @@ -20,8 +20,8 @@ # cargo-kani. set -eu -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -REPO_ROOT=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH='' cd -- "${SCRIPT_DIR}/.." && pwd) KANI_INSTALL_DIR=$("${SCRIPT_DIR}/install-kani.sh") CARGO_KANI_BIN="${KANI_INSTALL_DIR}/bin/cargo-kani" CLONE_DETECTOR_MANIFEST="${REPO_ROOT}/crates/whitaker_clones_core/Cargo.toml" @@ -80,9 +80,82 @@ run_decomposition_harnesses() { fi } +run_expected_counterexample() { + harness=$1 + expected_witness=$2 + expected_failure=$3 + shift 3 + output_file=$(mktemp) + if "${CARGO_KANI_BIN}" kani \ + --manifest-path "${REPO_ROOT}/Cargo.toml" \ + --package whitaker \ + --lib \ + --harness "${harness}" \ + "$@" >"${output_file}" 2>&1; then + cat "${output_file}" + rm -f "${output_file}" + printf 'Kani reachability harness unexpectedly verified: %s\n' "${harness}" >&2 + return 1 + fi + cat "${output_file}" + if ! grep -Fq 'VERIFICATION:- FAILED' "${output_file}" \ + || ! grep -Fq "Failed Checks: \"${expected_failure}\"" "${output_file}" \ + || ! awk -v expected="${expected_witness}" ' + /^Check [0-9]+:/ { + if (is_witness && status == "SUCCESS" && index(description, expected) > 0) { + found = 1 + } + is_witness = ($0 ~ /assertion\.1$/) + status = "" + description = "" + } + /- Status:/ && is_witness { status = $3 } + /- Description:/ && is_witness { description = $0 } + END { + if (is_witness && status == "SUCCESS" && index(description, expected) > 0) { + found = 1 + } + exit !found + } + ' "${output_file}"; then + rm -f "${output_file}" + printf 'Kani did not report the expected witness and named failure: %s\n' \ + "${harness}" >&2 + return 1 + fi + rm -f "${output_file}" +} + +run_whitaker_cli_harness() { + harness=$1 + printf 'Running Whitaker CLI Kani harness: %s\n' "${harness}" + "${CARGO_KANI_BIN}" kani \ + --manifest-path "${REPO_ROOT}/Cargo.toml" \ + --package whitaker \ + --lib \ + --harness "${harness}" +} + +run_whitaker_cli_harnesses() { + run_whitaker_cli_harness verify_classify_is_total_and_disjoint + run_expected_counterexample \ + verify_subcommand_is_reachable \ + 'assertion failed: classify(&[TokenTag::Install]) == Class::Subcommand(Subcommand::Install)' \ + 'subcommand class should have a counterexample' + run_expected_counterexample \ + verify_forward_is_reachable \ + 'assertion failed: classify(&[TokenTag::DylintFlag]) == Class::Forward' \ + 'forward class should have a counterexample' + run_expected_counterexample \ + verify_display_is_reachable \ + 'assertion failed: classify(&[TokenTag::Help]) == Class::Display' \ + 'display class should have a counterexample' +} + if [ $# -eq 0 ]; then run_decomposition_harnesses run_clone_detector_harnesses + run_whitaker_cli_harnesses exit 0 fi @@ -95,6 +168,10 @@ case "$1" in shift run_clone_detector_harnesses "$@" ;; + whitaker-cli) + shift + run_whitaker_cli_harnesses "$@" + ;; *) run_decomposition_harnesses "$@" ;; diff --git a/scripts/run-verus.sh b/scripts/run-verus.sh index dad62b00e..4dac99965 100755 --- a/scripts/run-verus.sh +++ b/scripts/run-verus.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -eu -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -REPO_ROOT=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH='' cd -- "${SCRIPT_DIR}/.." && pwd) VERUS_INSTALL_DIR=$("${SCRIPT_DIR}/install-verus.sh") VERUS_BIN="${VERUS_INSTALL_DIR}/verus" @@ -19,6 +19,9 @@ proof_files_for_group() { "${REPO_ROOT}/verus/clone_detector_candidate_pair.rs" \ "${REPO_ROOT}/verus/clone_detector_ast_features.rs" ;; + cli) + printf '%s\n' "${REPO_ROOT}/verus/whitaker_cli_dispatch.rs" + ;; all) printf '%s\n' \ "${REPO_ROOT}/verus/decomposition_cosine_threshold.rs" \ @@ -51,7 +54,7 @@ set_proof_files_for_group all if [ $# -gt 0 ] && [ "${1:0:1}" != "-" ]; then case "$1" in - all|decomposition|clone-detector) + all|decomposition|clone-detector|cli) set_proof_files_for_group "$1" shift ;; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 000000000..cabdc7266 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,281 @@ +//! Production adapters for installer, inventory, and Dylint subprocess ports. + +use std::{ffi::OsString, process::Command}; + +use camino::Utf8PathBuf; +use whitaker_installer::{ + dependency_binaries::host_target, + dirs::SystemBaseDirs, + installation_record::installed_paths, + list::{collect_installed, detect_active_toolchain}, + orchestration::run_install_for_whitaker, + prebuilt_path::prebuilt_library_dir, + stager::default_target_dir, +}; + +use crate::ports::{ + CliError, + InstallRequest, + InstallService, + LintInventory, + LintRunner, + ListRequest, + Services, + StagedInventory, +}; + +/// The system-backed adapters used by the `whitaker` executable. +#[derive(Debug, Default)] +pub struct SystemServices; + +impl SystemServices { + /// Creates the production adapter set. + /// + /// # Examples + /// + /// ``` + /// use whitaker::adapters::SystemServices; + /// + /// let services = SystemServices::new(); + /// let ports = services.ports(); + /// let _ = ports; + /// ``` + #[must_use] + pub const fn new() -> Self { Self } + + /// Borrows the implementation through the command ports. + /// + /// # Examples + /// + /// ``` + /// use whitaker::adapters::SystemServices; + /// + /// let services = SystemServices::new(); + /// let ports = services.ports(); + /// let _ = (ports.install, ports.inventory, ports.lint_runner); + /// ``` + #[must_use] + pub fn ports(&self) -> Services<'_> { + Services { + install: self, + inventory: self, + lint_runner: self, + } + } +} + +impl InstallService for SystemServices { + fn install( + &self, + request: &InstallRequest, + stderr: &mut dyn std::io::Write, + ) -> Result<(), CliError> { + run_install_for_whitaker(&request.arguments, stderr).map_err(|error| CliError::Install { + message: error.to_string(), + }) + } +} + +impl LintInventory for SystemServices { + fn list(&self, request: &ListRequest) -> Result { + let installed = + collect_installed(&request.arguments).map_err(|error| CliError::Inventory { + message: error.to_string(), + })?; + Ok(StagedInventory { + installed, + active_toolchain: detect_active_toolchain(), + }) + } +} + +impl LintRunner for SystemServices { + fn forward(&self, args: &[OsString]) -> Result { + let mut command = Command::new("cargo"); + command.arg("dylint").args(args); + if let Some(library_path) = active_library_path() { + command.env("DYLINT_LIBRARY_PATH", library_path); + } + let status = command + .status() + .map_err(|source| CliError::Forward { source })?; + Ok(status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(1)) + } +} + +/// Resolves the library path used when forwarding a command to cargo-dylint. +fn active_library_path() -> Option { + let explicit_path = std::env::var_os("DYLINT_LIBRARY_PATH"); + if explicit_path.as_ref().is_some_and(|path| !path.is_empty()) { + return explicit_path; + } + let recorded_paths = SystemBaseDirs::new() + .and_then(|dirs| installed_paths(&dirs).ok()) + .unwrap_or_default(); + let active_toolchain = detect_active_toolchain(); + let build_path = active_toolchain.as_ref().and_then(|toolchain| { + default_target_dir().map(|target_dir| target_dir.join(toolchain).join("release")) + }); + let prebuilt_path = active_toolchain.as_deref().and_then(prebuilt_library_path); + active_library_path_for(explicit_path, recorded_paths, build_path, prebuilt_path) +} + +/// Chooses an explicit path, then a build path, then a prebuilt path. +fn active_library_path_for( + explicit_path: Option, + recorded_paths: Vec, + build_path: Option, + prebuilt_path: Option, +) -> Option { + explicit_path + .filter(|path| !path.is_empty()) + .or_else(|| existing_recorded_directories(recorded_paths)) + .or_else(|| existing_directory(build_path)) + .or_else(|| existing_directory(prebuilt_path)) +} + +/// Joins every existing recorded path so Dylint can select by toolchain. +fn existing_recorded_directories(paths: Vec) -> Option { + let existing: Vec<_> = paths + .into_iter() + .filter(|path| path.is_absolute() && path.is_dir()) + .map(Utf8PathBuf::into_std_path_buf) + .filter(|path| std::env::join_paths([path]).is_ok()) + .collect(); + (!existing.is_empty()) + .then(|| std::env::join_paths(existing).ok()) + .flatten() +} + +/// Returns a directory path only when it currently names an existing directory. +fn existing_directory(directory: Option) -> Option { + directory + .filter(|candidate| candidate.is_dir()) + .map(|candidate| candidate.into_std_path_buf().into_os_string()) +} + +/// Resolves the prebuilt library directory for the active host and toolchain. +fn prebuilt_library_path(toolchain: &str) -> Option { + let dirs = SystemBaseDirs::new()?; + let target = host_target()?; + prebuilt_library_dir(&dirs, toolchain, target.as_str()).ok() +} + +#[cfg(test)] +mod tests { + //! Tests for adapter path selection and child-process behaviour. + + use std::ffi::OsString; + + use camino::{Utf8Path, Utf8PathBuf}; + use cap_std::{ambient_authority, fs_utf8::Dir}; + use tempfile::TempDir; + use whitaker_common::test_support::with_env_var; + + use super::{active_library_path, active_library_path_for}; + + fn utf8_path(path: &std::path::Path) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(path.to_path_buf()) + .expect("temporary test paths should be valid UTF-8") + } + + fn create_test_directory(temporary: &TempDir, name: &str) -> Utf8PathBuf { + let root = Dir::open_ambient_dir( + Utf8Path::from_path(temporary.path()).expect("temporary path should be valid UTF-8"), + ambient_authority(), + ) + .expect("temporary directory should be opened as a filesystem capability"); + root.create_dir_all(name) + .expect("test directory should be created inside the temporary directory"); + + utf8_path(&temporary.path().join(name)) + } + + #[test] + fn explicit_library_path_wins_when_build_and_prebuilt_directories_exist() { + let temporary = TempDir::new().expect("temporary directories should be created"); + let build_path = create_test_directory(&temporary, "build"); + let prebuilt_path = create_test_directory(&temporary, "prebuilt"); + let recorded_path = create_test_directory(&temporary, "recorded"); + let expected = OsString::from("/custom/dylint/lib"); + let actual = active_library_path_for( + Some(expected.clone()), + vec![recorded_path], + Some(build_path), + Some(prebuilt_path), + ); + + assert_eq!(actual, Some(expected)); + } + + #[test] + fn explicit_environment_path_precedes_recorded_and_default_paths() { + let expected = OsString::from("/custom/dylint/lib"); + + with_env_var("DYLINT_LIBRARY_PATH", &expected, || { + assert_eq!(active_library_path(), Some(expected.clone())); + }); + } + + #[test] + fn existing_build_directory_precedes_existing_prebuilt_directory() { + let temporary = TempDir::new().expect("temporary directories should be created"); + let build_path = create_test_directory(&temporary, "build"); + let prebuilt_path = create_test_directory(&temporary, "prebuilt"); + let expected = build_path.clone().into_os_string(); + + let actual = + active_library_path_for(None, Vec::new(), Some(build_path), Some(prebuilt_path)); + + assert_eq!(actual, Some(expected)); + } + + #[test] + fn prebuilt_directory_is_used_when_build_directory_is_absent() { + let temporary = TempDir::new().expect("temporary directories should be created"); + let missing_build_path = utf8_path(&temporary.path().join("build")); + let prebuilt_path = create_test_directory(&temporary, "prebuilt"); + let expected = prebuilt_path.clone().into_os_string(); + + let actual = active_library_path_for( + None, + Vec::new(), + Some(missing_build_path), + Some(prebuilt_path), + ); + + assert_eq!(actual, Some(expected)); + } + + #[test] + fn recorded_directories_cover_each_toolchain_before_default_candidates() { + let temporary = TempDir::new().expect("temporary directories should be created"); + let stable = create_test_directory(&temporary, "stable-custom-target"); + let nightly = create_test_directory(&temporary, "nightly-custom-target"); + let expected = std::env::join_paths([ + stable.as_std_path().to_path_buf(), + nightly.as_std_path().to_path_buf(), + ]) + .expect("temporary paths should form an environment path list"); + + let actual = active_library_path_for(None, vec![stable, nightly], None, None); + + assert_eq!(actual, Some(expected)); + } + + #[cfg(unix)] + #[test] + fn an_unrepresentable_recorded_directory_does_not_hide_valid_paths() { + let temporary = TempDir::new().expect("temporary directories should be created"); + let valid = create_test_directory(&temporary, "valid"); + let invalid = create_test_directory(&temporary, "invalid:directory"); + let expected = valid.clone().into_os_string(); + + let actual = active_library_path_for(None, vec![invalid, valid], None, None); + + assert_eq!(actual, Some(expected)); + } +} diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs new file mode 100644 index 000000000..57e1b1b7f --- /dev/null +++ b/src/cli/dispatch.rs @@ -0,0 +1,189 @@ +//! Pure token classification for the root command and legacy Dylint forwarding. + +/// Token categories relevant to command selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenTag { + /// The `install` subcommand. + Install, + /// The `ls` subcommand. + Ls, + /// A help request. + Help, + /// A version request. + Version, + /// The end-of-options marker. + DoubleDash, + /// A root-level quiet or verbose option. + GlobalFlag, + /// An option owned by `cargo dylint`. + DylintFlag, + /// A value or unrecognized token. + Other, +} + +/// A recognized Whitaker command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Subcommand { + /// Install dependencies and lint libraries. + Install, + /// List installed lint libraries. + Ls, +} + +/// How an argument vector should be handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + /// Dispatch to a Whitaker subcommand. + Subcommand(Subcommand), + /// Forward verbatim to `cargo dylint`. + Forward, + /// Parse and print root help or version output. + Display, +} + +/// Classifies a tokenized argument vector. +/// +/// Leading shared quiet and verbose flags do not change the command. The first +/// other token decides routing, so Dylint options remain owned by the legacy +/// forwarding path even when they precede a subcommand-looking value. +/// +/// # Examples +/// +/// ``` +/// use whitaker::cli::dispatch::{Class, Subcommand, TokenTag, classify}; +/// +/// assert_eq!( +/// classify(&[TokenTag::GlobalFlag, TokenTag::Install]), +/// Class::Subcommand(Subcommand::Install), +/// ); +/// assert_eq!(classify(&[TokenTag::DylintFlag]), Class::Forward); +/// ``` +#[must_use] +pub fn classify(tokens: &[TokenTag]) -> Class { + for token in tokens { + match token { + TokenTag::GlobalFlag => {} + TokenTag::Install => return Class::Subcommand(Subcommand::Install), + TokenTag::Ls => return Class::Subcommand(Subcommand::Ls), + TokenTag::Help | TokenTag::Version => return Class::Display, + TokenTag::DoubleDash | TokenTag::DylintFlag | TokenTag::Other => { + return Class::Forward; + } + } + } + Class::Forward +} + +#[cfg(test)] +mod tests { + //! Parameterized examples for the routing precedence. + + use googletest::prelude::*; + use rstest::rstest; + + use super::{Class, Subcommand, TokenTag, classify}; + + #[gtest] + #[rstest] + #[case::install(Subcommand::Install, vec![TokenTag::Install])] + #[case::install_after_quiet(Subcommand::Install, vec![TokenTag::GlobalFlag, TokenTag::Install])] + #[case::ls(Subcommand::Ls, vec![TokenTag::Ls])] + fn recognized_commands_are_never_forwarded( + #[case] subcommand: Subcommand, + #[case] tokens: Vec, + ) -> googletest::Result<()> { + verify_that!(classify(&tokens), eq(Class::Subcommand(subcommand)))?; + Ok(()) + } + + #[rstest] + #[case::help(vec![TokenTag::Help])] + #[case::version(vec![TokenTag::GlobalFlag, TokenTag::Version])] + fn display_requests_are_separated_from_dylint_options(#[case] tokens: Vec) { + assert_eq!( + classify(&tokens), + Class::Display, + "display request must not forward" + ); + } + + #[rstest] + #[case::all(vec![TokenTag::DylintFlag])] + #[case::literal_after_double_dash(vec![TokenTag::DoubleDash, TokenTag::Other])] + #[case::unrecognized_command(vec![TokenTag::Other])] + #[case::empty(vec![])] + fn dylint_and_unknown_arguments_are_forwarded(#[case] tokens: Vec) { + assert_eq!( + classify(&tokens), + Class::Forward, + "legacy arguments must forward" + ); + } +} + +#[cfg(kani)] +mod verification { + //! Bounded checks for routing totality, disjointness, and reachability. + + use super::{Class, Subcommand, TokenTag, classify}; + + fn tag_for(value: u8) -> TokenTag { + match value & 7 { + 0 => TokenTag::Install, + 1 => TokenTag::Ls, + 2 => TokenTag::Help, + 3 => TokenTag::Version, + 4 => TokenTag::DoubleDash, + 5 => TokenTag::GlobalFlag, + 6 => TokenTag::DylintFlag, + _ => TokenTag::Other, + } + } + + fn first_significant(tokens: &[TokenTag]) -> Option { + tokens + .iter() + .copied() + .find(|token| *token != TokenTag::GlobalFlag) + } + + #[kani::proof] + #[kani::unwind(7)] + fn verify_classify_is_total_and_disjoint() { + let raw: [u8; 6] = kani::any(); + let length: usize = kani::any(); + kani::assume(length <= raw.len()); + let tags = raw.map(tag_for); + let bounded = tags.get(..length).unwrap_or_default(); + let result = classify(bounded); + match first_significant(bounded) { + Some(TokenTag::Install) => { + assert!(result == Class::Subcommand(Subcommand::Install)); + } + Some(TokenTag::Ls) => assert!(result == Class::Subcommand(Subcommand::Ls)), + Some(TokenTag::Help | TokenTag::Version) => assert!(result == Class::Display), + Some(TokenTag::DoubleDash | TokenTag::DylintFlag | TokenTag::Other) | None => { + assert!(result == Class::Forward); + } + Some(TokenTag::GlobalFlag) => assert!(false, "filter must skip global flags"), + } + } + + #[kani::proof] + fn verify_subcommand_is_reachable() { + assert!(classify(&[TokenTag::Install]) == Class::Subcommand(Subcommand::Install)); + assert!(false, "subcommand class should have a counterexample"); + } + + #[kani::proof] + fn verify_forward_is_reachable() { + assert!(classify(&[TokenTag::DylintFlag]) == Class::Forward); + assert!(false, "forward class should have a counterexample"); + } + + #[kani::proof] + fn verify_display_is_reachable() { + assert!(classify(&[TokenTag::Help]) == Class::Display); + assert!(false, "display class should have a counterexample"); + } +} diff --git a/src/cli/exit.rs b/src/cli/exit.rs new file mode 100644 index 000000000..8bece37ba --- /dev/null +++ b/src/cli/exit.rs @@ -0,0 +1,59 @@ +//! Process exit policy for parsed, operational, and forwarded outcomes. + +/// A completed command outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// A command completed successfully. + Success, + /// A help or version request was printed. + Display, + /// Argument parsing failed. + UsageError, + /// An installer, inventory, output, or process error occurred. + OperationalFailure, + /// `cargo dylint` returned this status. + Forwarded(u8), +} + +/// Maps an outcome onto a process exit code. +/// +/// # Examples +/// +/// ``` +/// use whitaker::cli::exit::{Outcome, exit_code_for}; +/// +/// assert_eq!(exit_code_for(&Outcome::UsageError), 2); +/// assert_eq!(exit_code_for(&Outcome::Forwarded(23)), 23); +/// ``` +#[must_use] +pub const fn exit_code_for(outcome: &Outcome) -> u8 { + match outcome { + Outcome::Success | Outcome::Display => 0, + Outcome::UsageError => 2, + Outcome::OperationalFailure => 1, + Outcome::Forwarded(code) => *code, + } +} + +#[cfg(test)] +mod tests { + //! Tests for the exit-code partition. + + use rstest::rstest; + + use super::{Outcome, exit_code_for}; + + #[rstest] + #[case::success(Outcome::Success, 0)] + #[case::display(Outcome::Display, 0)] + #[case::usage_error(Outcome::UsageError, 2)] + #[case::operational_failure(Outcome::OperationalFailure, 1)] + #[case::forwarded_nonzero(Outcome::Forwarded(17), 17)] + fn maps_outcomes_to_exit_codes(#[case] outcome: Outcome, #[case] expected: u8) { + assert_eq!( + exit_code_for(&outcome), + expected, + "wrong exit code for {outcome:?}" + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 000000000..4fe8951bc --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,10 @@ +//! Localized command parsing, routing, and process-facing command execution. + +pub mod dispatch; +pub mod exit; + +mod parser; +mod runner; + +pub use parser::{Cli, Command}; +pub use runner::{CommandStreams, run_from}; diff --git a/src/cli/parser.rs b/src/cli/parser.rs new file mode 100644 index 000000000..597d9398c --- /dev/null +++ b/src/cli/parser.rs @@ -0,0 +1,268 @@ +//! Parses localized Whitaker commands and extracts shared root options. + +use std::ffi::{OsStr, OsString}; + +use clap::{CommandFactory, Parser, Subcommand as ClapSubcommand}; +use ortho_config::{LocalizedParse as _, Localizer}; +use whitaker_installer::cli::{InstallArgs, ListArgs}; + +/// Parsed root command and the common options consumed before dispatch. +#[derive(Debug)] +pub struct Cli { + /// Quiet-mode option shared by Whitaker commands. + pub quiet: bool, + /// Verbosity count shared by Whitaker commands. + pub verbosity: u8, + /// Required root subcommand. + pub command: Command, +} + +/// Commands owned by the root Whitaker executable. +#[derive(Debug, ClapSubcommand)] +pub enum Command { + /// Install or repair Whitaker dependencies and lint libraries. + Install(InstallArgs), + /// Show installed lints and bundle metadata. + Ls(ListArgs), +} + +/// Clap parser for the subcommand surface. +#[derive(Debug, Parser)] +#[command( + name = "whitaker", + version, + about = "Install and inspect Whitaker lint libraries", + after_help = concat!( + "Global options accepted before or after a subcommand:\n", + " -q, --quiet\n", + " -v, --verbose...", + ) +)] +struct ParsedCli { + #[command(subcommand)] + command: Command, +} + +impl Cli { + /// Parses arguments with localized command metadata and clap errors. + /// + /// Shared quiet and verbose options are extracted before clap parses the + /// selected command. The installer argument type remains the single source + /// of truth for its option values and conflicts. + /// + /// # Examples + /// + /// ``` + /// use ortho_config::NoOpLocalizer; + /// use whitaker::cli::{Cli, Command}; + /// + /// let cli = + /// Cli::try_parse_localized_from(["whitaker", "install", "--dry-run"], &NoOpLocalizer::new())?; + /// assert!(matches!(cli.command, Command::Install(_))); + /// # Ok::<(), clap::Error>(()) + /// ``` + /// + /// # Errors + /// + /// Returns a clap error for invalid options or invalid combinations. + pub fn try_parse_localized_from( + arguments: I, + localizer: &dyn Localizer, + ) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + let raw: Vec = arguments.into_iter().map(Into::into).collect(); + let (parsed_arguments, quiet, verbosity) = extract_global_options(raw); + let parsed = ParsedCli::try_parse_localized_from(parsed_arguments, localizer)?; + if quiet && verbosity > 0 { + return Err(ParsedCli::command().error( + clap::error::ErrorKind::ArgumentConflict, + "the argument '--quiet' cannot be used with '--verbose'", + )); + } + Ok(Self { + quiet, + verbosity, + command: parsed.command, + }) + } + + /// Returns install arguments with the shared root options applied. + /// + /// # Examples + /// + /// ``` + /// use ortho_config::NoOpLocalizer; + /// use whitaker::cli::Cli; + /// use whitaker_installer::cli::InstallArgs; + /// + /// let cli = Cli::try_parse_localized_from( + /// ["whitaker", "-v", "install", "--dry-run"], + /// &NoOpLocalizer::new(), + /// )?; + /// let arguments = cli.install_arguments(&InstallArgs::default()); + /// assert_eq!(arguments.verbosity, 1); + /// # Ok::<(), clap::Error>(()) + /// ``` + #[must_use] + pub fn install_arguments(&self, parsed_arguments: &InstallArgs) -> InstallArgs { + let mut arguments = parsed_arguments.clone(); + arguments.quiet = self.quiet; + arguments.verbosity = self.verbosity; + arguments + } +} + +#[derive(Debug, Clone, Copy)] +enum CommonOption { + Quiet, + Verbose(u8), + QuietAndVerbose(u8), +} + +/// Keeps argv[0], then extracts shared flags until the `--` delimiter. +/// +/// Quiet and verbose flags are accepted anywhere before `--`. +fn extract_global_options(arguments: Vec) -> (Vec, bool, u8) { + let mut parsed_arguments = Vec::with_capacity(arguments.len()); + let mut quiet = false; + let mut verbosity = 0_u8; + let mut shared_options_are_enabled = true; + for (index, argument) in arguments.into_iter().enumerate() { + if index == 0 { + parsed_arguments.push(argument); + continue; + } + if argument == OsStr::new("--") { + shared_options_are_enabled = false; + parsed_arguments.push(argument); + continue; + } + if !shared_options_are_enabled { + parsed_arguments.push(argument); + continue; + } + match common_option(&argument) { + Some(CommonOption::Quiet) => quiet = true, + Some(CommonOption::Verbose(count)) => { + verbosity = verbosity.saturating_add(count); + } + Some(CommonOption::QuietAndVerbose(count)) => { + quiet = true; + verbosity = verbosity.saturating_add(count); + } + None => parsed_arguments.push(argument), + } + } + (parsed_arguments, quiet, verbosity) +} + +/// Recognizes a quiet or verbose flag, including supported short clusters. +fn common_option(argument: &OsStr) -> Option { + match argument.to_str()? { + "-q" | "--quiet" => Some(CommonOption::Quiet), + "-v" | "--verbose" | "--verbosity" => Some(CommonOption::Verbose(1)), + value if is_verbosity_cluster(value) => { + let count = u8::try_from(value.len().saturating_sub(1)).unwrap_or(u8::MAX); + Some(CommonOption::Verbose(count)) + } + value if is_mixed_common_cluster(value) => { + let options = value.strip_prefix('-')?; + let count = u8::try_from(options.chars().filter(|option| *option == 'v').count()) + .unwrap_or(u8::MAX); + Some(CommonOption::QuietAndVerbose(count)) + } + _ => None, + } +} + +/// Reports whether a short cluster contains only recognized common options. +pub(super) fn is_common_cluster(value: &str) -> bool { + is_verbosity_cluster(value) || is_mixed_common_cluster(value) +} + +/// Recognizes a cluster containing two or more `-v` flags. +fn is_verbosity_cluster(value: &str) -> bool { + let Some(short_options) = value.strip_prefix('-') else { + return false; + }; + short_options.len() > 1 && short_options.chars().all(|option| option == 'v') +} + +/// Recognizes a cluster of `-q` and `-v` flags that includes quiet mode. +fn is_mixed_common_cluster(value: &str) -> bool { + let Some(options) = value.strip_prefix('-') else { + return false; + }; + options.len() > 1 + && options.chars().all(|option| matches!(option, 'q' | 'v')) + && options.contains('q') +} + +#[cfg(test)] +mod tests { + //! Tests for localized parsing and the shared root flags. + + use ortho_config::NoOpLocalizer; + + use super::{Cli, Command}; + + #[test] + fn common_flags_are_accepted_before_and_after_subcommands() { + let before = Cli::try_parse_localized_from( + ["whitaker", "-q", "install", "--dry-run"], + &NoOpLocalizer::new(), + ) + .expect("quiet before install should parse"); + assert!(before.quiet, "quiet should be stored on the root command"); + let after = Cli::try_parse_localized_from( + ["whitaker", "install", "-vv", "--dry-run"], + &NoOpLocalizer::new(), + ) + .expect("repeated verbosity after install should parse"); + assert_eq!(after.verbosity, 2, "repeated verbosity should be counted"); + } + + #[test] + fn root_flags_are_applied_to_the_shared_installer_request() { + let cli = Cli::try_parse_localized_from( + ["whitaker", "install", "--quiet", "--dry-run"], + &NoOpLocalizer::new(), + ) + .expect("install command should parse"); + let Command::Install(parsed) = &cli.command else { + panic!("command should be install"); + }; + let effective = cli.install_arguments(parsed); + assert!( + effective.quiet, + "shared quiet option should reach installer" + ); + assert!( + effective.execution.dry_run, + "installer option should remain set" + ); + } + + #[cfg(unix)] + #[test] + fn invalid_utf8_arguments_remain_parseable_as_os_strings() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let invalid = OsString::from_vec(vec![0xff]); + let parsed = Cli::try_parse_localized_from( + [ + OsString::from("whitaker"), + OsString::from("install"), + invalid, + ], + &NoOpLocalizer::new(), + ); + assert!( + parsed.is_err(), + "invalid install argument should be rejected" + ); + } +} diff --git a/src/cli/runner.rs b/src/cli/runner.rs new file mode 100644 index 000000000..b8e3a5408 --- /dev/null +++ b/src/cli/runner.rs @@ -0,0 +1,186 @@ +//! Executes routed commands and forwards legacy Dylint invocations. + +use std::{ + ffi::{OsStr, OsString}, + io::Write, +}; + +use ortho_config::{Localizer, is_display_request}; + +use super::{ + Cli, + Command, + dispatch::{Class, TokenTag, classify}, + exit::Outcome, + parser::is_common_cluster, +}; +use crate::ports::{CliError, InstallRequest, ListRequest, Services}; + +/// Separate output sinks passed through a root command invocation. +pub struct CommandStreams<'a> { + stdout: &'a mut dyn Write, + stderr: &'a mut dyn Write, +} + +impl<'a> CommandStreams<'a> { + /// Creates a stream pair for command output and diagnostics. + pub fn new(stdout: &'a mut dyn Write, stderr: &'a mut dyn Write) -> Self { + Self { stdout, stderr } + } +} + +/// Routes and executes the argument vector, preserving the legacy Dylint path. +/// +/// # Examples +/// +/// ``` +/// use std::io; +/// +/// use ortho_config::NoOpLocalizer; +/// use whitaker::{adapters::SystemServices, cli}; +/// +/// let services = SystemServices::new(); +/// let ports = services.ports(); +/// let mut stdout = io::sink(); +/// let mut stderr = io::sink(); +/// let outcome = cli::run_from( +/// ["whitaker", "--help"], +/// &ports, +/// &NoOpLocalizer::new(), +/// &mut cli::CommandStreams::new(&mut stdout, &mut stderr), +/// ); +/// assert_eq!(cli::exit::exit_code_for(&outcome), 0); +/// ``` +pub fn run_from( + arguments: I, + services: &Services<'_>, + localizer: &dyn Localizer, + streams: &mut CommandStreams<'_>, +) -> Outcome +where + I: IntoIterator, + T: Into + Clone, +{ + let raw: Vec = arguments.into_iter().map(Into::into).collect(); + let route = classify(&tokenize_process_args(&raw)); + if route == Class::Forward { + return forward_arguments(&raw, services, streams.stderr); + } + match Cli::try_parse_localized_from(raw, localizer) { + Ok(cli) => run_command(&cli, services, streams), + Err(error) if is_display_request(&error) => print_clap_error(&error, Outcome::Display), + Err(error) => print_clap_error(&error, Outcome::UsageError), + } +} + +/// Dispatches a parsed root command to its installer or inventory port. +fn run_command(cli: &Cli, services: &Services<'_>, streams: &mut CommandStreams<'_>) -> Outcome { + match &cli.command { + Command::Install(parsed_arguments) => { + run_install(cli.install_arguments(parsed_arguments), services, streams) + } + Command::Ls(arguments) => run_list(arguments, services, streams), + } +} + +/// Invokes the installer port and maps its result to a process outcome. +fn run_install( + arguments: whitaker_installer::cli::InstallArgs, + services: &Services<'_>, + streams: &mut CommandStreams<'_>, +) -> Outcome { + let result = services + .install + .install(&InstallRequest { arguments }, streams.stderr); + outcome_for_result(result, streams.stderr) +} + +/// Collects and renders installed-lint inventory through the inventory port. +fn run_list( + arguments: &whitaker_installer::cli::ListArgs, + services: &Services<'_>, + streams: &mut CommandStreams<'_>, +) -> Outcome { + let result = services + .inventory + .list(&ListRequest { + arguments: arguments.clone(), + }) + .and_then(|inventory| write_inventory(streams.stdout, arguments.json, &inventory)); + outcome_for_result(result, streams.stderr) +} + +/// Maps successful work or a CLI error to its command outcome. +fn outcome_for_result(result: Result, stderr: &mut dyn Write) -> Outcome { + match result { + Ok(_) => Outcome::Success, + Err(error) => print_cli_error(&error, stderr), + } +} + +/// Writes the selected human-readable or JSON inventory representation. +fn write_inventory( + stdout: &mut dyn Write, + as_json: bool, + inventory: &crate::ports::StagedInventory, +) -> Result<(), CliError> { + let output = if as_json { + whitaker_installer::list_output::format_json( + &inventory.installed, + inventory.active_toolchain.as_deref(), + ) + } else { + whitaker_installer::list_output::format_human( + &inventory.installed, + inventory.active_toolchain.as_deref(), + ) + }; + writeln!(stdout, "{output}").map_err(|source| CliError::Output { source }) +} + +/// Writes an operational CLI error and returns its failure outcome. +fn print_cli_error(error: &CliError, stderr: &mut dyn Write) -> Outcome { + writeln!(stderr, "{error}").unwrap_or(()); + Outcome::OperationalFailure +} + +/// Prints a clap diagnostic through clap's own output streams. +fn print_clap_error(error: &clap::Error, outcome: Outcome) -> Outcome { + match error.print() { + Ok(()) => outcome, + Err(_) => Outcome::OperationalFailure, + } +} + +/// Forwards legacy lint arguments to cargo-dylint and preserves its status. +fn forward_arguments(raw: &[OsString], services: &Services<'_>, stderr: &mut dyn Write) -> Outcome { + let arguments: Vec = raw.iter().skip(1).cloned().collect(); + match services.lint_runner.forward(&arguments) { + Ok(code) => Outcome::Forwarded(code), + Err(error) => print_cli_error(&error, stderr), + } +} + +/// Classifies every process argument after argv[0] for route selection. +fn tokenize_process_args(arguments: &[OsString]) -> Vec { + arguments + .iter() + .skip(1) + .map(|argument| token_tag(argument)) + .collect() +} + +/// Classifies one token; bare `help` is a root display request. +fn token_tag(argument: &OsStr) -> TokenTag { + match argument.to_str() { + Some("help" | "-h" | "--help") => TokenTag::Help, + Some("install") => TokenTag::Install, + Some("ls") => TokenTag::Ls, + Some("-V" | "--version") => TokenTag::Version, + Some("--") => TokenTag::DoubleDash, + Some("-q" | "--quiet" | "-v" | "--verbose" | "--verbosity") => TokenTag::GlobalFlag, + Some(value) if is_common_cluster(value) => TokenTag::GlobalFlag, + Some(value) if value.starts_with('-') => TokenTag::DylintFlag, + Some(_) | None => TokenTag::Other, + } +} diff --git a/src/lib.rs b/src/lib.rs index 0e8eedd07..dd7d7941c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,30 +1,8 @@ -//! Core Whitaker library surfaces shared configuration and helpers for lint crates. -#![cfg_attr(feature = "dylint-driver", feature(rustc_private))] - -// Link against `rustc_driver` only when consumers need the dylint driver runtime. -// Unit tests of this crate should not pull the compiler driver to avoid the -// duplicated `std`/`core` link errors seen during all-features test runs. -#[cfg(feature = "dylint-driver")] -extern crate rustc_data_structures; -#[cfg(all(feature = "dylint-driver", not(test)))] -extern crate rustc_driver; - -pub mod config; -#[cfg(feature = "dylint-driver")] -pub mod hir; -pub mod lints; -pub mod testing; - -pub use config::{ModuleMaxLinesConfig, SharedConfig}; -#[cfg(feature = "dylint-driver")] -pub use hir::{ - module_body_span, - module_header_span, - recover_user_editable_hir_span, - span_recovery_frames, -}; -pub use lints::{LintCrateTemplate, TemplateError, TemplateFiles}; - -/// Returns a greeting for the library. -#[must_use] -pub const fn greet() -> &'static str { "Hello from Whitaker!" } +//! Public command-line application boundary for Whitaker. +//! +//! The root package owns command selection and process exit policy. Installation, +//! inventory scanning, and Dylint execution remain behind installer adapters. + +pub mod adapters; +pub mod cli; +pub mod ports; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..ccce78330 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,24 @@ +//! Process entry point for the root `whitaker` command. + +use std::process; + +use ortho_config::NoOpLocalizer; +use whitaker::{adapters::SystemServices, cli}; + +fn main() { + let services = SystemServices::new(); + let ports = services.ports(); + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + let localizer = NoOpLocalizer::new(); + let outcome = cli::run_from( + std::env::args_os(), + &ports, + &localizer, + &mut cli::CommandStreams::new(&mut stdout, &mut stderr), + ); + let exit_code = cli::exit::exit_code_for(&outcome); + if exit_code != 0 { + process::exit(i32::from(exit_code)); + } +} diff --git a/src/ports.rs b/src/ports.rs new file mode 100644 index 000000000..4a580d973 --- /dev/null +++ b/src/ports.rs @@ -0,0 +1,154 @@ +//! Application ports separating CLI policy from installer and process adapters. + +use std::{ffi::OsString, io, io::Write}; + +use thiserror::Error; +use whitaker_installer::{ + cli::{InstallArgs, ListArgs}, + scanner::InstalledLints, +}; + +/// A semantic error returned by a command adapter. +#[derive(Debug, Error)] +pub enum CliError { + /// The installer could not complete the requested installation. + #[error("installation failed: {message}")] + Install { + /// Human-readable installer error details. + message: String, + }, + /// Installed lint inventory could not be read. + #[error("could not list installed lints: {message}")] + Inventory { + /// Human-readable inventory error details. + message: String, + }, + /// The forwarded Dylint process could not be started. + #[error("could not start cargo dylint: {source}")] + Forward { + /// The operating-system process error. + #[source] + source: io::Error, + }, + /// Writing command output failed. + #[error("could not write command output")] + Output { + /// The operating-system write error. + #[source] + source: io::Error, + }, +} + +/// Input needed to install Whitaker dependencies and lint libraries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallRequest { + /// Installer options parsed by the shared installer library. + pub arguments: InstallArgs, +} + +/// Input needed to inspect staged lint libraries. +#[derive(Debug, Clone)] +pub struct ListRequest { + /// List options parsed by the shared installer library. + pub arguments: ListArgs, +} + +/// Staged lint metadata and the toolchain active in the current directory. +#[derive(Debug, Default)] +pub struct StagedInventory { + /// Installed libraries grouped by toolchain. + pub installed: InstalledLints, + /// Active toolchain when the current workspace declares one. + pub active_toolchain: Option, +} + +/// Performs an installation on behalf of the command layer. +pub trait InstallService { + /// Runs an installation, streaming progress and diagnostics to `stderr`. + /// + /// # Examples + /// + /// ```no_run + /// use std::io; + /// + /// use whitaker::{ + /// adapters::SystemServices, + /// ports::{InstallRequest, InstallService}, + /// }; + /// use whitaker_installer::cli::InstallArgs; + /// + /// let services = SystemServices::new(); + /// let request = InstallRequest { + /// arguments: InstallArgs::default(), + /// }; + /// let mut stderr = io::sink(); + /// services.install(&request, &mut stderr)?; + /// # Ok::<(), whitaker::ports::CliError>(()) + /// ``` + /// + /// # Errors + /// + /// Returns an error when the installation cannot complete. + fn install(&self, request: &InstallRequest, stderr: &mut dyn Write) -> Result<(), CliError>; +} + +/// Reports the lints currently staged on this machine. +pub trait LintInventory { + /// Lists staged lints in the requested staging directory. + /// + /// # Examples + /// + /// ```no_run + /// use clap::Parser as _; + /// use whitaker::{ + /// adapters::SystemServices, + /// ports::{LintInventory, ListRequest}, + /// }; + /// use whitaker_installer::cli::ListArgs; + /// + /// let services = SystemServices::new(); + /// let inventory = services.list(&ListRequest { + /// arguments: ListArgs::try_parse_from(["whitaker"])?, + /// })?; + /// println!("{} toolchains", inventory.installed.by_toolchain.len()); + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Errors + /// + /// Returns an error when the staging directory cannot be scanned. + fn list(&self, request: &ListRequest) -> Result; +} + +/// Runs Dylint on behalf of a forwarded invocation. +pub trait LintRunner { + /// Forwards arguments to `cargo dylint` and returns its exit code. + /// + /// # Examples + /// + /// ```no_run + /// use std::ffi::OsString; + /// + /// use whitaker::{adapters::SystemServices, ports::LintRunner}; + /// + /// let services = SystemServices::new(); + /// let status = services.forward(&[OsString::from("--all")])?; + /// println!("cargo dylint exited with {status}"); + /// # Ok::<(), whitaker::ports::CliError>(()) + /// ``` + /// + /// # Errors + /// + /// Returns an error when the subprocess cannot be started. + fn forward(&self, args: &[OsString]) -> Result; +} + +/// Groups the independently replaceable command adapters. +pub struct Services<'a> { + /// Installation adapter. + pub install: &'a dyn InstallService, + /// Installed-lint inventory adapter. + pub inventory: &'a dyn LintInventory, + /// Dylint subprocess adapter. + pub lint_runner: &'a dyn LintRunner, +} diff --git a/tests/behaviour_binstall.rs b/tests/behaviour_binstall.rs new file mode 100644 index 000000000..2aa7a6648 --- /dev/null +++ b/tests/behaviour_binstall.rs @@ -0,0 +1,93 @@ +//! Behavioural checks for the root package's cargo-binstall metadata. + +use std::path::Path; + +use rstest::rstest; +use toml::{Table, Value}; +use whitaker_installer::binstall_metadata::{ + BIN_DIR_TEMPLATE, + DEFAULT_PKG_FMT, + PKG_URL_TEMPLATE, + WINDOWS_OVERRIDE_TARGET, + WINDOWS_PKG_FMT, + expand_pkg_url_for, + extract_binstall_table, + load_cargo_toml_from, +}; + +const RELEASE_TARGETS: [&str; 5] = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + WINDOWS_OVERRIDE_TARGET, +]; + +fn required_table<'a>(table: &'a Table, key: &str) -> Result<&'a Table, String> { + table + .get(key) + .and_then(Value::as_table) + .ok_or_else(|| format!("{key} table not found")) +} + +fn required_string<'a>(table: &'a Table, key: &str) -> Result<&'a str, String> { + table + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("{key} string not found")) +} + +#[rstest] +fn whitaker_package_metadata() { + let validation = validate_whitaker_package_metadata(); + assert!( + validation.is_ok(), + "root package metadata is inconsistent: {validation:?}" + ); +} + +fn validate_whitaker_package_metadata() -> Result<(), String> { + let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let manifest = load_cargo_toml_from(&manifest_path)?; + let package = required_table(&manifest, "package")?; + let package_name = required_string(package, "name")?; + let version = required_string(package, "version")?; + let binstall = extract_binstall_table(&manifest)?; + + if package_name != "whitaker" { + return Err(format!("root package name changed to `{package_name}`")); + } + if required_string(&binstall, "pkg-url")? != PKG_URL_TEMPLATE { + return Err("root pkg-url must match the shared template".to_owned()); + } + if required_string(&binstall, "bin-dir")? != BIN_DIR_TEMPLATE { + return Err("root bin-dir must match the shared template".to_owned()); + } + if required_string(&binstall, "pkg-fmt")? != DEFAULT_PKG_FMT { + return Err("root package default archive format changed".to_owned()); + } + + let overrides = required_table(&binstall, "overrides")?; + let windows = required_table(overrides, WINDOWS_OVERRIDE_TARGET)?; + if required_string(windows, "pkg-fmt")? != WINDOWS_PKG_FMT { + return Err("root Windows archive override changed".to_owned()); + } + if overrides.len() != 1 { + return Err(format!( + "expected one root override, found {}", + overrides.len() + )); + } + + for target in RELEASE_TARGETS { + let whitaker_url = expand_pkg_url_for(package_name, version, target); + let installer_url = expand_pkg_url_for("whitaker-installer", version, target); + if whitaker_url == installer_url { + return Err(format!( + "package URLs must remain distinct for release target {target}" + )); + } + } + + Ok(()) +} diff --git a/tests/behaviour_whitaker.rs b/tests/behaviour_whitaker.rs new file mode 100644 index 000000000..04d313d41 --- /dev/null +++ b/tests/behaviour_whitaker.rs @@ -0,0 +1,424 @@ +//! Behaviour-driven tests for the root `whitaker` command. + +use std::{ + cell::{Ref, RefCell}, + path::PathBuf, + process::{Command, Output}, +}; +#[cfg(all(unix, not(target_os = "macos")))] +use std::{error::Error, path::Path}; + +#[cfg(all(unix, not(target_os = "macos")))] +use cap_std::{ + ambient_authority, + fs_utf8::{Dir, Permissions, PermissionsExt}, +}; +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; +use tempfile::TempDir; +use whitaker_installer::builder::{library_extension, library_prefix}; +#[cfg(all(unix, not(target_os = "macos")))] +use whitaker_installer::{dirs::BaseDirs, installation_record::record_installation}; + +#[derive(Default)] +struct CliWorld { + output: RefCell>, + staging: RefCell>, + fake_cargo: RefCell>, +} + +impl CliWorld { + fn recorded_output(&self) -> Result, String> { + Ref::filter_map(self.output.borrow(), Option::as_ref) + .map_err(|_| "command output should be recorded".to_owned()) + } + + fn staging_directory(&self) -> Result, String> { + Ref::filter_map(self.staging.borrow(), Option::as_ref) + .map_err(|_| "install target should be recorded".to_owned()) + } +} + +#[whitaker_test_macros::allow_fixture_expansion_lints] +#[fixture] +fn world() -> CliWorld { CliWorld::default() } + +#[given("the whitaker binary is available")] +fn given_binary_available(world: &CliWorld) { let _ = world; } + +#[given("a Whitaker workspace checkout")] +fn given_workspace_checkout(world: &CliWorld) -> Result<(), String> { + let staging = TempDir::new() + .map_err(|error| format!("temporary install target should be created: {error}"))?; + world.staging.replace(Some(staging)); + Ok(()) +} + +#[cfg(unix)] +#[given("a fake cargo dylint command returning exit code 23")] +fn given_fake_cargo(world: &CliWorld) -> Result<(), String> { + use cap_std::{ + ambient_authority, + fs_utf8::{Dir, Permissions, PermissionsExt}, + }; + + let directory = TempDir::new() + .map_err(|error| format!("fake cargo directory should be created: {error}"))?; + let directory_handle = Dir::open_ambient_dir( + camino::Utf8Path::from_path(directory.path()) + .ok_or_else(|| "fake cargo directory path should be UTF-8".to_owned())?, + ambient_authority(), + ) + .map_err(|error| format!("fake cargo directory should be opened: {error}"))?; + directory_handle + .write("cargo", "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 23\n") + .map_err(|error| format!("fake cargo command should be written: {error}"))?; + directory_handle + .set_permissions("cargo", Permissions::from_mode(0o755)) + .map_err(|error| format!("fake cargo should be executable: {error}"))?; + world.fake_cargo.replace(Some(directory)); + Ok(()) +} + +#[given("a staging directory containing a staged suite library")] +fn given_staged_suite(world: &CliWorld) -> Result<(), String> { + use cap_std::{ambient_authority, fs_utf8::Dir}; + + let staging = TempDir::new() + .map_err(|error| format!("temporary staging directory should be created: {error}"))?; + let staging_dir = Dir::open_ambient_dir( + camino::Utf8Path::from_path(staging.path()) + .ok_or_else(|| "temporary staging path should be UTF-8".to_owned())?, + ambient_authority(), + ) + .map_err(|error| format!("temporary staging directory should be opened: {error}"))?; + staging_dir + .create_dir_all("nightly-test/release") + .map_err(|error| format!("staged release directory should be created: {error}"))?; + let library_name = format!( + "{}whitaker_suite@nightly-test{}", + library_prefix(), + library_extension() + ); + staging_dir + .write(format!("nightly-test/release/{library_name}"), []) + .map_err(|error| format!("staged suite marker should be written: {error}"))?; + world.staging.replace(Some(staging)); + Ok(()) +} + +#[when("I run whitaker with \"{arguments}\"")] +fn when_run_whitaker(world: &CliWorld, arguments: &str) -> Result<(), String> { + let parsed_arguments: Vec<&str> = arguments.split_ascii_whitespace().collect(); + let mut command = Command::new(env!("CARGO_BIN_EXE_whitaker")); + command.args(parsed_arguments); + if let Some(fake_cargo) = world.fake_cargo.borrow().as_ref() { + command.env("PATH", fake_cargo.path()); + } + if let Some(staging) = world.staging.borrow().as_ref() { + let target_dir = staging + .path() + .to_str() + .ok_or_else(|| "temporary path should be UTF-8".to_owned())?; + command.args(["--target-dir", target_dir]); + } + let output = command + .current_dir(workspace_root()?) + .output() + .map_err(|error| format!("whitaker process should start: {error}"))?; + world.output.replace(Some(output)); + Ok(()) +} + +#[then("the command succeeds")] +fn then_command_succeeds(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "command should succeed, stderr: {}", + String::from_utf8_lossy(&output.stderr) + )) + } +} + +#[then("the command fails")] +fn then_command_fails(world: &CliWorld) -> Result<(), String> { + if world.recorded_output()?.status.success() { + Err("command should fail".to_owned()) + } else { + Ok(()) + } +} + +#[then("the exit code is {expected:i32}")] +fn then_exit_code(world: &CliWorld, expected: i32) -> Result<(), String> { + let actual = world.recorded_output()?.status.code(); + if actual == Some(expected) { + Ok(()) + } else { + Err(format!( + "expected process exit code {expected}, found {actual:?}" + )) + } +} + +#[then("the output lists the subcommand \"{subcommand}\"")] +fn then_output_lists_subcommand(world: &CliWorld, subcommand: &str) -> Result<(), String> { + let output = world.recorded_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains(subcommand) { + Ok(()) + } else { + Err(format!("help output should list {subcommand}")) + } +} + +#[then("the output is valid JSON")] +fn then_output_is_json(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let _json_value = serde_json::from_str::(&stdout) + .map_err(|error| format!("ls --json stdout should contain valid JSON: {error}"))?; + Ok(()) +} + +#[then("stdout contains no diagnostic text")] +fn then_json_has_no_diagnostics(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.starts_with('{') { + Ok(()) + } else { + Err("diagnostics must not precede JSON stdout".to_owned()) + } +} + +#[then("no lint library is staged")] +fn then_no_lint_library_is_staged(world: &CliWorld) -> Result<(), String> { + use cap_std::{ambient_authority, fs_utf8::Dir}; + + let staging = world.staging_directory()?; + let staging_dir = Dir::open_ambient_dir( + camino::Utf8Path::from_path(staging.path()) + .ok_or_else(|| "temporary staging path should be UTF-8".to_owned())?, + ambient_authority(), + ) + .map_err(|error| format!("temporary staging directory should be opened: {error}"))?; + let entries = staging_dir + .read_dir(".") + .map_err(|error| format!("temporary staging directory should be readable: {error}"))?; + if entries.count() > 0 { + Err("dry-run should leave the staging directory empty".to_owned()) + } else { + Ok(()) + } +} + +#[then("stdout is empty")] +fn then_stdout_is_empty(world: &CliWorld) -> Result<(), String> { + if world.recorded_output()?.stdout.is_empty() { + Ok(()) + } else { + Err("install diagnostics belong on stderr".to_owned()) + } +} + +#[then("stderr contains install diagnostics")] +fn then_stderr_contains_install_diagnostics(world: &CliWorld) -> Result<(), String> { + if world.recorded_output()?.stderr.is_empty() { + Err("install progress and diagnostics should be written to stderr".to_owned()) + } else { + Ok(()) + } +} + +#[cfg(unix)] +#[then("the invocation reaches cargo dylint")] +fn then_invocation_reaches_cargo_dylint(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + if output.status.code() != Some(23) { + return Err("forwarded status should be preserved".to_owned()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.starts_with("dylint\n--all\n--\n-p\nwhitaker-common\n--all-targets\n") { + Ok(()) + } else { + Err(format!( + "cargo dylint should receive the original argument vector: {stdout:?}" + )) + } +} + +#[then("the output names the staged suite")] +fn then_output_names_staged_suite(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("whitaker_suite") { + Ok(()) + } else { + Err("staged suite should be listed".to_owned()) + } +} + +#[then("no wrapper-generation message is reported")] +fn then_no_wrapper_generation_message_is_reported(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("Wrapper scripts created") { + Err("dry-run should not report wrapper generation".to_owned()) + } else { + Ok(()) + } +} + +#[then("the error names both conflicting options")] +fn then_error_names_conflicts(world: &CliWorld) -> Result<(), String> { + let output = world.recorded_output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("--lint") { + return Err("error should mention --lint".to_owned()); + } + if !stderr.contains("--individual-lints") { + return Err("error should mention --individual-lints".to_owned()); + } + Ok(()) +} + +#[scenario(path = "tests/features/whitaker_cli.feature", index = 0)] +fn scenario_root_command_lists_subcommands(world: CliWorld) { let _ = world; } + +#[scenario(path = "tests/features/whitaker_cli.feature", index = 1)] +fn scenario_help_exits_successfully(world: CliWorld) { let _ = world; } + +#[scenario(path = "tests/features/whitaker_cli.feature", index = 2)] +fn scenario_lists_staged_lints_as_json(world: CliWorld) { let _ = world; } + +#[scenario(path = "tests/features/whitaker_cli.feature", index = 3)] +fn scenario_lists_staged_lints_as_text(world: CliWorld) { let _ = world; } + +#[scenario(path = "tests/features/whitaker_cli.feature", index = 4)] +fn scenario_dry_run_has_no_stdout_or_wrapper(world: CliWorld) { let _ = world; } + +#[scenario(path = "tests/features/whitaker_cli.feature", index = 5)] +fn scenario_conflicting_lint_flags_are_rejected(world: CliWorld) { let _ = world; } + +#[cfg(unix)] +#[scenario(path = "tests/features/whitaker_cli.feature", index = 6)] +fn scenario_legacy_lint_invocations_are_forwarded(world: CliWorld) { let _ = world; } + +fn workspace_root() -> Result { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(PathBuf::from) + .ok_or_else(|| "root package directory should have a parent".to_owned()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn forwarding_uses_all_recorded_custom_paths_outside_a_checkout() { + let temporary = TempDir::new().expect("temporary directory should be created"); + let fixture = recorded_path_fixture(&temporary).expect("recorded-path fixture should exist"); + + let output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .arg("--all") + .env("PATH", &fixture.fake_cargo) + .env_remove("DYLINT_LIBRARY_PATH") + .env("XDG_DATA_HOME", &fixture.data_home) + .env("HOME", temporary.path()) + .current_dir(&fixture.external_cwd) + .output() + .expect("root CLI should start outside a Whitaker checkout"); + + assert!(output.status.success()); + let path_list = String::from_utf8(output.stdout).expect("fake cargo output should be UTF-8"); + let forwarded_paths: Vec<_> = std::env::split_paths(path_list.trim()).collect(); + assert!(forwarded_paths.contains(&fixture.stable)); + assert!(forwarded_paths.contains(&fixture.nightly)); + assert!(!fixture.external_cwd.join("rust-toolchain.toml").exists()); +} + +#[cfg(all(unix, not(target_os = "macos")))] +struct TestBaseDirs { + data: PathBuf, +} + +#[cfg(all(unix, not(target_os = "macos")))] +impl BaseDirs for TestBaseDirs { + fn home(&self) -> Option { None } + + fn executables(&self) -> Option { None } + + fn whitaker_data(&self) -> Option { Some(self.data.clone()) } +} + +#[cfg(all(unix, not(target_os = "macos")))] +struct RecordedPathFixture { + data_home: PathBuf, + stable: PathBuf, + nightly: PathBuf, + external_cwd: PathBuf, + fake_cargo: PathBuf, +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn recorded_path_fixture(temporary: &TempDir) -> Result> { + let test_root = Dir::open_ambient_dir( + camino::Utf8Path::from_path(temporary.path()) + .ok_or_else(|| std::io::Error::other("temporary path should be UTF-8"))?, + ambient_authority(), + )?; + create_fixture_directories(&test_root)?; + + let fixture = RecordedPathFixture { + data_home: temporary.path().join("xdg-data"), + stable: temporary.path().join("custom-stable"), + nightly: temporary.path().join("custom-nightly"), + external_cwd: temporary.path().join("external-project"), + fake_cargo: temporary.path().join("fake-cargo"), + }; + record_fixture_toolchains(&fixture)?; + create_fake_cargo(&fixture.fake_cargo)?; + Ok(fixture) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn create_fixture_directories(root: &Dir) -> std::io::Result<()> { + for directory in [ + "xdg-data", + "custom-stable", + "custom-nightly", + "external-project", + "fake-cargo", + ] { + root.create_dir_all(directory)?; + } + Ok(()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn record_fixture_toolchains(fixture: &RecordedPathFixture) -> Result<(), Box> { + let dirs = TestBaseDirs { + data: fixture.data_home.join("whitaker"), + }; + for (toolchain, path) in [("stable", &fixture.stable), ("nightly", &fixture.nightly)] { + let staging_path = camino::Utf8Path::from_path(path) + .ok_or_else(|| std::io::Error::other("staging path should be UTF-8"))?; + record_installation(&dirs, toolchain, staging_path)?; + } + Ok(()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn create_fake_cargo(directory: &Path) -> Result<(), Box> { + let path = camino::Utf8Path::from_path(directory) + .ok_or_else(|| std::io::Error::other("fake cargo path should be UTF-8"))?; + let fake_cargo_dir = Dir::open_ambient_dir(path, ambient_authority())?; + fake_cargo_dir.write( + "cargo", + "#!/bin/sh\nprintf '%s\\n' \"$DYLINT_LIBRARY_PATH\"\n", + )?; + fake_cargo_dir.set_permissions("cargo", Permissions::from_mode(0o755))?; + Ok(()) +} diff --git a/tests/build_config.rs b/tests/build_config.rs deleted file mode 100644 index bd7724145..000000000 --- a/tests/build_config.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Build configuration guards for dynamic linking expectations. - -use std::{fs, path::Path}; - -use toml::Value; - -#[test] -fn cargo_config_prefers_dynamic_linking() { - let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".cargo/config.toml"); - let contents = fs::read_to_string(&config_path) - .unwrap_or_else(|err| panic!("failed to read {config_path:?}: {err}")); - let value: Value = toml::from_str(&contents).expect("cargo config should parse as TOML table"); - - let rustflags = value - .get("build") - .and_then(|table| table.get("rustflags")) - .and_then(Value::as_array) - .expect("build.rustflags should be an array"); - - // Expect exactly the pair ["-C", "prefer-dynamic"] to guard against regressions. - let flags: Vec<&str> = rustflags - .iter() - .map(|v| v.as_str().expect("rustflags entries should be strings")) - .collect(); - assert_eq!( - flags, - ["-C", "prefer-dynamic"], - "rustflags must prefer dynamic linkage" - ); -} diff --git a/tests/cli_list_snapshot.rs b/tests/cli_list_snapshot.rs new file mode 100644 index 000000000..93d217499 --- /dev/null +++ b/tests/cli_list_snapshot.rs @@ -0,0 +1,81 @@ +//! Snapshot guard for the current, intentionally unstable `whitaker ls` JSON. + +use std::process::Command; + +use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use whitaker_installer::builder::{library_extension, library_prefix}; + +#[test] +fn json_inventory_keeps_its_current_shape() { + let staging = TempDir::new().expect("temporary staging directory should be created"); + let staging_dir = Dir::open_ambient_dir( + camino::Utf8Path::from_path(staging.path()).expect("temporary path should be UTF-8"), + ambient_authority(), + ) + .expect("temporary staging directory should be opened"); + staging_dir + .create_dir_all("nightly-test/release") + .expect("release directory should be created"); + let library_name = format!( + "{}whitaker_suite@nightly-test{}", + library_prefix(), + library_extension() + ); + staging_dir + .write(format!("nightly-test/release/{library_name}"), []) + .expect("suite marker should be written"); + let target_dir = Utf8PathBuf::from_path_buf(staging.path().to_owned()) + .expect("temporary path should be UTF-8"); + + let output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .args(["ls", "--json", "--target-dir"]) + .arg(target_dir.as_str()) + .output() + .expect("whitaker ls process should start"); + assert!( + output.status.success(), + "ls should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let actual: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("ls --json should produce valid JSON"); + let first_toolchain = actual + .get("toolchains") + .and_then(serde_json::Value::as_array) + .and_then(|toolchains| toolchains.first()) + .and_then(|toolchain| toolchain.get("channel")); + assert_eq!( + first_toolchain, + Some(&serde_json::Value::String("nightly-test".to_owned())), + "snapshot fixture should contain the staged test toolchain", + ); + insta::assert_json_snapshot!(actual, @r###" + { + "toolchains": [ + { + "active": false, + "channel": "nightly-test", + "libraries": [ + { + "lints": [ + "bumpy_road_function", + "conditional_max_n_branches", + "function_attrs_follow_docs", + "module_max_lines", + "module_must_have_inner_docs", + "no_expect_outside_tests", + "test_must_not_have_example", + "no_std_fs_operations", + "no_unwrap_or_else_panic" + ], + "name": "whitaker_suite" + } + ] + } + ] + } + "###); +} diff --git a/tests/e2e_exit_codes.rs b/tests/e2e_exit_codes.rs new file mode 100644 index 000000000..dc2e0e29c --- /dev/null +++ b/tests/e2e_exit_codes.rs @@ -0,0 +1,121 @@ +//! End-to-end assertions for the root binary's process exit contract. + +use std::process::Command; + +#[test] +fn root_help_exits_successfully() { + let output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .arg("--help") + .output() + .expect("whitaker help process should start"); + assert_eq!( + output.status.code(), + Some(0), + "help should exit successfully" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("install"), "help should list install"); + assert!(stdout.contains("ls"), "help should list ls"); +} + +#[test] +fn root_help_subcommand_is_not_forwarded_to_dylint() { + let output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .arg("help") + .output() + .expect("whitaker help process should start"); + assert_eq!( + output.status.code(), + Some(0), + "the help subcommand should exit successfully" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Usage: whitaker"), + "unexpected help: {stdout}" + ); + assert!(output.stderr.is_empty(), "help should not report an error"); +} + +#[test] +fn install_usage_errors_use_clap_exit_code_two() { + let output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .args([ + "install", + "--lint", + "module_max_lines", + "--individual-lints", + ]) + .output() + .expect("whitaker install process should start"); + assert_eq!( + output.status.code(), + Some(2), + "usage errors should exit with code 2" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--lint"), "error should name --lint"); + assert!( + stderr.contains("--individual-lints"), + "error should name --individual-lints" + ); +} + +#[cfg(unix)] +#[test] +fn legacy_dylint_invocations_forward_arguments_and_exit_status() { + use cap_std::{ + ambient_authority, + fs_utf8::{Dir, Permissions, PermissionsExt}, + }; + use tempfile::TempDir; + + let bin_dir = TempDir::new().expect("fake cargo directory should be created"); + let bin_dir_handle = Dir::open_ambient_dir( + camino::Utf8Path::from_path(bin_dir.path()).expect("temporary path should be UTF-8"), + ambient_authority(), + ) + .expect("fake cargo directory should be opened"); + bin_dir_handle + .write("cargo", "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 23\n") + .expect("fake cargo command should be written"); + bin_dir_handle + .set_permissions("cargo", Permissions::from_mode(0o755)) + .expect("fake cargo should be executable"); + + let output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .args(["--all", "--", "-p", "whitaker-common", "--all-targets"]) + .env("PATH", bin_dir.path()) + .output() + .expect("whitaker forwarding process should start"); + assert_eq!( + output.status.code(), + Some(23), + "forwarded status should be preserved" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.starts_with("dylint\n--all\n--\n"), + "cargo dylint args should be preserved" + ); + assert!( + stdout.contains("whitaker-common"), + "cargo arguments should reach the fake process" + ); + + let version_output = Command::new(env!("CARGO_BIN_EXE_whitaker")) + .args(["--all", "--version"]) + .env("PATH", bin_dir.path()) + .output() + .expect("whitaker forwarding process should start"); + assert_eq!( + version_output.status.code(), + Some(23), + "Dylint's --version option should be forwarded after --all", + ); + let version_stdout = String::from_utf8_lossy(&version_output.stdout); + assert!( + version_stdout.starts_with("dylint\n--all\n--version\n"), + "cargo dylint should receive the later version option: {version_stdout:?}", + ); +} diff --git a/tests/features/whitaker_cli.feature b/tests/features/whitaker_cli.feature new file mode 100644 index 000000000..91eecc46a --- /dev/null +++ b/tests/features/whitaker_cli.feature @@ -0,0 +1,49 @@ +Feature: The root whitaker command-line interface + + Scenario: The root command lists its subcommands + Given the whitaker binary is available + When I run whitaker with "--help" + Then the command succeeds + And the output lists the subcommand "install" + And the output lists the subcommand "ls" + + Scenario: Requesting help exits successfully + Given the whitaker binary is available + When I run whitaker with "--help" + Then the exit code is 0 + + Scenario: Listing staged lints as JSON + Given a staging directory containing a staged suite library + When I run whitaker with "ls --json" + Then the command succeeds + And the output is valid JSON + And stdout contains no diagnostic text + + Scenario: Listing staged lints as text + Given a staging directory containing a staged suite library + When I run whitaker with "ls" + Then the command succeeds + And the output names the staged suite + + Scenario: A dry-run install reports configuration without writing stdout + Given a Whitaker workspace checkout + When I run whitaker with "install --dry-run" + Then the command succeeds + And no lint library is staged + And stdout is empty + And stderr contains install diagnostics + And no wrapper-generation message is reported + + Scenario: Conflicting lint selection flags are rejected + Given the whitaker binary is available + When I run whitaker with "install --lint module_max_lines --individual-lints" + Then the command fails + And the exit code is 2 + And the error names both conflicting options + + Scenario: Legacy lint invocations are forwarded to cargo dylint + Given the whitaker binary is available + And a fake cargo dylint command returning exit code 23 + When I run whitaker with "--all -- -p whitaker-common --all-targets" + Then the invocation reaches cargo dylint + And the exit code is 23 diff --git a/tests/ortho_config_compat.rs b/tests/ortho_config_compat.rs new file mode 100644 index 000000000..e2e9363a8 --- /dev/null +++ b/tests/ortho_config_compat.rs @@ -0,0 +1,21 @@ +//! Compile-time compatibility check for future root CLI configuration. + +use clap::Parser; +use ortho_config::OrthoConfig; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Parser, Deserialize, Serialize, OrthoConfig)] +#[command(name = "whitaker")] +#[ortho_config(prefix = "WHITAKER_")] +struct ConfigCompatibleCli { + /// Enables verbose CLI output. + #[arg(long)] + verbose: bool, +} + +#[test] +fn ortho_config_derives_alongside_clap_parser() { + let parsed = ConfigCompatibleCli::try_parse_from(["whitaker", "--verbose"]) + .expect("the clap derive should parse the test argument"); + assert!(parsed.verbose, "the clap-derived flag should be retained"); +} diff --git a/tests/property_arg_parity.rs b/tests/property_arg_parity.rs new file mode 100644 index 000000000..88cf481bd --- /dev/null +++ b/tests/property_arg_parity.rs @@ -0,0 +1,253 @@ +//! Differential property tests for root and legacy installer argument parity. + +use std::{ + collections::{BTreeMap, BTreeSet}, + ffi::OsString, +}; + +use clap::Parser as _; +use ortho_config::NoOpLocalizer; +use proptest::prelude::*; +use whitaker::{ + cli::{Cli, Command}, + ports::InstallRequest, +}; +use whitaker_installer::cli::{Cli as InstallerCli, Command as InstallerCommand, InstallArgs}; + +const OPTION_PAIR_COUNT: usize = 91; + +#[derive(Clone, Copy)] +enum OptionKind { + TargetDir, + Lint, + IndividualLints, + Experimental, + Jobs, + Toolchain, + Cranelift, + DryRun, + Verbosity, + Quiet, + SkipDeps, + SkipWrapper, + NoUpdate, + BuildOnly, +} + +impl OptionKind { + const ALL: [Self; 14] = [ + Self::TargetDir, + Self::Lint, + Self::IndividualLints, + Self::Experimental, + Self::Jobs, + Self::Toolchain, + Self::Cranelift, + Self::DryRun, + Self::Verbosity, + Self::Quiet, + Self::SkipDeps, + Self::SkipWrapper, + Self::NoUpdate, + Self::BuildOnly, + ]; + + const fn bit(self) -> u16 { + match self { + Self::TargetDir => 1 << 0, + Self::Lint => 1 << 1, + Self::IndividualLints => 1 << 2, + Self::Experimental => 1 << 3, + Self::Jobs => 1 << 4, + Self::Toolchain => 1 << 5, + Self::Cranelift => 1 << 6, + Self::DryRun => 1 << 7, + Self::Verbosity => 1 << 8, + Self::Quiet => 1 << 9, + Self::SkipDeps => 1 << 10, + Self::SkipWrapper => 1 << 11, + Self::NoUpdate => 1 << 12, + Self::BuildOnly => 1 << 13, + } + } + + const fn name(self) -> &'static str { + match self { + Self::TargetDir => "target-dir", + Self::Lint => "lint", + Self::IndividualLints => "individual-lints", + Self::Experimental => "experimental", + Self::Jobs => "jobs", + Self::Toolchain => "toolchain", + Self::Cranelift => "cranelift", + Self::DryRun => "dry-run", + Self::Verbosity => "verbosity", + Self::Quiet => "quiet", + Self::SkipDeps => "skip-deps", + Self::SkipWrapper => "skip-wrapper", + Self::NoUpdate => "no-update", + Self::BuildOnly => "build-only", + } + } + + fn append(self, arguments: &mut Vec) { + match self { + Self::TargetDir => { + arguments.extend(["-t", "/tmp/Whitaker install λ"].map(OsString::from)); + } + Self::Lint => arguments.extend(["-l", "module_max_lines"].map(OsString::from)), + Self::IndividualLints => arguments.push(OsString::from("--individual-lints")), + Self::Experimental => arguments.push(OsString::from("--experimental")), + Self::Jobs => arguments.extend(["-j", "2"].map(OsString::from)), + Self::Toolchain => { + arguments.extend(["--toolchain", "nightly-test"].map(OsString::from)); + } + Self::Cranelift => arguments.push(OsString::from("--cranelift")), + Self::DryRun => arguments.push(OsString::from("--dry-run")), + Self::Verbosity => arguments.push(OsString::from("--verbosity")), + Self::Quiet => arguments.push(OsString::from("-q")), + Self::SkipDeps => arguments.push(OsString::from("--skip-deps")), + Self::SkipWrapper => arguments.push(OsString::from("--skip-wrapper")), + Self::NoUpdate => arguments.push(OsString::from("--no-update")), + Self::BuildOnly => arguments.push(OsString::from("--build-only")), + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(1))] + + #[test] + fn root_install_matches_legacy_install_for_4096_generated_argument_sets( + generated_masks in prop::collection::vec(0_u16..(1 << 14), 4096 - 105) + ) { + let mut seen_options = BTreeSet::new(); + let mut seen_pairs = BTreeSet::new(); + let mut option_counts = BTreeMap::new(); + let mut masks = Vec::with_capacity(4096); + for kind in OptionKind::ALL { + masks.push(kind.bit()); + } + for (left_index, left) in OptionKind::ALL.iter().enumerate() { + for right in OptionKind::ALL.iter().skip(left_index + 1) { + masks.push(left.bit() | right.bit()); + } + } + masks.extend(generated_masks); + + for (case_index, mask) in masks.iter().copied().enumerate() { + let selected: Vec = OptionKind::ALL + .iter() + .copied() + .filter(|kind| mask & kind.bit() != 0) + .collect(); + for kind in &selected { + seen_options.insert(kind.name()); + *option_counts.entry(kind.name()).or_insert(0_usize) += 1; + } + for (left_index, left) in selected.iter().enumerate() { + for right in selected.iter().skip(left_index + 1) { + seen_pairs.insert((left.name(), right.name())); + } + } + + let mut install_arguments = Vec::new(); + for kind in selected { + kind.append(&mut install_arguments); + } + if mask & OptionKind::Lint.bit() != 0 { + install_arguments.extend(["-l", "no_expect_outside_tests"].map(OsString::from)); + } + if mask & OptionKind::Verbosity.bit() != 0 && case_index & 1 == 0 { + install_arguments.push(OsString::from("-v")); + } + + let root_result = parse_root(&install_arguments); + let legacy_result = parse_legacy(&install_arguments, case_index & 1 == 0); + match (root_result, legacy_result) { + (Ok(root), Ok(legacy)) => { + prop_assert!(matches!(root.command, Command::Install(_))); + let Command::Install(parsed) = &root.command else { + prop_assert!(false, "root install parser returned a different command"); + continue; + }; + let root_arguments = root.install_arguments(parsed); + let legacy_arguments = legacy.install_args(); + prop_assert_eq!( + root_arguments.clone(), + legacy_arguments.clone(), + "argument mapping drifted for case {}: {:?}", + case_index, + install_arguments, + ); + let request = InstallRequest { arguments: root_arguments }; + let expected_request = InstallRequest { + arguments: legacy_arguments.clone(), + }; + prop_assert_eq!(request, expected_request); + } + (Err(root), Err(legacy)) => { + prop_assert_eq!( + root.exit_code(), + legacy.exit_code(), + "rejection exit code drifted for case {}: {:?}", + case_index, + install_arguments, + ); + } + (root, legacy) => { + prop_assert!(false, + "parser acceptance differed for case {case_index}: root={root:?}, legacy={legacy:?}, args={install_arguments:?}"); + } + } + } + + prop_assert_eq!(seen_options.len(), OptionKind::ALL.len(), "not every installer option appeared"); + prop_assert_eq!(seen_pairs.len(), OPTION_PAIR_COUNT, "not every option pair co-occurred"); + for kind in OptionKind::ALL { + prop_assert!( + option_counts.get(kind.name()).copied().unwrap_or_default() >= 205, + "option --{} appeared in fewer than 5% of the 4096 cases", + kind.name(), + ); + } + } +} + +fn parse_root(arguments: &[OsString]) -> Result { + let mut raw = vec![OsString::from("whitaker"), OsString::from("install")]; + raw.extend(arguments.iter().cloned()); + Cli::try_parse_localized_from(raw, &NoOpLocalizer::new()) +} + +fn parse_legacy( + arguments: &[OsString], + explicit_subcommand: bool, +) -> Result { + let mut raw = vec![OsString::from("whitaker-installer")]; + if explicit_subcommand { + raw.push(OsString::from("install")); + } + raw.extend(arguments.iter().cloned()); + InstallerCli::try_parse_from(raw) +} + +#[test] +fn root_install_type_is_the_installer_install_type() { + let parsed = parse_root(&[OsString::from("--dry-run")]).expect("root install should parse"); + let Command::Install(arguments) = parsed.command else { + panic!("root command should parse install"); + }; + assert!( + arguments.execution.dry_run, + "root install options should use installer args" + ); + let _: InstallArgs = arguments; +} + +#[test] +fn explicit_installer_install_subcommand_is_supported() { + let parsed = parse_legacy(&[OsString::from("--dry-run")], true) + .expect("legacy explicit install should parse"); + assert!(matches!(parsed.command, Some(InstallerCommand::Install(_)))); +} diff --git a/typos.local.toml b/typos.local.toml index 876a0bfb3..3643d2c23 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -62,6 +62,8 @@ ignore = [ "(?m)^error: Tha gnìomh std::fs `std::fs::read` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", "(?m)^module_max_lines = Tha mòideal \\{ \\$module \\} a’ leudachadh gu \\{ \\$lines \\} loidhnichean agus a’ briseadh an crìoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnìomh std::fs `\\{ \\$operation \\}` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", + # Preserve the external Cargo environment-variable spelling in vendored code. + "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", ] [files] diff --git a/typos.toml b/typos.toml index 5a0c01a2d..e2f772956 100644 --- a/typos.toml +++ b/typos.toml @@ -34,6 +34,7 @@ extend-exclude = [ [default] locale = "en-gb" extend-ignore-re = [ + "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", diff --git a/vendor/dylint_testing/Cargo.toml b/vendor/dylint_testing/Cargo.toml new file mode 100644 index 000000000..5f93c823e --- /dev/null +++ b/vendor/dylint_testing/Cargo.toml @@ -0,0 +1,91 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "dylint_testing" +version = "6.0.4" +authors = ["Samuel E. Moelius III "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Utilities for testing Dylint libraries" +readme = "README.md" +license = "MIT OR Apache-2.0" +repository = "https://github.com/trailofbits/dylint" +resolver = "2" + +[features] +default = [] +deny_warnings = [] + +[lib] +name = "dylint_testing" +path = "src/lib.rs" + +[dependencies.anyhow] +version = "1.0" + +[dependencies.cargo_metadata] +version = "0.23" + +[dependencies.compiletest_rs] +version = "0.11" + +[dependencies.dylint] +version = "=6.0.4" + +[dependencies.dylint_internal] +version = "=6.0.4" + +[dependencies.env_logger] +version = "0.11" + +[dependencies.once_cell] +version = "1.21" + +[dependencies.regex] +version = "1.13" + +[dependencies.serde_json] +version = "1.0" + +[dependencies.tempfile] +version = "3.27" + +[lints.clippy] +missing_errors_doc = "allow" +missing_panics_doc = "allow" +option_if_let_else = "allow" +significant_drop_tightening = "allow" +struct_field_names = "allow" + +[lints.clippy.nursery] +level = "warn" +priority = -1 + +[lints.clippy.pedantic] +level = "warn" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "deny" +priority = 0 +check-cfg = [ + "cfg(coverage)", + "cfg(dylint_lib, values(any()))", + "cfg(nightly)", + "cfg(__cargo_cli)", + "cfg(__library_packages)", +] diff --git a/vendor/dylint_testing/LICENSE-MIT b/vendor/dylint_testing/LICENSE-MIT new file mode 100644 index 000000000..4652dc3bb --- /dev/null +++ b/vendor/dylint_testing/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2022 Trail of Bits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/dylint_testing/README.md b/vendor/dylint_testing/README.md new file mode 100644 index 000000000..83f7e1e47 --- /dev/null +++ b/vendor/dylint_testing/README.md @@ -0,0 +1,14 @@ +# Local Dylint testing compatibility patch + +This directory contains `dylint_testing` 6.0.4 from crates.io, with one small +compatibility change for managed Rust compiler wrappers. + +Upstream issue [1696](https://github.com/trailofbits/dylint/issues/1696) +records that the UI harness assumes verbose Cargo output begins with `rustc`. +Managed wrappers put the real compiler later in the command. This patch locates +that compiler and keeps its arguments, while allowing the wrapper to continue +running. + +Remove this patch when an upstream release handles wrapped compiler commands. +The upstream licence is MIT OR Apache-2.0; this vendored copy uses the MIT +option in [LICENSE-MIT](LICENSE-MIT). diff --git a/vendor/dylint_testing/src/lib.rs b/vendor/dylint_testing/src/lib.rs new file mode 100644 index 000000000..6210a592c --- /dev/null +++ b/vendor/dylint_testing/src/lib.rs @@ -0,0 +1,509 @@ +//! This crate provides convenient access to the [`compiletest_rs`] package for testing [Dylint] +//! libraries. +//! +//! **Note: If your test has dependencies, you must use `ui_test_example` or `ui_test_examples`.** +//! See the [`question_mark_in_expression`] example in this repository. +//! +//! This crate provides the following three functions: +//! +//! - [`ui_test`] - test a library on all source files in a directory +//! - [`ui_test_example`] - test a library on one example target +//! - [`ui_test_examples`] - test a library on all example targets +//! +//! For most situations, you can add the following to your library's `lib.rs` file: +//! +//! ```rust,ignore +//! #[test] +//! fn ui() { +//! dylint_testing::ui_test(env!("CARGO_PKG_NAME"), "ui"); +//! } +//! ``` +//! +//! And include one or more `.rs` and `.stderr` files in a `ui` directory alongside your library's +//! `src` directory. See the [examples] in this repository. +//! +//! # Test builder +//! +//! In addition to the above three functions, [`ui::Test`] is a test "builder." Currently, the main +//! advantage of using `Test` over the above functions is that `Test` allows flags to be passed to +//! `rustc`. For an example of its use, see [`non_thread_safe_call_in_test`] in this repository. +//! +//! `Test` has three constructors, which correspond to the above three functions as follows: +//! +//! - [`ui::Test::src_base`] <-> [`ui_test`] +//! - [`ui::Test::example`] <-> [`ui_test_example`] +//! - [`ui::Test::examples`] <-> [`ui_test_examples`] +//! +//! In each case, the constructor's arguments are exactly those of the corresponding function. +//! +//! A `Test` instance has the following methods: +//! +//! - `dylint_toml` - set the `dylint.toml` file's contents (for testing [configurable libraries]) +//! - `rustc_flags` - pass flags to the compiler when running the test +//! - `run` - run the test +//! +//! # Updating `.stderr` files +//! +//! If the standard error that results from running your `.rs` file differs from the contents of +//! your `.stderr` file, `compiletest_rs` will produce a report like the following: +//! +//! ```text +//! diff of stderr: +//! +//! error: calling `std::env::set_var` in a test could affect the outcome of other tests +//! --> $DIR/main.rs:8:5 +//! | +//! LL | std::env::set_var("KEY", "VALUE"); +//! | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +//! | +//! = note: `-D non-thread-safe-call-in-test` implied by `-D warnings` +//! +//! -error: aborting due to previous error +//! +error: calling `std::env::set_var` in a test could affect the outcome of other tests +//! + --> $DIR/main.rs:23:9 +//! + | +//! +LL | std::env::set_var("KEY", "VALUE"); +//! + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +//! + +//! +error: aborting due to 2 previous errors +//! +//! +//! +//! The actual stderr differed from the expected stderr. +//! Actual stderr saved to ... +//! ``` +//! +//! The meaning of each line is as follows: +//! +//! - A line beginning with a plus (`+`) is in the actual standard error, but not in your `.stderr` +//! file. +//! - A line beginning with a minus (`-`) is in your `.stderr` file, but not in the actual standard +//! error. +//! - A line beginning with a space (` `) is in both the actual standard error and your `.stderr` +//! file, and is provided for context. +//! - All other lines (e.g., `diff of stderr:`) contain `compiletest_rs` messages. +//! +//! **Note:** In the actual standard error, a blank line usually follows the `error: aborting due to +//! N previous errors` line. So a correct `.stderr` file will typically contain one blank line at +//! the end. +//! +//! In general, it is not too hard to update a `.stderr` file by hand. However, the `compiletest_rs` +//! report should contain a line of the form `Actual stderr saved to PATH`. Copying `PATH` to your +//! `.stderr` file should update it completely. +//! +//! Additional documentation on `compiletest_rs` can be found in [its repository]. +//! +//! [Dylint]: https://github.com/trailofbits/dylint/tree/master +//! [`compiletest_rs`]: https://github.com/Manishearth/compiletest-rs +//! [`non_thread_safe_call_in_test`]: https://github.com/trailofbits/dylint/tree/master/examples/general/non_thread_safe_call_in_test/src/lib.rs +//! [`question_mark_in_expression`]: https://github.com/trailofbits/dylint/tree/master/examples/restriction/question_mark_in_expression/Cargo.toml +//! [`ui::Test::example`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html#method.example +//! [`ui::Test::examples`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html#method.examples +//! [`ui::Test::src_base`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html#method.src_base +//! [`ui::Test`]: https://docs.rs/dylint_testing/latest/dylint_testing/ui/struct.Test.html +//! [`ui_test_example`]: https://docs.rs/dylint_testing/latest/dylint_testing/fn.ui_test_example.html +//! [`ui_test_examples`]: https://docs.rs/dylint_testing/latest/dylint_testing/fn.ui_test_examples.html +//! [`ui_test`]: https://docs.rs/dylint_testing/latest/dylint_testing/fn.ui_test.html +//! [configurable libraries]: https://github.com/trailofbits/dylint/tree/master#configurable-libraries +//! [docs.rs documentation]: https://docs.rs/dylint_testing/latest/dylint_testing/ +//! [examples]: https://github.com/trailofbits/dylint/tree/master/examples +//! [its repository]: https://github.com/Manishearth/compiletest-rs + +use std::{ + env::{consts, remove_var, set_var, var_os}, + ffi::{OsStr, OsString}, + fs::{copy, read_dir, remove_file}, + io::BufRead, + path::{Path, PathBuf}, + sync::{LazyLock, Mutex}, +}; + +use anyhow::{Context, Result, anyhow, ensure}; +use cargo_metadata::{Metadata, Package, Target, TargetKind}; +use compiletest_rs as compiletest; +use dylint_internal::{CommandExt, env, library_filename, rustup::is_rustc}; +use once_cell::sync::OnceCell; +use regex::Regex; + +pub mod ui; + +static DRIVER: OnceCell> = OnceCell::new(); +static LINKING_FLAGS: OnceCell> = OnceCell::new(); + +/// Test a library on all source files in a directory. +/// +/// - `name` is the name of a Dylint library to be tested. (Often, this is the same as the package +/// name.) +/// - `src_base` is a directory containing: +/// - source files on which to test the library (`.rs` files), and +/// - the output those files should produce (`.stderr` files). +pub fn ui_test(name: &str, src_base: impl AsRef) { ui::Test::src_base(name, src_base).run(); } + +/// Test a library on one example target. +/// +/// - `name` is the name of a Dylint library to be tested. +/// - `example` is an example target on which to test the library. +pub fn ui_test_example(name: &str, example: &str) { ui::Test::example(name, example).run(); } + +/// Test a library on all example targets. +/// +/// - `name` is the name of a Dylint library to be tested. +pub fn ui_test_examples(name: &str) { ui::Test::examples(name).run(); } + +fn initialize(name: &str) -> &Result { + DRIVER.get_or_init(|| { + let _ = env_logger::try_init(); + + // smoelius: Try to order failures by how informative they are: failure to build the + // library, failure to find the library, failure to build/find the driver. + + dylint_internal::cargo::build(&format!("library `{name}`")) + .build() + .success()?; + + // smoelius: `DYLINT_LIBRARY_PATH` must be set before `dylint_libs` is called. + // smoelius: This was true when `dylint_libs` called `name_toolchain_map`, but that is + // no longer the case. I am leaving the comment here for now in case removal + // of the `name_toolchain_map` call causes a regression. + let metadata = dylint_internal::cargo::current_metadata().unwrap(); + let dylint_library_path = metadata.target_directory.join("debug"); + unsafe { + set_var(env::DYLINT_LIBRARY_PATH, dylint_library_path); + } + + let dylint_libs = dylint_libs(name)?; + let driver = dylint::driver_builder::get( + &dylint::opts::Dylint::default(), + env!("RUSTUP_TOOLCHAIN"), + )?; + + unsafe { + set_var(env::CLIPPY_DISABLE_DOCS_LINKS, "true"); + set_var(env::DYLINT_LIBS, dylint_libs); + } + + Ok(driver) + }) +} + +#[doc(hidden)] +pub fn dylint_libs(name: &str) -> Result { + let metadata = dylint_internal::cargo::current_metadata().unwrap(); + let rustup_toolchain = env::var(env::RUSTUP_TOOLCHAIN)?; + let filename = library_filename(name, &rustup_toolchain); + let path = metadata.target_directory.join("debug").join(filename); + let paths = vec![path]; + serde_json::to_string(&paths).map_err(Into::into) +} + +fn example_target(package: &Package, example: &str) -> Result { + package + .targets + .iter() + .find(|target| target.kind == [TargetKind::Example] && target.name == example) + .cloned() + .ok_or_else(|| anyhow!("Could not find example `{example}`")) +} + +#[allow(clippy::unnecessary_wraps)] +fn example_targets(package: &Package) -> Result> { + Ok(package + .targets + .iter() + .filter(|target| target.kind == [TargetKind::Example]) + .cloned() + .collect()) +} + +fn run_example_test( + driver: &Path, + metadata: &Metadata, + package: &Package, + target: &Target, + config: &ui::Config, +) -> Result<()> { + let linking_flags = linking_flags(metadata, package, target)?; + let file_name = target + .src_path + .file_name() + .ok_or_else(|| anyhow!("Could not get file name"))?; + + let tempdir = tempfile::tempdir().with_context(|| "`tempdir` failed")?; + let src_base = tempdir.path(); + let to = src_base.join(file_name); + + copy(&target.src_path, &to).with_context(|| { + format!( + "Could not copy `{}` to `{}`", + target.src_path, + to.to_string_lossy() + ) + })?; + for extension in ["fixed", "stderr", "stdout"] { + copy_with_extension(&target.src_path, &to, extension) + .map(|_| ()) + .unwrap_or_default(); + } + + let mut config = config.clone(); + config.rustc_flags.extend(linking_flags.iter().cloned()); + + run_tests(driver, src_base, &config); + + Ok(()) +} + +fn linking_flags( + metadata: &Metadata, + package: &Package, + target: &Target, +) -> Result<&'static [String]> { + LINKING_FLAGS + .get_or_try_init(|| { + let rustc_flags = rustc_flags(metadata, package, target)?; + + let mut linking_flags = Vec::new(); + + let mut iter = rustc_flags.into_iter(); + while let Some(flag) = iter.next() { + if flag.starts_with("--edition=") { + linking_flags.push(flag); + } else if flag == "--extern" || flag == "-L" { + let arg = next(&flag, &mut iter)?; + linking_flags.extend([flag, arg.trim_matches('\'').to_owned()]); + } + } + + Ok(linking_flags) + }) + .map(Vec::as_slice) +} + +// smoelius: We need to recover the `rustc` flags used to build a target. I can see four options: +// +// * Use `cargo build --build-plan` +// - Pros: Easily parsable JSON output +// - Cons: Unstable and likely to be removed: https://github.com/rust-lang/cargo/issues/7614 +// * Parse the output of `cargo build --verbose` +// - Pros: ? +// - Cons: Not as easily parsable, requires synchronization (see below) +// * Use a custom executor like Siderophile does: https://github.com/trailofbits/siderophile/blob/26c067306f6c2f66d9530dacef6b17dbf59cdf8c/src/trawl_source/mod.rs#L399 +// - Pros: Ground truth +// - Cons: Seems a bit of a heavy lift (Note: I think Siderophile's approach was inspired by +// `cargo-geiger`.) +// * Set `RUSTC_WORKSPACE_WRAPPER` to something that logs `rustc` invocations +// - Pros: Ground truth +// - Cons: Requires a separate executable/script, portability could be an issue +// +// I am going with the second option for now, because it seems to be the least of all evils. This +// decision may need to be revisited. + +static RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*Running\s*`(.*)`$").unwrap()); + +fn rustc_flags(metadata: &Metadata, package: &Package, target: &Target) -> Result> { + // smoelius: The following comments are old and retained for posterity. The linking flags are + // now initialized using a `OnceCell`, which makes the mutex unnecessary. + // smoelius: Force rebuilding of the example by removing it. This is kind of messy. The + // example is a shared resource that may be needed by multiple tests. For now, I lock a mutex + // while the example is removed and put back. + // smoelius: Should we use a temporary target directory here? + let output = { + remove_example(metadata, package, target)?; + + // smoelius: Because of lazy initialization, `cargo build` is run only once. Seeing + // "Building example `target`" for one example but not for others is confusing. So instead + // say "Building `package` examples". + dylint_internal::cargo::build(&format!("`{}` examples", package.name)) + .build() + .env_remove(env::CARGO_TERM_COLOR) + .args([ + "--manifest-path", + package.manifest_path.as_ref(), + "--example", + &target.name, + "--verbose", + ]) + .logged_output(true)? + }; + + let matches = output + .stderr + .lines() + .map(|line| { + let line = + line.with_context(|| format!("Could not read from `{}`", package.manifest_path))?; + Ok((*RE).captures(&line).and_then(|captures| { + let args = captures[1] + .split(' ') + .map(ToOwned::to_owned) + .collect::>(); + let rustc_args = compiler_arguments(&args)?; + if rustc_args + .windows(2) + .any(|window| window == ["--crate-name", &snake_case(&target.name)]) + { + Some(rustc_args.to_vec()) + } else { + None + } + })) + }) + .collect::>>>>()?; + + let mut matches = matches.into_iter().flatten().collect::>>(); + ensure!( + matches.len() <= 1, + "Found multiple `rustc` invocations for `{}`", + target.name + ); + matches + .pop() + .ok_or_else(|| anyhow!("Found no `rustc` invocations for `{}`", target.name)) +} + +/// Returns the compiler arguments after any configured rustc wrappers. +fn compiler_arguments(args: &[String]) -> Option<&[String]> { + args.iter() + .position(|argument| is_rustc(argument)) + .map(|index| &args[index..]) +} + +#[cfg(test)] +mod compiler_arguments_tests { + use super::compiler_arguments; + + #[test] + fn finds_rustc_after_managed_wrapper() { + let args = vec![ + "/home/user/.local/bin/build-limits-rustc".to_owned(), + "/toolchain/bin/rustc".to_owned(), + "--crate-name".to_owned(), + "sample".to_owned(), + ]; + + assert_eq!(compiler_arguments(&args), Some(&args[1..])); + } + + #[test] + fn retains_bare_rustc_invocation() { + let args = vec![ + "/toolchain/bin/rustc".to_owned(), + "--crate-name".to_owned(), + "sample".to_owned(), + ]; + + assert_eq!(compiler_arguments(&args), Some(&args[..])); + } +} + +fn remove_example(metadata: &Metadata, _package: &Package, target: &Target) -> Result<()> { + let examples = metadata.target_directory.join("debug/examples"); + for entry in + read_dir(&examples).with_context(|| format!("`read_dir` failed for `{examples}`"))? + { + let entry = entry.with_context(|| format!("`read_dir` failed for `{examples}`"))?; + let path = entry.path(); + + let file_name = entry.file_name(); + let s = file_name.to_string_lossy(); + let target_name = snake_case(&target.name); + if s == target_name.clone() + consts::EXE_SUFFIX + || s.starts_with(&(target_name.clone() + "-")) + { + remove_file(&path).with_context(|| { + format!("`remove_file` failed for `{}`", path.to_string_lossy()) + })?; + } + } + + Ok(()) +} + +fn next(flag: &str, iter: &mut I) -> Result +where + I: Iterator, +{ + iter.next() + .ok_or_else(|| anyhow!("Missing argument for `{flag}`")) +} + +fn copy_with_extension, Q: AsRef>( + from: P, + to: Q, + extension: &str, +) -> Result { + let from = from.as_ref().with_extension(extension); + let to = to.as_ref().with_extension(extension); + copy(from, to).map_err(Into::into) +} + +static MUTEX: Mutex<()> = Mutex::new(()); + +fn run_tests(driver: &Path, src_base: &Path, config: &ui::Config) { + let _lock = MUTEX.lock().unwrap(); + + // smoelius: There doesn't seem to be a way to set environment variables using `compiletest`'s + // [`Config`](https://docs.rs/compiletest_rs/0.7.1/compiletest_rs/common/struct.Config.html) + // struct. For comparison, where Clippy uses `compiletest`, it sets environment variables + // directly (see: https://github.com/rust-lang/rust-clippy/blob/master/tests/compile-test.rs). + // + // Of course, even if `compiletest` had such support, it would need to be incorporated into + // `dylint_testing`. + + let _var = config + .dylint_toml + .as_ref() + .map(|value| VarGuard::set(env::DYLINT_TOML, value)); + + let config = compiletest::Config { + mode: compiletest::common::Mode::Ui, + rustc_path: driver.to_path_buf(), + src_base: src_base.to_path_buf(), + target_rustcflags: Some( + config.rustc_flags.clone().join(" ") + + " --emit=metadata" + + if cfg!(feature = "deny_warnings") { + " -Dwarnings" + } else { + "" + } + + " -Zui-testing", + ), + ..compiletest::Config::default() + }; + + compiletest::run_tests(&config); +} + +// smoelius: `VarGuard` was copied from: +// https://github.com/rust-lang/rust-clippy/blob/9cc8da222b3893bc13bc13c8827e93f8ea246854/tests/compile-test.rs +// smoelius: Clippy dropped `VarGuard` when it switched to `ui_test`: +// https://github.com/rust-lang/rust-clippy/commit/77d10ac63dae6ef0a691d9acd63d65de9b9bf88e + +/// Restores an env var on drop +#[must_use] +struct VarGuard { + key: &'static str, + value: Option, +} + +impl VarGuard { + fn set(key: &'static str, val: impl AsRef) -> Self { + let value = var_os(key); + unsafe { + set_var(key, val); + } + Self { key, value } + } +} + +impl Drop for VarGuard { + fn drop(&mut self) { + match self.value.as_deref() { + None => unsafe { remove_var(self.key) }, + Some(value) => unsafe { set_var(self.key, value) }, + } + } +} + +fn snake_case(name: &str) -> String { name.replace('-', "_") } diff --git a/vendor/dylint_testing/src/ui.rs b/vendor/dylint_testing/src/ui.rs new file mode 100644 index 000000000..d45747b06 --- /dev/null +++ b/vendor/dylint_testing/src/ui.rs @@ -0,0 +1,117 @@ +use std::{ + env::current_dir, + path::{Path, PathBuf}, +}; + +use crate::{example_target, example_targets, initialize, run_example_test, run_tests}; + +enum Target { + SrcBase(PathBuf), + Example(String), + Examples, +} + +#[derive(Clone, Default)] +pub(super) struct Config { + pub(super) rustc_flags: Vec, + pub(super) dylint_toml: Option, +} + +/// Test builder +pub struct Test { + name: String, + target: Target, + config: Config, +} + +impl Test { + /// Test a library on all source files in a directory (similar to [`ui_test`]). + /// + /// [`ui_test`]: crate::ui_test + #[must_use] + pub fn src_base(name: &str, src_base: impl AsRef) -> Self { + Self::new(name, Target::SrcBase(src_base.as_ref().to_owned())) + } + + /// Test a library on one example target (similar to [`ui_test_example`]). + /// + /// [`ui_test_example`]: crate::ui_test_example + #[must_use] + pub fn example(name: &str, example: &str) -> Self { + Self::new(name, Target::Example(example.to_owned())) + } + + /// Test a library on all example targets (similar to [`ui_test_examples`]). + /// + /// [`ui_test_examples`]: crate::ui_test_examples + #[must_use] + pub fn examples(name: &str) -> Self { Self::new(name, Target::Examples) } + + /// Pass flags to the compiler when running the test. + pub fn rustc_flags( + &mut self, + rustc_flags: impl IntoIterator>, + ) -> &mut Self { + self.config + .rustc_flags + .extend(rustc_flags.into_iter().map(|s| s.as_ref().to_owned())); + self + } + + /// Set the `dylint.toml` file's contents (for testing configurable libraries). + pub fn dylint_toml(&mut self, dylint_toml: impl AsRef) -> &mut Self { + self.config.dylint_toml = Some(dylint_toml.as_ref().to_owned()); + self + } + + /// Run the test. + #[allow(clippy::needless_pass_by_ref_mut)] + pub fn run(&mut self) { self.run_immutable(); } + + fn new(name: &str, target: Target) -> Self { + Self { + name: name.to_owned(), + target, + config: Config::default(), + } + } + + fn run_immutable(&self) { + let driver = initialize(&self.name).as_ref().unwrap(); + + match &self.target { + Target::SrcBase(src_base) => { + run_tests(driver, src_base, &self.config); + } + Target::Example(example) => { + let metadata = dylint_internal::cargo::current_metadata().unwrap(); + let current_dir = current_dir().unwrap(); + let package = + dylint_internal::cargo::package_with_root(&metadata, ¤t_dir).unwrap(); + let target = example_target(&package, example).unwrap(); + + run_example_test(driver, &metadata, &package, &target, &self.config).unwrap(); + } + Target::Examples => { + let metadata = dylint_internal::cargo::current_metadata().unwrap(); + let current_dir = current_dir().unwrap(); + let package = + dylint_internal::cargo::package_with_root(&metadata, ¤t_dir).unwrap(); + let targets = example_targets(&package).unwrap(); + + for target in targets { + run_example_test(driver, &metadata, &package, &target, &self.config).unwrap(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // smoelius: Verify that `rustc_flags` compiles when used as intended. + #[allow(dead_code)] + fn rustc_flags() { let _ = Test::src_base("name", PathBuf::new()).rustc_flags(["--test"]); } +} diff --git a/verus/whitaker_cli_dispatch.rs b/verus/whitaker_cli_dispatch.rs new file mode 100644 index 000000000..9b5b2814d --- /dev/null +++ b/verus/whitaker_cli_dispatch.rs @@ -0,0 +1,121 @@ +//! Unbounded proof that Whitaker CLI token classification is total and disjoint. +//! +//! Run with `scripts/run-verus.sh cli`; the default `make verus` group omits +//! this proof until it verifies within tolerance. This model mirrors the pure +//! classifier in `src/cli/dispatch.rs`; the bounded Kani harness checks the +//! production implementation over token sequences up to length six. + +use vstd::prelude::*; + +verus! { + +/// Token categories inspected by the Whitaker CLI router. +pub enum TokenTag { + /// Root `install` command. + Install, + /// Root `ls` command. + Ls, + /// Root help option. + Help, + /// Root version option. + Version, + /// End-of-options marker. + DoubleDash, + /// Shared quiet or verbose option. + GlobalFlag, + /// Option owned by `cargo dylint`. + DylintFlag, + /// Any other token. + Other, +} + +/// The single route selected by the root CLI classifier. +pub enum RoutingClass { + /// Dispatch to an installer or inventory subcommand. + Subcommand, + /// Print root-level help or version output. + Display, + /// Forward the arguments to `cargo dylint`. + Forward, +} + +/// Classifies tokens using the same first-significant-token policy as Rust. +pub open spec fn classify(tokens: Seq) -> RoutingClass + decreases tokens.len() +{ + if tokens.len() == 0 { + RoutingClass::Forward + } else if tokens[0] == TokenTag::GlobalFlag { + classify(tokens.drop_first()) + } else if tokens[0] == TokenTag::Install || tokens[0] == TokenTag::Ls { + RoutingClass::Subcommand + } else if tokens[0] == TokenTag::Help || tokens[0] == TokenTag::Version { + RoutingClass::Display + } else { + RoutingClass::Forward + } +} + +/// Reports whether classification selects a Whitaker subcommand. +pub open spec fn is_subcommand(tokens: Seq) -> bool { + classify(tokens) == RoutingClass::Subcommand +} + +/// Reports whether the first non-global token requests root help or version. +pub open spec fn is_display(tokens: Seq) -> bool { + classify(tokens) == RoutingClass::Display +} + +/// Reports whether routing reaches the legacy `cargo dylint` path. +pub open spec fn is_forward(tokens: Seq) -> bool { + classify(tokens) == RoutingClass::Forward +} + +/// Each routing class has an explicit witness, preventing vacuous disjointness. +proof fn lemma_classes_are_inhabited() + ensures + exists|tokens: Seq| is_subcommand(tokens), + exists|tokens: Seq| is_display(tokens), + exists|tokens: Seq| is_forward(tokens), +{ + assert(is_subcommand(seq![TokenTag::Install])) by (compute); + assert(is_display(seq![TokenTag::Help])) by (compute); + assert(is_forward(seq![TokenTag::DylintFlag])) by (compute); +} + +/// Exactly one routing class applies to every finite token sequence. +proof fn lemma_classification_is_total_and_disjoint(tokens: Seq) + ensures + is_subcommand(tokens) || is_display(tokens) || is_forward(tokens), + !(is_subcommand(tokens) && is_forward(tokens)), + !(is_display(tokens) && is_forward(tokens)), + !(is_subcommand(tokens) && is_display(tokens)), + decreases tokens.len() +{ + if tokens.len() == 0 { + assert(is_forward(tokens)) by (compute); + } else if tokens[0] == TokenTag::GlobalFlag { + let rest = tokens.drop_first(); + lemma_classification_is_total_and_disjoint(rest); + assert(is_subcommand(tokens) == is_subcommand(rest)) by (compute); + assert(is_display(tokens) == is_display(rest)) by (compute); + assert(is_forward(tokens) == is_forward(rest)) by (compute); + } else if tokens[0] == TokenTag::Install || tokens[0] == TokenTag::Ls { + assert(is_subcommand(tokens)) by (compute); + assert(!is_display(tokens)) by (compute); + assert(!is_forward(tokens)) by (compute); + } else if tokens[0] == TokenTag::Help || tokens[0] == TokenTag::Version { + assert(!is_subcommand(tokens)) by (compute); + assert(is_display(tokens)) by (compute); + assert(!is_forward(tokens)) by (compute); + } else { + assert(!is_subcommand(tokens)) by (compute); + assert(!is_display(tokens)) by (compute); + assert(is_forward(tokens)) by (compute); + } +} + +fn main() { +} + +} // verus!