diff --git a/.config/nextest.toml b/.config/nextest.toml index 5eceddc5..827acc4f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -13,7 +13,7 @@ default-filter = "not (binary(behaviour_toolchain) | binary(behaviour_cli) | kin [profile.ci] default-filter = "not kind(example)" -# Serialise dylint UI tests that build lint libraries and use +# Serialize dylint UI tests that build lint libraries and use # `#[serial]`. nextest runs each test in its own process, so the # `serial_test` crate's in-process mutex has no effect. A test-group # with max-threads = 1 prevents concurrent lint-library builds from @@ -49,7 +49,7 @@ retries = { backoff = "exponential", count = 2, delay = "5s" } slow-timeout = { period = "10m", terminate-after = 1 } [[profile.default.overrides]] -# Serialise ignored exclusion integration tests when they are explicitly run. +# Serialize ignored exclusion integration tests when they are explicitly run. # They build and stage the lint library before invoking `cargo dylint`, so they # share the same target-directory race risk as the UI harnesses above. filter = "binary(integration_exclusion)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10204fe2..46b0ea71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ env: BUILD_PROFILE: debug CARGO_INCREMENTAL: 0 CARGO_TERM_COLOR: always + WHITAKER_INSTALLER_VERSION: '0.2.7' RUSTFLAGS: -D warnings RUSTDOCFLAGS: -D warnings SCCACHE_GHA_ENABLED: "true" @@ -129,6 +130,26 @@ jobs: !**/.uv-cache/** !**/.uv-tools/** + - name: Cache Whitaker installer + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/bin/whitaker-installer + ~/.cache/cargo-binstall + key: whitaker-installer-${{ runner.os }}-${{ runner.arch }}-${{ env.WHITAKER_INSTALLER_VERSION }} + + - name: Install Whitaker + run: | + if ! command -v whitaker-installer >/dev/null 2>&1; then + if cargo binstall --version >/dev/null 2>&1; then + cargo binstall --no-confirm --locked "whitaker-installer@${WHITAKER_INSTALLER_VERSION}" + else + echo "cargo-binstall unavailable; building whitaker-installer from crates.io" + cargo install --locked whitaker-installer --version "${WHITAKER_INSTALLER_VERSION}" + fi + fi + whitaker-installer + - name: Lint run: make lint diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 00000000..ddaf6641 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,26 @@ +unstable_features = true +comment_width = 100 +format_code_in_doc_comments = true +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +wrap_comments = true +group_imports = "StdExternalCrate" +use_try_shorthand = true +hex_literal_case = "Lower" +format_strings = true +format_macro_matchers = true +fn_single_line = true +condense_wildcard_suffixes = true +use_field_init_shorthand = true + +# Dylint UI fixtures are test data, not source: their `.stderr` expectations +# match compiler output byte for byte, including line and column numbers. +# Reformatting them (for example `format_strings` splitting a long `reason` +# across lines) silently shifts those spans and breaks the UI tests. Fixtures +# under `ui/` are already immune because they are not Cargo targets; these are +# targets, so exclude them explicitly. +ignore = [ + "crates/no_expect_outside_tests/examples", + "crates/no_unwrap_or_else_panic/examples", + "crates/rstest_helper_should_be_fixture/examples", +] diff --git a/AGENTS.md b/AGENTS.md index ad0d6d48..400cf9db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,18 +146,30 @@ project: - `make lint` executes: ```sh - cargo clippy --workspace --all-targets --all-features -- -D warnings + RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo doc --workspace --no-deps + cargo clippy --workspace --lib --bins --tests --benches --all-features \ + -- -D warnings + whitaker --all -- -p whitaker-common -p whitaker-installer \ + -p whitaker_clones_core -p whitaker_sarif --all-targets --all-features ``` - linting every target with all features enabled and denying all Clippy - warnings. - - `make test` executes: + building rustdoc with warnings denied, linting every target except the + Dylint UI fixtures under `examples/` (which deliberately contain the + anti-patterns the suite detects, so the workspace policy cannot apply to + them) with all features enabled and denying all Clippy warnings, and + running the + Whitaker Dylint suite over the support crates (install via + `cargo install whitaker-installer && whitaker-installer`). `make + lint-clippy` runs the rustdoc and Clippy subset; `make lint-whitaker` + runs the Whitaker subset. + - `make test` executes `cargo nextest run` over the CI crate subset and + then the workspace doctests: ```sh - cargo test --workspace + RUSTFLAGS="-D warnings" cargo test --workspace --doc --all-features ``` - running the full workspace test suite. Use `make fmt` + running the full workspace test suite including doctests. Use `make fmt` (`cargo fmt --workspace`) to apply formatting fixes reported by the formatter check. - Clippy warnings MUST be disallowed. diff --git a/Cargo.lock b/Cargo.lock index 8c588882..1d4844bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher", "cpubits", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "serde_core", @@ -207,9 +207,10 @@ dependencies = [ "rustc_span", "serde", "tempfile", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -238,9 +239,9 @@ dependencies = [ [[package]] name = "cap-primitives" -version = "3.4.5" +version = "3.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c" dependencies = [ "ambient-authority", "fs-set-times", @@ -256,9 +257,9 @@ dependencies = [ [[package]] name = "cap-primitives" -version = "4.0.2" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" dependencies = [ "ambient-authority", "fs-set-times", @@ -274,12 +275,12 @@ dependencies = [ [[package]] name = "cap-std" -version = "3.4.5" +version = "3.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7" dependencies = [ "camino", - "cap-primitives 3.4.5", + "cap-primitives 3.4.6", "io-extras 0.18.4", "io-lifetimes 2.0.4", "rustix", @@ -287,12 +288,12 @@ dependencies = [ [[package]] name = "cap-std" -version = "4.0.2" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" dependencies = [ "camino", - "cap-primitives 4.0.2", + "cap-primitives 4.0.3", "io-extras 0.19.0", "io-lifetimes 3.0.1", "rustix", @@ -324,9 +325,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.67" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -461,6 +462,7 @@ dependencies = [ "tempfile", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -701,13 +703,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -724,9 +726,9 @@ checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" [[package]] name = "dylint" -version = "6.0.1" +version = "6.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c738fb72ea7d248df2995a31b3698beb63d0f1f9ca5a5dc0188c4b64cd0e86e4" +checksum = "d59232730e7347f7b56a021e943c7cbd57636f56866c2a95341aa6159e64dad0" dependencies = [ "anstyle", "anyhow", @@ -742,9 +744,9 @@ dependencies = [ [[package]] name = "dylint_internal" -version = "6.0.1" +version = "6.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9796d3441d7894cbaf4992640799efffc1661978f0cc1266ec788c32254fdfb3" +checksum = "bc1edfdeb8f1b20ee0adcd6fa30f1562aebe7ef49caf89a0befab488d62babfd" dependencies = [ "anstyle", "anyhow", @@ -754,17 +756,20 @@ dependencies = [ "home", "log", "regex", + "semver", "serde", "tar", + "tempfile", "thiserror 2.0.20", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", + "toml_edit", ] [[package]] name = "dylint_linting" -version = "6.0.1" +version = "6.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c213635b3eaa84f43b9b497377f17d9a8d4feb413bab1d82e03ad1c73e4f6d8c" +checksum = "9e9f65c0439f032da8c5febad832b033c6784f084a3fdc8c3b41e0ed727d5654" dependencies = [ "cargo_metadata", "dylint_internal", @@ -772,14 +777,14 @@ dependencies = [ "rustversion", "serde", "thiserror 2.0.20", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "dylint_testing" -version = "6.0.1" +version = "6.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5f50ee06be9ebfb5b9538ba0d7bb08adc33e8f31c901e7d398de2a9b1ae290" +checksum = "6f7d8b8cb1558ef0d090882c6359e5e2d5571ca6633446fa2347fa48da7b27bb" dependencies = [ "anyhow", "cargo_metadata", @@ -795,9 +800,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encode_unicode" @@ -841,14 +846,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filetime" @@ -871,9 +876,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -984,15 +989,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fragile" version = "2.1.0" @@ -1043,19 +1039,20 @@ dependencies = [ "serial_test", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1064,20 +1061,20 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -1087,9 +1084,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -1162,17 +1159,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags 2.13.1", "libc", "libgit2-sys", "log", - "openssl-probe", - "openssl-sys", - "url", ] [[package]] @@ -1183,9 +1177,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -1255,9 +1249,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1271,9 +1265,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -1325,114 +1319,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "ignore" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -1521,7 +1412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1538,9 +1429,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1565,11 +1456,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "log", "portable-atomic", @@ -1577,12 +1469,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + [[package]] name = "jiff-static" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn 2.0.119", @@ -1606,9 +1508,9 @@ checksum = "a037eddb7d28de1d0fc42411f501b53b75838d313908078d6698d064f3029b24" [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1635,41 +1537,25 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", - "libssh2-sys", "libz-sys", - "openssl-sys", "pkg-config", ] [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] -[[package]] -name = "libssh2-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - [[package]] name = "libz-sys" version = "1.1.29" @@ -1688,12 +1574,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "lock_api" version = "0.4.14" @@ -1833,6 +1713,7 @@ dependencies = [ "tempfile", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1854,6 +1735,7 @@ dependencies = [ "serial_test", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1884,6 +1766,7 @@ dependencies = [ "tokio", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1910,9 +1793,10 @@ dependencies = [ "serde_json", "serial_test", "tempfile", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1936,6 +1820,7 @@ dependencies = [ "temp-env", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1975,24 +1860,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -2073,15 +1940,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -2092,15 +1959,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -2189,9 +2047,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2223,9 +2081,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2380,9 +2238,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2470,7 +2328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe104196f61dc8911a8da1b10005e9401e7c2e14ad9302e2de6688311c0beec7" dependencies = [ "camino", - "cap-std 3.4.5", + "cap-std 3.4.6", "cfg-if", "convert_case 0.6.0", "gherkin", @@ -2509,7 +2367,7 @@ name = "rstest_helper_should_be_fixture" version = "0.2.7" dependencies = [ "camino", - "cap-std 4.0.2", + "cap-std 4.0.3", "dylint_linting", "dylint_testing", "filetime", @@ -2523,7 +2381,7 @@ dependencies = [ "rustc_session", "rustc_span", "serde", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "trybuild", "whitaker", "whitaker-common", @@ -2668,7 +2526,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2683,9 +2541,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -2698,18 +2556,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -2919,12 +2777,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strsim" version = "0.11.1" @@ -2969,17 +2821,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "sys-locale" version = "0.3.2" @@ -3002,9 +2843,9 @@ dependencies = [ [[package]] name = "target-triple" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" [[package]] name = "temp-env" @@ -3022,10 +2863,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3071,6 +2912,7 @@ dependencies = [ "serde", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -3145,9 +2987,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -3165,9 +3007,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -3186,13 +3028,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3206,9 +3048,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -3237,14 +3079,15 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -3304,7 +3147,7 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -3475,30 +3318,12 @@ dependencies = [ "log", ] -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -3507,9 +3332,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "value-bag" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" [[package]] name = "vcpkg" @@ -3559,9 +3384,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -3572,9 +3397,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3582,9 +3407,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -3595,18 +3420,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -3629,9 +3454,10 @@ dependencies = [ "rustc_span", "serde", "thiserror 2.0.20", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "whitaker-common", "whitaker-installer", + "whitaker_test_macros", ] [[package]] @@ -3649,9 +3475,11 @@ dependencies = [ "rstest", "rstest-bdd", "rstest-bdd-macros", + "temp-env", "tempfile", "thiserror 2.0.20", "unic-langid", + "whitaker_test_macros", ] [[package]] @@ -3659,17 +3487,17 @@ name = "whitaker-installer" version = "0.2.7" dependencies = [ "camino", - "cap-std 4.0.2", + "cap-std 4.0.3", "clap", "directories-next", "flate2", "fs2", - "libc", "log", "mockall", "rstest", "rstest-bdd", "rstest-bdd-macros", + "rustix", "serde", "serde_json", "sha2", @@ -3677,13 +3505,14 @@ dependencies = [ "temp-env", "tempfile", "thiserror 2.0.20", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tracing", "trybuild", "ureq", "wait-timeout", "whitaker-common", "whitaker-installer", + "whitaker_test_macros", "zip", "zstd", ] @@ -3693,7 +3522,7 @@ name = "whitaker_clones_core" version = "0.2.7" dependencies = [ "camino", - "cap-std 4.0.2", + "cap-std 4.0.3", "insta", "proptest", "ra_ap_syntax", @@ -3705,9 +3534,10 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.20", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "tracing", "whitaker_sarif", + "whitaker_test_macros", ] [[package]] @@ -3722,6 +3552,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.20", + "whitaker_test_macros", ] [[package]] @@ -3746,6 +3577,17 @@ dependencies = [ "rustc_session", "rustc_span", "test_must_not_have_example", + "whitaker-common", + "whitaker_test_macros", +] + +[[package]] +name = "whitaker_test_macros" +version = "0.2.7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3770,7 +3612,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3791,7 +3633,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3800,7 +3642,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -3818,14 +3669,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3834,48 +3702,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -3907,12 +3823,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - [[package]] name = "xattr" version = "1.6.1" @@ -3923,43 +3833,20 @@ dependencies = [ "rustix", ] -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -3971,21 +3858,6 @@ name = "zerofrom" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] [[package]] name = "zeroize" @@ -3993,38 +3865,14 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", - "yoke", "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", ] [[package]] @@ -4056,9 +3904,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 7e79a266..31da331a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ clap = "4.5.38" directories-next = "2.0.0" dylint_linting = "6" dylint_testing = "6" -libc = "0.2" fluent-templates = "^0.15.0" insta = { version = "1", features = ["json"] } once_cell = "^1.21.3" @@ -49,6 +48,10 @@ rustc_lexer = "0.1.0" ra_ap_syntax = "=0.0.334" whitaker_clones_core = { path = "crates/whitaker_clones_core", version = "0.2.7" } whitaker_sarif = { path = "crates/whitaker_sarif", version = "0.2.7" } +# Path-only (no `version`): the crate is `publish = false`, and Cargo strips +# versionless dev-dependencies when packaging, so the published crates do not +# gain an unresolvable requirement. +whitaker_test_macros = { path = "crates/whitaker_test_macros" } temp-env = "0.3.6" tempfile = "3.19.1" wait-timeout = "0.2.1" @@ -95,13 +98,17 @@ rustc_span = { workspace = true, optional = true } dylint_linting = { workspace = true, optional = 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 } -[lints.clippy] +[lints] +workspace = true + +[workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } # 1. hygiene @@ -109,8 +116,10 @@ allow_attributes = "deny" allow_attributes_without_reason = "deny" blanket_clippy_restriction_lints = "deny" cognitive_complexity = "deny" +disallowed_methods = "deny" needless_pass_by_value = "deny" implicit_hasher = "deny" +missing_assert_message = "deny" # 2. debugging leftovers dbg_macro = "deny" @@ -166,6 +175,7 @@ result_large_err = "deny" [workspace.lints.rust] unknown_lints = "deny" renamed_and_removed_lints = "deny" +unsafe_code = "forbid" missing_docs = "deny" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } diff --git a/Makefile b/Makefile index b5cf8885..8829d3fb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test coverage build release lint fmt check-fmt markdownlint nixie publish-check typecheck install-smoke release-installer-dry-run package-lints workflow-test workflow-test-deps test-workflow-contracts verus kani verus-clone-detector kani-clone-detector spelling spelling-config spelling-config-write spelling-phrase-check spelling-helper-test +.PHONY: help all clean test coverage build release lint lint-clippy lint-whitaker fmt check-fmt markdownlint nixie publish-check typecheck install-smoke release-installer-dry-run package-lints workflow-test workflow-test-deps test-workflow-contracts verus kani verus-clone-detector kani-clone-detector spelling spelling-config spelling-config-write spelling-phrase-check spelling-helper-test # Appended only on targets that invoke binaries commonly installed under these # prefixes (cargo/bun/user-local), so the default recipe environment stays @@ -11,6 +11,15 @@ CARGO ?= $(or $(shell command -v cargo 2>/dev/null),$(shell [ -x "$(HOME)/.cargo CARGO_LOCKED ?= BUILD_JOBS ?= CARGO_FLAGS ?= --workspace --all-targets --all-features +# Dylint UI fixtures live under `examples/` because `dylint_testing::Test::example` +# builds them with each lint crate's dev-dependencies (tokio, rstest); `ui/` +# fixtures are standalone and cannot carry those. Every file there is a +# `fail_*`/`pass_*` fixture that deliberately contains the anti-patterns the +# suite detects, so the workspace lint policy cannot meaningfully apply to them +# -- a fixture proving `expect_used` fires cannot itself forbid `expect_used`. +# 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_CARGO_FLAGS ?= $(CARGO_FLAGS) $(TEST_EXCLUDES) NEXTEST_PROFILE ?= @@ -47,12 +56,29 @@ 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. +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 rstest_helper_should_be_fixture \ + $(foreach crate,$(LINT_CRATES),--exclude $(crate)) CARGO_DYLINT_VERSION ?= 6.0.1 DYLINT_LINK_VERSION ?= 6.0.1 # Host-tool installs run under this toolchain: the dylint 6.0.1 lockfile # needs a newer rustc than the repository's pinned nightly provides. 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 build: target/debug/$(APP) ## Build debug binary release: target/release/$(APP) ## Build release binary @@ -107,6 +133,7 @@ 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) test --workspace --doc --all-features $(DOCTEST_EXCLUDES) $(BUILD_JOBS); \ if [ "$${ACT_WORKFLOW_TESTS:-0}" = "1" ]; then \ $(MAKE) workflow-test; \ fi @@ -147,9 +174,15 @@ target/%/$(APP): ## Build binary in debug or release mode manifest=$$(grep -l whitaker-installer */Cargo.toml crates/*/Cargo.toml); \ $(CARGO) build $(CARGO_LOCKED) $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(APP) --manifest-path "$$manifest" -lint: ## Run Clippy with warnings denied +lint: lint-clippy lint-whitaker ## Run rustdoc, Clippy, and the Whitaker Dylint suite + +lint-clippy: ## Run rustdoc and Clippy with warnings denied RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc $(CARGO_LOCKED) --workspace --no-deps - $(CARGO) clippy $(CARGO_LOCKED) $(CARGO_FLAGS) -- $(RUST_FLAGS) + $(CARGO) clippy $(CARGO_LOCKED) $(CLIPPY_FLAGS) -- $(RUST_FLAGS) + +lint-whitaker: ## Run the Whitaker Dylint suite with warnings denied + @export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; \ + RUSTFLAGS="$(RUST_FLAGS)" $(WHITAKER) --all -- $(WHITAKER_PACKAGES) --all-targets --all-features fmt: ## Format Rust and Markdown sources $(CARGO) fmt --all diff --git a/common/Cargo.toml b/common/Cargo.toml index 76b52eb5..8c3dc360 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -18,6 +18,7 @@ fluent-templates = { workspace = true } glob = "0.3.3" log = { workspace = true } once_cell = { workspace = true } +temp-env = { workspace = true } tempfile = "3.14.0" thiserror = { workspace = true } unic-langid = { workspace = true } @@ -33,11 +34,8 @@ rstest-bdd-macros = { workspace = true } proptest = "1" regex = "1.10.4" logtest = "2.0.0" +whitaker_test_macros = { workspace = true } -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } - -[lints.clippy] -expect_used = "deny" -unwrap_used = "deny" +[lints] +workspace = true diff --git a/common/locales/cy/common.ftl b/common/locales/cy/common.ftl index d5f35a3f..4f996c64 100644 --- a/common/locales/cy/common.ftl +++ b/common/locales/cy/common.ftl @@ -6,7 +6,7 @@ -term-branch = cangen -term-test-coverage = cwmpas profion -# Borrowed English nouns typically pluralise with -iau (Modern Welsh, Gareth +# Borrowed English nouns typically pluralize with -iau (Modern Welsh, Gareth # King §2.8), so we render "lint" as "lintiau" in aggregated messaging. common-lint-count = { $lint -> diff --git a/common/locales/en-GB/no_expect_outside_tests.ftl b/common/locales/en-GB/no_expect_outside_tests.ftl index 29ef45aa..3d478e5f 100644 --- a/common/locales/en-GB/no_expect_outside_tests.ftl +++ b/common/locales/en-GB/no_expect_outside_tests.ftl @@ -1,7 +1,7 @@ ## Restrict expect calls outside test contexts. no_expect_outside_tests = Avoid calling expect on { $receiver } outside test-only code. - .note = The call originates within { $context } which is not recognised as a test. + .note = The call originates within { $context } which is not recognized as a test. .help = { $handling -> [option] Handle the `None` variant of { $receiver } or move the code into a test. [result] Handle the `Err` variant of { $receiver } or move the code into a test. diff --git a/common/src/attributes/attribute.rs b/common/src/attributes/attribute.rs index 5284d711..090871cf 100644 --- a/common/src/attributes/attribute.rs +++ b/common/src/attributes/attribute.rs @@ -22,7 +22,7 @@ impl Attribute { /// assert!(attribute.is_outer()); /// ``` #[must_use] - pub fn new(path: AttributePath, kind: AttributeKind) -> Self { + pub const fn new(path: AttributePath, kind: AttributeKind) -> Self { Self { path, kind, @@ -87,9 +87,7 @@ impl Attribute { /// assert!(attribute.path().is_doc()); /// ``` #[must_use] - pub fn path(&self) -> &AttributePath { - &self.path - } + pub const fn path(&self) -> &AttributePath { &self.path } /// Returns the attachment kind (inner or outer). /// @@ -102,9 +100,7 @@ impl Attribute { /// assert!(attribute.kind().is_inner()); /// ``` #[must_use] - pub const fn kind(&self) -> AttributeKind { - self.kind - } + pub const fn kind(&self) -> AttributeKind { self.kind } /// Returns the attribute arguments. /// @@ -121,9 +117,7 @@ impl Attribute { /// assert_eq!(attribute.arguments(), &["dead_code"]); /// ``` #[must_use] - pub fn arguments(&self) -> &[String] { - &self.arguments - } + pub fn arguments(&self) -> &[String] { &self.arguments } /// Indicates whether the attribute is a doc comment (`#[doc = ...]`). /// @@ -136,9 +130,7 @@ impl Attribute { /// assert!(attribute.is_doc()); /// ``` #[must_use] - pub fn is_doc(&self) -> bool { - self.path.is_doc() - } + pub fn is_doc(&self) -> bool { self.path.is_doc() } /// Indicates whether the attribute marks a test-like context. /// @@ -157,9 +149,7 @@ impl Attribute { /// assert!(rstest.is_test_like()); /// ``` #[must_use] - pub fn is_test_like(&self) -> bool { - self.is_test_like_with(&[]) - } + pub fn is_test_like(&self) -> bool { self.is_test_like_with(&[]) } /// Indicates whether the attribute marks a test-like context when supplied /// with additional recognized paths. @@ -205,9 +195,7 @@ impl Attribute { /// assert!(attribute.is_inner()); /// ``` #[must_use] - pub const fn is_inner(&self) -> bool { - self.kind.is_inner() - } + pub const fn is_inner(&self) -> bool { self.kind.is_inner() } /// Returns `true` when the attribute is an outer attribute. /// @@ -220,9 +208,7 @@ impl Attribute { /// assert!(attribute.is_outer()); /// ``` #[must_use] - pub const fn is_outer(&self) -> bool { - self.kind.is_outer() - } + pub const fn is_outer(&self) -> bool { self.kind.is_outer() } } fn matches_builtin_test_like_path(path: &AttributePath) -> bool { @@ -246,9 +232,12 @@ fn is_prelude_test_attribute(path: &AttributePath) -> bool { #[cfg(test)] mod tests { - use super::*; + //! Tests for attribute parsing, classification, and ordering helpers. + use rstest::rstest; + use super::*; + #[rstest] #[case::core_v1("core::prelude::v1::test", true)] #[case::absolute_core_v1("::core::prelude::v1::test", true)] diff --git a/common/src/attributes/helpers.rs b/common/src/attributes/helpers.rs index ecce64f2..340c4f8a 100644 --- a/common/src/attributes/helpers.rs +++ b/common/src/attributes/helpers.rs @@ -7,7 +7,12 @@ use super::{Attribute, AttributePath}; /// # Examples /// /// ``` -/// use whitaker_common::attributes::{split_doc_attributes, Attribute, AttributeKind, AttributePath}; +/// use whitaker_common::attributes::{ +/// Attribute, +/// AttributeKind, +/// AttributePath, +/// split_doc_attributes, +/// }; /// /// let doc = Attribute::new(AttributePath::from("doc"), AttributeKind::Outer); /// let allow = Attribute::new(AttributePath::from("allow"), AttributeKind::Outer); @@ -26,7 +31,7 @@ pub fn split_doc_attributes(attrs: &[Attribute]) -> (Vec<&Attribute>, Vec<&Attri /// # Examples /// /// ``` -/// use whitaker_common::attributes::{outer_attributes, Attribute, AttributeKind, AttributePath}; +/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath, outer_attributes}; /// /// let inner = Attribute::new(AttributePath::from("doc"), AttributeKind::Inner); /// let outer = Attribute::new(AttributePath::from("test"), AttributeKind::Outer); @@ -44,7 +49,12 @@ pub fn outer_attributes(attrs: &[Attribute]) -> Vec<&Attribute> { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{has_test_like_attribute, Attribute, AttributeKind, AttributePath}; +/// use whitaker_common::attributes::{ +/// Attribute, +/// AttributeKind, +/// AttributePath, +/// has_test_like_attribute, +/// }; /// /// let attr = Attribute::new(AttributePath::from("tokio::test"), AttributeKind::Outer); /// assert!(has_test_like_attribute(&[attr])); @@ -60,7 +70,12 @@ pub fn has_test_like_attribute(attrs: &[Attribute]) -> bool { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{has_test_like_attribute_with, Attribute, AttributeKind, AttributePath}; +/// use whitaker_common::attributes::{ +/// Attribute, +/// AttributeKind, +/// AttributePath, +/// has_test_like_attribute_with, +/// }; /// /// let attr = Attribute::new(AttributePath::from("custom::test"), AttributeKind::Outer); /// let additional = vec![AttributePath::from("custom::test")]; diff --git a/common/src/attributes/kind.rs b/common/src/attributes/kind.rs index 9fac08ae..4f7be744 100644 --- a/common/src/attributes/kind.rs +++ b/common/src/attributes/kind.rs @@ -21,9 +21,7 @@ impl AttributeKind { /// assert!(!AttributeKind::Outer.is_inner()); /// ``` #[must_use] - pub const fn is_inner(self) -> bool { - matches!(self, Self::Inner) - } + pub const fn is_inner(self) -> bool { matches!(self, Self::Inner) } /// Returns `true` when the attribute is an outer attribute. /// @@ -36,7 +34,5 @@ impl AttributeKind { /// assert!(!AttributeKind::Inner.is_outer()); /// ``` #[must_use] - pub const fn is_outer(self) -> bool { - matches!(self, Self::Outer) - } + pub const fn is_outer(self) -> bool { matches!(self, Self::Outer) } } diff --git a/common/src/attributes/mod.rs b/common/src/attributes/mod.rs index e6169e6c..e62c5d1a 100644 --- a/common/src/attributes/mod.rs +++ b/common/src/attributes/mod.rs @@ -28,7 +28,10 @@ mod path; pub use attribute::Attribute; pub use helpers::{ - has_test_like_attribute, has_test_like_attribute_with, outer_attributes, split_doc_attributes, + has_test_like_attribute, + has_test_like_attribute_with, + outer_attributes, + split_doc_attributes, }; pub use kind::AttributeKind; pub use path::AttributePath; diff --git a/common/src/attributes/path.rs b/common/src/attributes/path.rs index 6bb85459..189d8880 100644 --- a/common/src/attributes/path.rs +++ b/common/src/attributes/path.rs @@ -7,9 +7,12 @@ pub type AttributePath = SimplePath; #[cfg(test)] mod tests { - use super::AttributePath; + //! Tests for `AttributePath` segment matching and comparison. + use rstest::rstest; + use super::AttributePath; + #[rstest] fn parses_paths() { let path = AttributePath::from("tokio::test"); diff --git a/common/src/attributes/tests.rs b/common/src/attributes/tests.rs index 376e818a..d7aede02 100644 --- a/common/src/attributes/tests.rs +++ b/common/src/attributes/tests.rs @@ -1,12 +1,13 @@ //! Tests for attribute helpers. -use super::*; use rstest::rstest; +use super::*; + #[rstest] #[case::empty(Vec::::new())] -#[case::single(vec!["dead_code".to_string()])] -#[case::complex(vec!["cfg(feature = \"test\")".to_string(), "path(\"std::io\")".to_string()])] +#[case::single(vec!["dead_code".to_owned()])] +#[case::complex(vec!["cfg(feature = \"test\")".to_owned(), "path(\"std::io\")".to_owned()])] fn attribute_with_arguments_preserves_inputs(#[case] arguments: Vec) { let attribute = Attribute::with_arguments( AttributePath::from("allow"), diff --git a/common/src/brain_trait_metrics/diagnostic.rs b/common/src/brain_trait_metrics/diagnostic.rs index 00ca4151..4cb3f9d4 100644 --- a/common/src/brain_trait_metrics/diagnostic.rs +++ b/common/src/brain_trait_metrics/diagnostic.rs @@ -7,10 +7,12 @@ //! See `docs/brain-trust-lints-design.md` §Diagnostic output for the //! full format specification. -use super::TraitMetrics; -use super::evaluation::BrainTraitDisposition; +use super::{TraitMetrics, evaluation::BrainTraitDisposition}; use crate::decomposition_advice::{ - DecompositionContext, DecompositionSuggestion, SubjectKind, format_diagnostic_note, + DecompositionContext, + DecompositionSuggestion, + SubjectKind, + format_diagnostic_note, }; #[cfg(test)] @@ -29,10 +31,10 @@ mod tests; /// # Examples /// /// ``` -/// use whitaker_common::brain_trait_metrics::evaluation::{ -/// BrainTraitDiagnostic, BrainTraitDisposition, +/// use whitaker_common::brain_trait_metrics::{ +/// TraitMetricsBuilder, +/// evaluation::{BrainTraitDiagnostic, BrainTraitDisposition}, /// }; -/// use whitaker_common::brain_trait_metrics::TraitMetricsBuilder; /// /// let metrics = TraitMetricsBuilder::new("Foo").build(); /// let diag = BrainTraitDiagnostic::new(&metrics, BrainTraitDisposition::Pass); @@ -66,52 +68,38 @@ impl BrainTraitDiagnostic { /// Returns the trait name. #[must_use] - pub fn trait_name(&self) -> &str { - &self.trait_name - } + pub fn trait_name(&self) -> &str { &self.trait_name } /// Returns the evaluation disposition. #[must_use] - pub fn disposition(&self) -> BrainTraitDisposition { - self.disposition - } + pub const fn disposition(&self) -> BrainTraitDisposition { self.disposition } /// Returns the number of required methods. #[must_use] - pub fn required_method_count(&self) -> usize { - self.required_method_count - } + pub const fn required_method_count(&self) -> usize { self.required_method_count } /// Returns the number of default methods. #[must_use] - pub fn default_method_count(&self) -> usize { - self.default_method_count - } + pub const fn default_method_count(&self) -> usize { self.default_method_count } /// Returns the total method count (required + default). #[must_use] - pub fn total_method_count(&self) -> usize { + pub const fn total_method_count(&self) -> usize { self.required_method_count + self.default_method_count } /// Returns the sum of default method cognitive complexity values. #[must_use] - pub fn default_method_cc_sum(&self) -> usize { - self.default_method_cc_sum - } + pub const fn default_method_cc_sum(&self) -> usize { self.default_method_cc_sum } /// Returns the total number of trait items (methods + associated /// types + associated consts). #[must_use] - pub fn total_item_count(&self) -> usize { - self.total_item_count - } + pub const fn total_item_count(&self) -> usize { self.total_item_count } /// Returns implementor burden (required method count). #[must_use] - pub fn implementor_burden(&self) -> usize { - self.implementor_burden - } + pub const fn implementor_burden(&self) -> usize { self.implementor_burden } } // --------------------------------------------------------------------------- @@ -126,10 +114,10 @@ impl BrainTraitDiagnostic { /// # Examples /// /// ``` -/// use whitaker_common::brain_trait_metrics::evaluation::{ -/// BrainTraitDiagnostic, BrainTraitDisposition, format_primary_message, +/// use whitaker_common::brain_trait_metrics::{ +/// TraitMetricsBuilder, +/// evaluation::{BrainTraitDiagnostic, BrainTraitDisposition, format_primary_message}, /// }; -/// use whitaker_common::brain_trait_metrics::TraitMetricsBuilder; /// /// let mut builder = TraitMetricsBuilder::new("Parser"); /// builder.add_required_method("parse"); @@ -149,8 +137,8 @@ pub fn format_primary_message(diagnostic: &BrainTraitDiagnostic) -> String { if cc > 0 { format!( - "`{name}` has {total} methods ({req} required, \ - {def} default) with default method complexity CC={cc}." + "`{name}` has {total} methods ({req} required, {def} default) with default method \ + complexity CC={cc}." ) } else { format!("`{name}` has {total} methods ({req} required, {def} default).") @@ -165,10 +153,10 @@ pub fn format_primary_message(diagnostic: &BrainTraitDiagnostic) -> String { /// # Examples /// /// ``` -/// use whitaker_common::brain_trait_metrics::evaluation::{ -/// BrainTraitDiagnostic, BrainTraitDisposition, format_note, +/// use whitaker_common::brain_trait_metrics::{ +/// TraitMetricsBuilder, +/// evaluation::{BrainTraitDiagnostic, BrainTraitDisposition, format_note}, /// }; -/// use whitaker_common::brain_trait_metrics::TraitMetricsBuilder; /// /// let metrics = TraitMetricsBuilder::new("Foo").build(); /// let diag = BrainTraitDiagnostic::new(&metrics, BrainTraitDisposition::Pass); @@ -181,14 +169,13 @@ pub fn format_note(diagnostic: &BrainTraitDiagnostic) -> String { String::from("Total method count measures interface size and implementation surface area."); if diagnostic.default_method_cc_sum() > 0 { note.push_str( - " Default method CC sum measures complexity hidden behind \ - the trait's default implementations.", + " Default method CC sum measures complexity hidden behind the trait's default \ + implementations.", ); } if diagnostic.required_method_count() > 0 { note.push_str( - " Implementor burden indicates how many methods each \ - implementor must provide.", + " Implementor burden indicates how many methods each implementor must provide.", ); } note @@ -201,10 +188,10 @@ pub fn format_note(diagnostic: &BrainTraitDiagnostic) -> String { /// # Examples /// /// ``` -/// use whitaker_common::brain_trait_metrics::evaluation::{ -/// BrainTraitDiagnostic, BrainTraitDisposition, format_decomposition_note, +/// use whitaker_common::brain_trait_metrics::{ +/// TraitMetricsBuilder, +/// evaluation::{BrainTraitDiagnostic, BrainTraitDisposition, format_decomposition_note}, /// }; -/// use whitaker_common::brain_trait_metrics::TraitMetricsBuilder; /// /// let metrics = TraitMetricsBuilder::new("Foo").build(); /// let diagnostic = BrainTraitDiagnostic::new(&metrics, BrainTraitDisposition::Pass); @@ -231,10 +218,10 @@ pub fn format_decomposition_note( /// # Examples /// /// ``` -/// use whitaker_common::brain_trait_metrics::evaluation::{ -/// BrainTraitDiagnostic, BrainTraitDisposition, format_help, +/// use whitaker_common::brain_trait_metrics::{ +/// TraitMetricsBuilder, +/// evaluation::{BrainTraitDiagnostic, BrainTraitDisposition, format_help}, /// }; -/// use whitaker_common::brain_trait_metrics::TraitMetricsBuilder; /// /// let metrics = TraitMetricsBuilder::new("Foo").build(); /// let diag = BrainTraitDiagnostic::new(&metrics, BrainTraitDisposition::Pass); @@ -249,10 +236,7 @@ pub fn format_help(diagnostic: &BrainTraitDiagnostic) -> String { parts.push("splitting the trait into focused sub-traits"); } if diagnostic.default_method_cc_sum() > 0 { - parts.push( - "extracting complex default method bodies into free \ - functions or helper traits", - ); + parts.push("extracting complex default method bodies into free functions or helper traits"); } if diagnostic.required_method_count() > 0 { parts.push("providing more default implementations to reduce implementor burden"); @@ -260,8 +244,7 @@ pub fn format_help(diagnostic: &BrainTraitDiagnostic) -> String { if parts.is_empty() { return String::from( - "Consider splitting the trait into smaller, focused \ - sub-traits to reduce complexity.", + "Consider splitting the trait into smaller, focused sub-traits to reduce complexity.", ); } diff --git a/common/src/brain_trait_metrics/diagnostic_tests.rs b/common/src/brain_trait_metrics/diagnostic_tests.rs index e555fbd3..8ffd0bf6 100644 --- a/common/src/brain_trait_metrics/diagnostic_tests.rs +++ b/common/src/brain_trait_metrics/diagnostic_tests.rs @@ -1,12 +1,14 @@ //! Unit tests for brain trait diagnostic formatting. -use super::*; -use crate::brain_trait_metrics::TraitMetricsBuilder; -use crate::brain_trait_metrics::evaluation::BrainTraitDisposition; -use crate::decomposition_advice::SubjectKind; -use crate::test_support::decomposition::{decomposition_suggestions, transport_trait_fixture}; use rstest::rstest; +use super::*; +use crate::{ + brain_trait_metrics::{TraitMetricsBuilder, evaluation::BrainTraitDisposition}, + decomposition_advice::SubjectKind, + test_support::decomposition::{decomposition_suggestions, transport_trait_fixture}, +}; + // --------------------------------------------------------------------------- // Helper: build diagnostics for formatting tests // --------------------------------------------------------------------------- @@ -20,7 +22,7 @@ struct DiagnosticInput<'a> { } /// Builds a diagnostic for a trait with the given method breakdown. -fn build_diagnostic(input: DiagnosticInput<'_>) -> BrainTraitDiagnostic { +fn build_diagnostic(input: &DiagnosticInput<'_>) -> BrainTraitDiagnostic { let mut builder = TraitMetricsBuilder::new(input.name); for i in 0..input.required { builder.add_required_method(format!("req_{i}")); @@ -39,7 +41,7 @@ fn build_diagnostic(input: DiagnosticInput<'_>) -> BrainTraitDiagnostic { /// Builds a primary message for a trait with 15 required + 10 default /// methods, each default having CC=5 (CC sum=50). fn primary_message_with_defaults() -> String { - let diag = build_diagnostic(DiagnosticInput { + let diag = build_diagnostic(&DiagnosticInput { name: "Parser", required: 15, default: 10, @@ -55,9 +57,12 @@ fn primary_message_with_defaults() -> String { #[case("15 required", "required count")] #[case("10 default", "default count")] #[case("CC=50", "CC sum")] -fn primary_message_with_defaults_contains(#[case] fragment: &str, #[case] _description: &str) { +fn primary_message_with_defaults_contains(#[case] fragment: &str, #[case] description: &str) { let msg = primary_message_with_defaults(); - assert!(msg.contains(fragment), "missing fragment: {fragment}"); + assert!( + msg.contains(fragment), + "missing {description} fragment: {fragment}" + ); } // --------------------------------------------------------------------------- @@ -66,7 +71,7 @@ fn primary_message_with_defaults_contains(#[case] fragment: &str, #[case] _descr #[rstest] fn primary_message_omits_cc_when_zero() { - let diag = build_diagnostic(DiagnosticInput { + let diag = build_diagnostic(&DiagnosticInput { name: "Simple", required: 10, default: 0, @@ -87,7 +92,7 @@ fn primary_message_omits_cc_when_zero() { #[rstest] fn primary_message_with_only_default_methods() { - let diag = build_diagnostic(DiagnosticInput { + let diag = build_diagnostic(&DiagnosticInput { name: "AllDefault", required: 0, default: 5, @@ -115,7 +120,7 @@ fn note_contains_expected_fragment( #[case] fragment: &str, ) { let (required, default, cc_per_default) = shape; - let diag = build_diagnostic(DiagnosticInput { + let diag = build_diagnostic(&DiagnosticInput { name, required, default, @@ -130,7 +135,7 @@ fn note_contains_expected_fragment( #[rstest] fn note_omits_cc_when_no_default_methods() { - let diag = build_diagnostic(DiagnosticInput { + let diag = build_diagnostic(&DiagnosticInput { name: "Foo", required: 10, default: 0, @@ -149,7 +154,7 @@ fn note_omits_cc_when_no_default_methods() { #[rstest] fn decomposition_note_delegates_to_shared_renderer_for_traits() { - let diagnostic = build_diagnostic(DiagnosticInput { + let diagnostic = build_diagnostic(&DiagnosticInput { name: "Transport", required: 2, default: 2, @@ -191,7 +196,7 @@ fn help_suggestions( #[case] fragment: &str, ) { let (required, default, cc_per_default) = shape; - let diag = build_diagnostic(DiagnosticInput { + let diag = build_diagnostic(&DiagnosticInput { name, required, default, @@ -236,7 +241,7 @@ fn total_item_count_includes_associated_items() { /// Builds a diagnostic for accessor tests: trait "Qux" with 10 /// required, 5 default (CC=4 each), CC sum=20. fn accessor_diagnostic() -> BrainTraitDiagnostic { - build_diagnostic(DiagnosticInput { + build_diagnostic(&DiagnosticInput { name: "Qux", required: 10, default: 5, diff --git a/common/src/brain_trait_metrics/evaluation.rs b/common/src/brain_trait_metrics/evaluation.rs index fa887ff2..555d7820 100644 --- a/common/src/brain_trait_metrics/evaluation.rs +++ b/common/src/brain_trait_metrics/evaluation.rs @@ -15,9 +15,11 @@ //! the full design rationale. use super::TraitMetrics; - pub use super::diagnostic::{ - BrainTraitDiagnostic, format_decomposition_note, format_help, format_note, + BrainTraitDiagnostic, + format_decomposition_note, + format_help, + format_note, format_primary_message, }; @@ -68,9 +70,7 @@ pub enum BrainTraitDisposition { /// ``` /// use whitaker_common::brain_trait_metrics::evaluation::BrainTraitThresholdsBuilder; /// -/// let thresholds = BrainTraitThresholdsBuilder::new() -/// .methods_warn(25) -/// .build(); +/// let thresholds = BrainTraitThresholdsBuilder::new().methods_warn(25).build(); /// assert_eq!(thresholds.methods_warn(), 25); /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -84,22 +84,16 @@ impl BrainTraitThresholds { /// Total method count at or above which the warn rule's method /// condition is met. #[must_use] - pub fn methods_warn(&self) -> usize { - self.methods_warn - } + pub const fn methods_warn(&self) -> usize { self.methods_warn } /// Total method count at or above which the deny rule triggers. #[must_use] - pub fn methods_deny(&self) -> usize { - self.methods_deny - } + pub const fn methods_deny(&self) -> usize { self.methods_deny } /// Default method CC sum at or above which the warn rule's /// complexity condition is met. #[must_use] - pub fn default_cc_warn(&self) -> usize { - self.default_cc_warn - } + pub const fn default_cc_warn(&self) -> usize { self.default_cc_warn } } // --------------------------------------------------------------------------- @@ -138,7 +132,7 @@ pub struct BrainTraitThresholdsBuilder { impl BrainTraitThresholdsBuilder { /// Creates a builder with all thresholds set to their defaults. #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { methods_warn: DEFAULT_METHODS_WARN, methods_deny: DEFAULT_METHODS_DENY, @@ -148,28 +142,28 @@ impl BrainTraitThresholdsBuilder { /// Sets the method count warn threshold. #[must_use] - pub fn methods_warn(mut self, value: usize) -> Self { + pub const fn methods_warn(mut self, value: usize) -> Self { self.methods_warn = value; self } /// Sets the method count deny threshold. #[must_use] - pub fn methods_deny(mut self, value: usize) -> Self { + pub const fn methods_deny(mut self, value: usize) -> Self { self.methods_deny = value; self } /// Sets the default method CC sum warn threshold. #[must_use] - pub fn default_cc_warn(mut self, value: usize) -> Self { + pub const fn default_cc_warn(mut self, value: usize) -> Self { self.default_cc_warn = value; self } /// Consumes the builder and returns the completed thresholds. #[must_use] - pub fn build(self) -> BrainTraitThresholds { + pub const fn build(self) -> BrainTraitThresholds { BrainTraitThresholds { methods_warn: self.methods_warn, methods_deny: self.methods_deny, @@ -179,9 +173,7 @@ impl BrainTraitThresholdsBuilder { } impl Default for BrainTraitThresholdsBuilder { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- @@ -190,19 +182,19 @@ impl Default for BrainTraitThresholdsBuilder { /// Computes total method count (required + default), excluding /// associated types and consts. -fn total_method_count(metrics: &TraitMetrics) -> usize { +const fn total_method_count(metrics: &TraitMetrics) -> usize { metrics.required_method_count() + metrics.default_method_count() } /// Returns `true` when the deny condition holds (OR-based). #[must_use] -fn is_deny_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { +const fn is_deny_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { total_method_count(metrics) >= thresholds.methods_deny } /// Returns `true` when all warn conditions hold simultaneously (AND-based). #[must_use] -fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { +const fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { total_method_count(metrics) >= thresholds.methods_warn && metrics.default_method_cc_sum() >= thresholds.default_cc_warn } @@ -219,10 +211,10 @@ fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) /// # Examples /// /// ``` -/// use whitaker_common::brain_trait_metrics::evaluation::{ -/// BrainTraitThresholdsBuilder, evaluate_brain_trait, +/// use whitaker_common::brain_trait_metrics::{ +/// TraitMetricsBuilder, +/// evaluation::{BrainTraitThresholdsBuilder, evaluate_brain_trait}, /// }; -/// use whitaker_common::brain_trait_metrics::TraitMetricsBuilder; /// /// let thresholds = BrainTraitThresholdsBuilder::new().build(); /// let metrics = TraitMetricsBuilder::new("Safe").build(); @@ -233,7 +225,7 @@ fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) /// ); /// ``` #[must_use] -pub fn evaluate_brain_trait( +pub const fn evaluate_brain_trait( metrics: &TraitMetrics, thresholds: &BrainTraitThresholds, ) -> BrainTraitDisposition { diff --git a/common/src/brain_trait_metrics/evaluation_tests.rs b/common/src/brain_trait_metrics/evaluation_tests.rs index 8cf779f4..d591b337 100644 --- a/common/src/brain_trait_metrics/evaluation_tests.rs +++ b/common/src/brain_trait_metrics/evaluation_tests.rs @@ -1,8 +1,9 @@ //! Unit tests for brain trait threshold evaluation. +use rstest::rstest; + use super::*; use crate::brain_trait_metrics::TraitMetricsBuilder; -use rstest::rstest; // --------------------------------------------------------------------------- // Helper: build TraitMetrics with the desired shape @@ -116,7 +117,7 @@ fn builder_default_trait_matches_new() { #[case("high CC but few methods", 3, 2, 10)] #[case("at methods_warn but CC below threshold", 15, 5, 1)] fn evaluate_pass_cases( - #[case] _label: &str, + #[case] label: &str, #[case] required: usize, #[case] default: usize, #[case] cc_per_default: usize, @@ -125,7 +126,8 @@ fn evaluate_pass_cases( let thresholds = BrainTraitThresholdsBuilder::new().build(); assert_eq!( evaluate_brain_trait(&metrics, &thresholds), - BrainTraitDisposition::Pass + BrainTraitDisposition::Pass, + "case: {label}" ); } @@ -149,7 +151,7 @@ fn pass_when_at_methods_warn_but_cc_one_below() { #[case("above warn below deny", 15, 10, 6)] #[case("just below methods_deny", 19, 10, 4)] fn evaluate_warn_cases( - #[case] _label: &str, + #[case] label: &str, #[case] required: usize, #[case] default: usize, #[case] cc_per_default: usize, @@ -158,7 +160,8 @@ fn evaluate_warn_cases( let thresholds = BrainTraitThresholdsBuilder::new().build(); assert_eq!( evaluate_brain_trait(&metrics, &thresholds), - BrainTraitDisposition::Warn + BrainTraitDisposition::Warn, + "case: {label}" ); } @@ -171,7 +174,7 @@ fn evaluate_warn_cases( #[case("method count above deny", 25, 10, 0)] #[case("deny supersedes warn", 20, 10, 5)] fn evaluate_deny_cases( - #[case] _label: &str, + #[case] label: &str, #[case] required: usize, #[case] default: usize, #[case] cc_per_default: usize, @@ -180,7 +183,8 @@ fn evaluate_deny_cases( let thresholds = BrainTraitThresholdsBuilder::new().build(); assert_eq!( evaluate_brain_trait(&metrics, &thresholds), - BrainTraitDisposition::Deny + BrainTraitDisposition::Deny, + "case: {label}" ); } @@ -208,14 +212,18 @@ fn evaluate_deny_cases( BrainTraitDisposition::Warn )] fn custom_threshold_overrides( - #[case] _label: &str, + #[case] label: &str, #[case] shape: (usize, usize, usize), #[case] thresholds: BrainTraitThresholds, #[case] expected: BrainTraitDisposition, ) { let (required, default, cc_per_default) = shape; let metrics = build_trait_metrics("Custom", required, default, cc_per_default); - assert_eq!(evaluate_brain_trait(&metrics, &thresholds), expected); + assert_eq!( + evaluate_brain_trait(&metrics, &thresholds), + expected, + "case: {label}" + ); } // --------------------------------------------------------------------------- diff --git a/common/src/brain_trait_metrics/item.rs b/common/src/brain_trait_metrics/item.rs index eff9fb77..81982107 100644 --- a/common/src/brain_trait_metrics/item.rs +++ b/common/src/brain_trait_metrics/item.rs @@ -132,9 +132,7 @@ impl TraitItemMetrics { /// assert_eq!(item.name(), "parse"); /// ``` #[must_use] - pub fn name(&self) -> &str { - &self.name - } + pub fn name(&self) -> &str { &self.name } /// Returns the trait item kind. /// @@ -147,9 +145,7 @@ impl TraitItemMetrics { /// assert_eq!(item.kind(), TraitItemKind::RequiredMethod); /// ``` #[must_use] - pub fn kind(&self) -> TraitItemKind { - self.kind - } + pub const fn kind(&self) -> TraitItemKind { self.kind } /// Returns default method cognitive complexity when present. /// @@ -162,9 +158,7 @@ impl TraitItemMetrics { /// assert_eq!(item.default_method_cc(), Some(9)); /// ``` #[must_use] - pub fn default_method_cc(&self) -> Option { - self.default_method_cc - } + pub const fn default_method_cc(&self) -> Option { self.default_method_cc } /// Returns `true` when this item is a required method. /// @@ -177,9 +171,7 @@ impl TraitItemMetrics { /// assert!(item.is_required_method()); /// ``` #[must_use] - pub fn is_required_method(&self) -> bool { - self.kind == TraitItemKind::RequiredMethod - } + pub fn is_required_method(&self) -> bool { self.kind == TraitItemKind::RequiredMethod } /// Returns `true` when this item is a default method. /// @@ -192,9 +184,7 @@ impl TraitItemMetrics { /// assert!(item.is_default_method()); /// ``` #[must_use] - pub fn is_default_method(&self) -> bool { - self.kind == TraitItemKind::DefaultMethod - } + pub fn is_default_method(&self) -> bool { self.kind == TraitItemKind::DefaultMethod } } /// Returns the total number of trait items. @@ -211,9 +201,7 @@ impl TraitItemMetrics { /// assert_eq!(trait_item_count(&items), 2); /// ``` #[must_use] -pub fn trait_item_count(items: &[TraitItemMetrics]) -> usize { - items.len() -} +pub const fn trait_item_count(items: &[TraitItemMetrics]) -> usize { items.len() } /// Returns the number of required methods. /// diff --git a/common/src/brain_trait_metrics/metrics.rs b/common/src/brain_trait_metrics/metrics.rs index 62489260..91c98a42 100644 --- a/common/src/brain_trait_metrics/metrics.rs +++ b/common/src/brain_trait_metrics/metrics.rs @@ -40,9 +40,7 @@ impl TraitMetrics { /// assert_eq!(metrics.trait_name(), "Parser"); /// ``` #[must_use] - pub fn trait_name(&self) -> &str { - &self.trait_name - } + pub fn trait_name(&self) -> &str { &self.trait_name } /// Returns the total number of trait items. /// @@ -56,9 +54,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().total_item_count(), 1); /// ``` #[must_use] - pub fn total_item_count(&self) -> usize { - self.total_item_count - } + pub const fn total_item_count(&self) -> usize { self.total_item_count } /// Returns the number of required methods. /// @@ -72,9 +68,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().required_method_count(), 1); /// ``` #[must_use] - pub fn required_method_count(&self) -> usize { - self.required_method_count - } + pub const fn required_method_count(&self) -> usize { self.required_method_count } /// Returns the number of default methods. /// @@ -88,9 +82,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().default_method_count(), 1); /// ``` #[must_use] - pub fn default_method_count(&self) -> usize { - self.default_method_count - } + pub const fn default_method_count(&self) -> usize { self.default_method_count } /// Returns the sum of default method cognitive complexity values. /// @@ -105,9 +97,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().default_method_cc_sum(), 12); /// ``` #[must_use] - pub fn default_method_cc_sum(&self) -> usize { - self.default_method_cc_sum - } + pub const fn default_method_cc_sum(&self) -> usize { self.default_method_cc_sum } /// Returns implementor burden as the required method count. /// @@ -122,9 +112,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().implementor_burden(), 2); /// ``` #[must_use] - pub fn implementor_burden(&self) -> usize { - self.required_method_count - } + pub const fn implementor_burden(&self) -> usize { self.required_method_count } } /// Incremental builder for [`TraitMetrics`]. @@ -164,9 +152,7 @@ impl TraitMetricsBuilder { /// builder.add_item(TraitItemMetrics::required_method("parse")); /// assert_eq!(builder.build().required_method_count(), 1); /// ``` - pub fn add_item(&mut self, item: TraitItemMetrics) { - self.items.push(item); - } + pub fn add_item(&mut self, item: TraitItemMetrics) { self.items.push(item); } /// Adds a required method. /// @@ -255,9 +241,7 @@ impl TraitMetricsBuilder { /// assert!(TraitMetricsBuilder::new("Parser").is_empty()); /// ``` #[must_use] - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } + pub const fn is_empty(&self) -> bool { self.items.is_empty() } /// Consumes the builder and returns aggregated trait metrics. /// @@ -277,44 +261,21 @@ impl TraitMetricsBuilder { /// ``` #[must_use] pub fn build(self) -> TraitMetrics { - let (total_item_count, required_method_count, default_method_count, default_method_cc_sum) = - self.items.iter().fold( - (0, 0, 0, 0), - |( - total_item_count, - required_method_count, - default_method_count, - default_method_cc_sum, - ), - item| { - let total_item_count = total_item_count + 1; - let (required_method_count, default_method_count, default_method_cc_sum) = - match item.kind() { - TraitItemKind::RequiredMethod => ( - required_method_count + 1, - default_method_count, - default_method_cc_sum, - ), - TraitItemKind::DefaultMethod => ( - required_method_count, - default_method_count + 1, - default_method_cc_sum + item.default_method_cc().unwrap_or(0), - ), - TraitItemKind::AssociatedType | TraitItemKind::AssociatedConst => ( - required_method_count, - default_method_count, - default_method_cc_sum, - ), - }; + let total_item_count = self.items.len(); + let mut required_method_count = 0; + let mut default_method_count = 0; + let mut default_method_cc_sum = 0; - ( - total_item_count, - required_method_count, - default_method_count, - default_method_cc_sum, - ) - }, - ); + for item in &self.items { + match item.kind() { + TraitItemKind::RequiredMethod => required_method_count += 1, + TraitItemKind::DefaultMethod => { + default_method_count += 1; + default_method_cc_sum += item.default_method_cc().unwrap_or(0); + } + TraitItemKind::AssociatedType | TraitItemKind::AssociatedConst => {} + } + } TraitMetrics { trait_name: self.trait_name, diff --git a/common/src/brain_trait_metrics/mod.rs b/common/src/brain_trait_metrics/mod.rs index e0790c74..2ee48f83 100644 --- a/common/src/brain_trait_metrics/mod.rs +++ b/common/src/brain_trait_metrics/mod.rs @@ -24,11 +24,21 @@ mod metrics; mod tests; pub use evaluation::{ - BrainTraitDiagnostic, BrainTraitDisposition, BrainTraitThresholds, BrainTraitThresholdsBuilder, - evaluate_brain_trait, format_help, format_note, format_primary_message, + BrainTraitDiagnostic, + BrainTraitDisposition, + BrainTraitThresholds, + BrainTraitThresholdsBuilder, + evaluate_brain_trait, + format_help, + format_note, + format_primary_message, }; pub use item::{ - TraitItemKind, TraitItemMetrics, default_method_cc_sum, default_method_count, - required_method_count, trait_item_count, + TraitItemKind, + TraitItemMetrics, + default_method_cc_sum, + default_method_count, + required_method_count, + trait_item_count, }; pub use metrics::{TraitMetrics, TraitMetricsBuilder}; diff --git a/common/src/brain_trait_metrics/tests.rs b/common/src/brain_trait_metrics/tests.rs index e5e03328..54b46ce8 100644 --- a/common/src/brain_trait_metrics/tests.rs +++ b/common/src/brain_trait_metrics/tests.rs @@ -1,8 +1,9 @@ //! Unit tests for brain trait metric collection. -use super::*; use rstest::rstest; +use super::*; + fn mixed_items() -> Vec { vec![ TraitItemMetrics::required_method("parse"), @@ -28,7 +29,7 @@ struct ExpectedTraitMetrics { burden: usize, } -fn assert_trait_metrics(metrics: &TraitMetrics, expected: ExpectedTraitMetrics) { +fn assert_trait_metrics(metrics: &TraitMetrics, expected: &ExpectedTraitMetrics) { assert_eq!(metrics.trait_name(), expected.name); assert_eq!(metrics.total_item_count(), expected.total); assert_eq!(metrics.required_method_count(), expected.required); @@ -96,7 +97,7 @@ fn default_method_cc_sum_aggregates_only_default_methods() { let items = vec![ TraitItemMetrics::required_method("parse"), TraitItemMetrics::default_method("render", 12), - TraitItemMetrics::default_method("serialise", 8), + TraitItemMetrics::default_method("serialize", 8), TraitItemMetrics::associated_type("Output"), ]; @@ -132,7 +133,7 @@ fn builder_builds_mixed_trait_metrics() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "Parser", total: 4, required: 1, @@ -153,7 +154,7 @@ fn builder_add_item_supports_prebuilt_entries() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "Renderer", total: 2, required: 1, @@ -175,7 +176,7 @@ fn builder_filters_macro_expanded_default_methods() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "Parser", total: 2, required: 1, @@ -191,7 +192,7 @@ fn implementor_burden_equals_required_method_count() { let mut builder = TraitMetricsBuilder::new("Transformer"); builder.add_required_method("parse"); builder.add_required_method("validate"); - builder.add_default_method("normalise", 7, false); + builder.add_default_method("normalize", 7, false); builder.add_associated_type("Output"); let metrics = builder.build(); @@ -206,7 +207,7 @@ fn empty_trait_has_zeroed_metrics() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "EmptyTrait", total: 0, required: 0, diff --git a/common/src/brain_type_metrics/cognitive_complexity.rs b/common/src/brain_type_metrics/cognitive_complexity.rs index 117de893..28a5f0d9 100644 --- a/common/src/brain_type_metrics/cognitive_complexity.rs +++ b/common/src/brain_type_metrics/cognitive_complexity.rs @@ -18,7 +18,7 @@ #[path = "cognitive_complexity_tests.rs"] mod tests; -/// Incrementally computes cognitive complexity following SonarSource +/// Incrementally computes cognitive complexity following `SonarSource` /// rules, with macro-expansion filtering. /// /// The HIR walker calls builder methods for each relevant node, @@ -28,12 +28,11 @@ mod tests; /// /// # Three increment categories /// -/// - **Structural** (+1): `if`, `else if`, `else`, `match`, `for`, -/// `while`, `loop`, `?` operator, catch-equivalent constructs. -/// - **Nesting** (+effective_depth): applied alongside structural for -/// constructs that also incur a nesting penalty. -/// - **Fundamental** (+1): boolean operator sequence breaks (`&&`, -/// `||`). +/// - **Structural** (+1): `if`, `else if`, `else`, `match`, `for`, `while`, `loop`, `?` operator, +/// catch-equivalent constructs. +/// - **Nesting** (+`effective_depth)`: applied alongside structural for constructs that also incur +/// a nesting penalty. +/// - **Fundamental** (+1): boolean operator sequence breaks (`&&`, `||`). /// /// # Examples /// @@ -42,12 +41,12 @@ mod tests; /// /// let mut cc = CognitiveComplexityBuilder::new(); /// // Simulate: if condition { ... } -/// cc.record_structural_increment(false); // +1 -/// cc.record_nesting_increment(false); // +0 (depth is 0) +/// cc.record_structural_increment(false); // +1 +/// cc.record_nesting_increment(false); // +0 (depth is 0) /// cc.push_nesting(false); /// // Simulate: nested if { ... } -/// cc.record_structural_increment(false); // +1 -/// cc.record_nesting_increment(false); // +1 (depth is 1) +/// cc.record_structural_increment(false); // +1 +/// cc.record_nesting_increment(false); // +1 (depth is 1) /// cc.push_nesting(false); /// cc.pop_nesting(); /// cc.pop_nesting(); @@ -77,7 +76,7 @@ impl CognitiveComplexityBuilder { /// assert_eq!(cc.effective_depth(), 0); /// ``` #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { score: 0, nesting_stack: Vec::new(), @@ -104,13 +103,13 @@ impl CognitiveComplexityBuilder { /// cc.record_structural_increment(true); // macro — filtered /// assert_eq!(cc.score(), 1); /// ``` - pub fn record_structural_increment(&mut self, is_from_expansion: bool) { + pub const fn record_structural_increment(&mut self, is_from_expansion: bool) { if !is_from_expansion { self.score += 1; } } - /// Records a nesting increment (+effective_depth). + /// Records a nesting increment (+`effective_depth`). /// /// Called alongside [`record_structural_increment`](Self::record_structural_increment) /// for constructs that also incur a nesting penalty (e.g. `if`, @@ -130,7 +129,7 @@ impl CognitiveComplexityBuilder { /// cc.pop_nesting(); /// assert_eq!(cc.build(), 1); /// ``` - pub fn record_nesting_increment(&mut self, is_from_expansion: bool) { + pub const fn record_nesting_increment(&mut self, is_from_expansion: bool) { if !is_from_expansion { self.score += self.effective_depth; } @@ -155,7 +154,7 @@ impl CognitiveComplexityBuilder { /// cc.record_fundamental_increment(true); // macro — filtered /// assert_eq!(cc.score(), 1); /// ``` - pub fn record_fundamental_increment(&mut self, is_from_expansion: bool) { + pub const fn record_fundamental_increment(&mut self, is_from_expansion: bool) { if !is_from_expansion { self.score += 1; } @@ -224,9 +223,7 @@ impl CognitiveComplexityBuilder { /// cc.pop_nesting(); /// ``` #[must_use] - pub fn effective_depth(&self) -> usize { - self.effective_depth - } + pub const fn effective_depth(&self) -> usize { self.effective_depth } /// Returns the accumulated cognitive complexity score so far. /// @@ -240,9 +237,7 @@ impl CognitiveComplexityBuilder { /// assert_eq!(cc.score(), 1); /// ``` #[must_use] - pub fn score(&self) -> usize { - self.score - } + pub const fn score(&self) -> usize { self.score } /// Consumes the builder and returns the final complexity score. /// @@ -271,7 +266,5 @@ impl CognitiveComplexityBuilder { } impl Default for CognitiveComplexityBuilder { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } diff --git a/common/src/brain_type_metrics/cognitive_complexity_tests.rs b/common/src/brain_type_metrics/cognitive_complexity_tests.rs index b9eb59c5..9e8a3251 100644 --- a/common/src/brain_type_metrics/cognitive_complexity_tests.rs +++ b/common/src/brain_type_metrics/cognitive_complexity_tests.rs @@ -1,8 +1,9 @@ //! Unit tests for [`super::CognitiveComplexityBuilder`]. -use super::*; use rstest::rstest; +use super::*; + // --------------------------------------------------------------------------- // Individual increment types — non-expansion // --------------------------------------------------------------------------- @@ -151,10 +152,10 @@ fn mixed_expansion_nesting() { /// Parameterized composite scenarios. Each case simulates a code /// pattern and asserts the expected CC score. /// -/// Parameters: `(label, expected_score)` +/// Parameters: `(expected_score)` #[rstest] -#[case("simple_if", 1)] -fn composite_simple_if(#[case] _label: &str, #[case] expected: usize) { +#[case(1)] +fn composite_simple_if(#[case] expected: usize) { // if cond {} => structural +1, nesting +0 (depth 0) let mut cc = CognitiveComplexityBuilder::new(); cc.record_structural_increment(false); @@ -165,8 +166,8 @@ fn composite_simple_if(#[case] _label: &str, #[case] expected: usize) { } #[rstest] -#[case("nested_if_in_if", 3)] -fn composite_nested_if(#[case] _label: &str, #[case] expected: usize) { +#[case(3)] +fn composite_nested_if(#[case] expected: usize) { // if { if {} } // outer: struct +1, nest +0 (depth 0) // inner: struct +1, nest +1 (depth 1) @@ -183,8 +184,8 @@ fn composite_nested_if(#[case] _label: &str, #[case] expected: usize) { } #[rstest] -#[case("if_with_boolean_ops", 3)] -fn composite_if_with_boolean_ops(#[case] _label: &str, #[case] expected: usize) { +#[case(3)] +fn composite_if_with_boolean_ops(#[case] expected: usize) { // if a && b || c {} // structural +1, fundamental +1 (&&), fundamental +1 (||) let mut cc = CognitiveComplexityBuilder::new(); @@ -197,8 +198,8 @@ fn composite_if_with_boolean_ops(#[case] _label: &str, #[case] expected: usize) } #[rstest] -#[case("triple_nested_loop", 6)] -fn composite_triple_nested_loop(#[case] _label: &str, #[case] expected: usize) { +#[case(6)] +fn composite_triple_nested_loop(#[case] expected: usize) { // for { for { for {} } } // L1: struct +1, nest +0 (depth 0), push => depth 1 // L2: struct +1, nest +1 (depth 1), push => depth 2 @@ -220,8 +221,8 @@ fn composite_triple_nested_loop(#[case] _label: &str, #[case] expected: usize) { } #[rstest] -#[case("macro_if_inside_real_for", 1)] -fn composite_macro_if_inside_real_for(#[case] _label: &str, #[case] expected: usize) { +#[case(1)] +fn composite_macro_if_inside_real_for(#[case] expected: usize) { // for { MACRO_IF } // for: struct +1, nest +0, push(false) => depth 1 // macro if: struct(true) skipped, nest(true) skipped @@ -238,8 +239,8 @@ fn composite_macro_if_inside_real_for(#[case] _label: &str, #[case] expected: us } #[rstest] -#[case("real_if_inside_macro_for", 1)] -fn composite_real_if_inside_macro_for(#[case] _label: &str, #[case] expected: usize) { +#[case(1)] +fn composite_real_if_inside_macro_for(#[case] expected: usize) { // MACRO_FOR { if {} } // macro for: struct(true) skipped, push(true) => eff depth 0 // real if: struct(false) +1, nest(false) +0 (eff depth is 0) @@ -285,7 +286,8 @@ fn default_matches_new() { fn build_panics_on_unbalanced_stack() { let mut cc = CognitiveComplexityBuilder::new(); cc.push_nesting(false); - let _ = cc.build(); + // The build call should panic before yielding a score. + let _score = cc.build(); } #[rstest] diff --git a/common/src/brain_type_metrics/diagnostic.rs b/common/src/brain_type_metrics/diagnostic.rs index 95f21bcb..66fa07b6 100644 --- a/common/src/brain_type_metrics/diagnostic.rs +++ b/common/src/brain_type_metrics/diagnostic.rs @@ -7,12 +7,12 @@ //! See `docs/brain-trust-lints-design.md` §Diagnostic output for the //! full format specification. -use std::fmt::Write; - -use super::evaluation::BrainTypeDisposition; -use super::{MethodMetrics, TypeMetrics}; +use super::{MethodMetrics, TypeMetrics, evaluation::BrainTypeDisposition}; use crate::decomposition_advice::{ - DecompositionContext, DecompositionSuggestion, SubjectKind, format_diagnostic_note, + DecompositionContext, + DecompositionSuggestion, + SubjectKind, + format_diagnostic_note, }; #[cfg(test)] @@ -31,10 +31,10 @@ mod tests; /// # Examples /// /// ``` -/// use whitaker_common::brain_type_metrics::evaluation::{ -/// BrainTypeDiagnostic, BrainTypeDisposition, +/// use whitaker_common::brain_type_metrics::{ +/// TypeMetricsBuilder, +/// evaluation::{BrainTypeDiagnostic, BrainTypeDisposition}, /// }; -/// use whitaker_common::brain_type_metrics::TypeMetricsBuilder; /// /// let metrics = TypeMetricsBuilder::new("Foo", 25, 80).build(); /// let diag = BrainTypeDiagnostic::new(&metrics, BrainTypeDisposition::Pass); @@ -66,39 +66,27 @@ impl BrainTypeDiagnostic { /// Returns the type name. #[must_use] - pub fn type_name(&self) -> &str { - &self.type_name - } + pub fn type_name(&self) -> &str { &self.type_name } /// Returns the evaluation disposition. #[must_use] - pub fn disposition(&self) -> BrainTypeDisposition { - self.disposition - } + pub const fn disposition(&self) -> BrainTypeDisposition { self.disposition } /// Returns the Weighted Methods Count. #[must_use] - pub fn wmc(&self) -> usize { - self.wmc - } + pub const fn wmc(&self) -> usize { self.wmc } /// Returns the LCOM4 connected component count. #[must_use] - pub fn lcom4(&self) -> usize { - self.lcom4 - } + pub const fn lcom4(&self) -> usize { self.lcom4 } /// Returns the foreign reach count. #[must_use] - pub fn foreign_reach(&self) -> usize { - self.foreign_reach - } + pub const fn foreign_reach(&self) -> usize { self.foreign_reach } /// Returns brain methods with their full metric details. #[must_use] - pub fn brain_methods(&self) -> &[MethodMetrics] { - &self.brain_methods - } + pub fn brain_methods(&self) -> &[MethodMetrics] { &self.brain_methods } } // --------------------------------------------------------------------------- @@ -116,10 +104,10 @@ impl BrainTypeDiagnostic { /// # Examples /// /// ``` -/// use whitaker_common::brain_type_metrics::evaluation::{ -/// BrainTypeDiagnostic, BrainTypeDisposition, format_primary_message, +/// use whitaker_common::brain_type_metrics::{ +/// TypeMetricsBuilder, +/// evaluation::{BrainTypeDiagnostic, BrainTypeDisposition, format_primary_message}, /// }; -/// use whitaker_common::brain_type_metrics::TypeMetricsBuilder; /// /// let mut builder = TypeMetricsBuilder::new("Foo", 25, 80); /// builder.add_method("parse", 31, 140); @@ -159,8 +147,8 @@ fn format_primary_with_one_brain_method( let lcom4 = diagnostic.lcom4(); let fr_suffix = foreign_reach_suffix(diagnostic); format!( - "`{name}` has WMC={wmc}, LCOM4={lcom4}{fr_suffix}, \ - and a brain method `{}` (CC={}, LOC={}).", + "`{name}` has WMC={wmc}, LCOM4={lcom4}{fr_suffix}, and a brain method `{}` (CC={}, \ + LOC={}).", bm.name(), bm.cognitive_complexity(), bm.lines_of_code(), @@ -177,25 +165,21 @@ fn format_primary_with_many_brain_methods( let lcom4 = diagnostic.lcom4(); let fr_suffix = foreign_reach_suffix(diagnostic); let n = methods.len(); - let mut msg = format!( - "`{name}` has WMC={wmc}, LCOM4={lcom4}{fr_suffix}, \ - and {n} brain methods: ", - ); - for (i, bm) in methods.iter().enumerate() { - if i > 0 { - msg.push_str(", "); - } - // Write cannot fail on String. - let _ = write!( - msg, - "`{}` (CC={}, LOC={})", - bm.name(), - bm.cognitive_complexity(), - bm.lines_of_code(), - ); - } - msg.push('.'); - msg + let method_list = methods + .iter() + .map(|bm| { + format!( + "`{}` (CC={}, LOC={})", + bm.name(), + bm.cognitive_complexity(), + bm.lines_of_code(), + ) + }) + .collect::>() + .join(", "); + format!( + "`{name}` has WMC={wmc}, LCOM4={lcom4}{fr_suffix}, and {n} brain methods: {method_list}.", + ) } /// Returns the foreign reach suffix for the primary message, or an @@ -217,10 +201,10 @@ fn foreign_reach_suffix(diagnostic: &BrainTypeDiagnostic) -> String { /// # Examples /// /// ``` -/// use whitaker_common::brain_type_metrics::evaluation::{ -/// BrainTypeDiagnostic, BrainTypeDisposition, format_note, +/// use whitaker_common::brain_type_metrics::{ +/// TypeMetricsBuilder, +/// evaluation::{BrainTypeDiagnostic, BrainTypeDisposition, format_note}, /// }; -/// use whitaker_common::brain_type_metrics::TypeMetricsBuilder; /// /// let metrics = TypeMetricsBuilder::new("Foo", 25, 80).build(); /// let diag = BrainTypeDiagnostic::new(&metrics, BrainTypeDisposition::Pass); @@ -229,24 +213,26 @@ fn foreign_reach_suffix(diagnostic: &BrainTypeDiagnostic) -> String { /// ``` #[must_use] pub fn format_note(diagnostic: &BrainTypeDiagnostic) -> String { - let mut note = String::from("WMC measures total cognitive complexity across all methods."); + let mut sentences = vec![String::from( + "WMC measures total cognitive complexity across all methods.", + )]; if !diagnostic.brain_methods().is_empty() { - note.push_str(" Brain methods are methods with high complexity and size."); + sentences.push(String::from( + "Brain methods are methods with high complexity and size.", + )); } if diagnostic.lcom4() >= 2 { - note.push_str( - " LCOM4 >= 2 indicates the type has multiple unrelated \ - responsibilities.", - ); + sentences.push(String::from( + "LCOM4 >= 2 indicates the type has multiple unrelated responsibilities.", + )); } if diagnostic.foreign_reach() > 0 { - let _ = write!( - note, - " Foreign reach of {} indicates coupling to external modules.", + sentences.push(format!( + "Foreign reach of {} indicates coupling to external modules.", diagnostic.foreign_reach(), - ); + )); } - note + sentences.join(" ") } /// Formats a decomposition note from precomputed community suggestions. @@ -256,10 +242,10 @@ pub fn format_note(diagnostic: &BrainTypeDiagnostic) -> String { /// # Examples /// /// ``` -/// use whitaker_common::brain_type_metrics::evaluation::{ -/// BrainTypeDiagnostic, BrainTypeDisposition, format_decomposition_note, +/// use whitaker_common::brain_type_metrics::{ +/// TypeMetricsBuilder, +/// evaluation::{BrainTypeDiagnostic, BrainTypeDisposition, format_decomposition_note}, /// }; -/// use whitaker_common::brain_type_metrics::TypeMetricsBuilder; /// /// let metrics = TypeMetricsBuilder::new("Foo", 25, 80).build(); /// let diagnostic = BrainTypeDiagnostic::new(&metrics, BrainTypeDisposition::Pass); @@ -286,10 +272,10 @@ pub fn format_decomposition_note( /// # Examples /// /// ``` -/// use whitaker_common::brain_type_metrics::evaluation::{ -/// BrainTypeDiagnostic, BrainTypeDisposition, format_help, +/// use whitaker_common::brain_type_metrics::{ +/// TypeMetricsBuilder, +/// evaluation::{BrainTypeDiagnostic, BrainTypeDisposition, format_help}, /// }; -/// use whitaker_common::brain_type_metrics::TypeMetricsBuilder; /// /// let metrics = TypeMetricsBuilder::new("Foo", 25, 80).build(); /// let diag = BrainTypeDiagnostic::new(&metrics, BrainTypeDisposition::Pass); @@ -312,8 +298,8 @@ pub fn format_help(diagnostic: &BrainTypeDiagnostic) -> String { if parts.is_empty() { return String::from( - "Consider extracting related methods into separate types or \ - modules to reduce complexity and improve cohesion.", + "Consider extracting related methods into separate types or modules to reduce \ + complexity and improve cohesion.", ); } diff --git a/common/src/brain_type_metrics/diagnostic_tests.rs b/common/src/brain_type_metrics/diagnostic_tests.rs index 12cfeae3..3caf610f 100644 --- a/common/src/brain_type_metrics/diagnostic_tests.rs +++ b/common/src/brain_type_metrics/diagnostic_tests.rs @@ -1,12 +1,14 @@ //! Unit tests for brain type diagnostic formatting. -use super::*; -use crate::brain_type_metrics::TypeMetricsBuilder; -use crate::brain_type_metrics::evaluation::BrainTypeDisposition; -use crate::decomposition_advice::SubjectKind; -use crate::test_support::decomposition::{decomposition_suggestions, parser_serde_fs_fixture}; use rstest::rstest; +use super::*; +use crate::{ + brain_type_metrics::{TypeMetricsBuilder, evaluation::BrainTypeDisposition}, + decomposition_advice::SubjectKind, + test_support::decomposition::{decomposition_suggestions, parser_serde_fs_fixture}, +}; + // --------------------------------------------------------------------------- // Diagnostic — primary message (one brain method) // --------------------------------------------------------------------------- @@ -33,10 +35,13 @@ fn one_brain_method_message() -> String { #[case("a brain method", "singular form")] fn one_brain_method_message_contains_expected_fragment( #[case] fragment: &str, - #[case] _description: &str, + #[case] description: &str, ) { let msg = one_brain_method_message(); - assert!(msg.contains(fragment), "missing fragment: {fragment}"); + assert!( + msg.contains(fragment), + "missing {description} fragment: {fragment}" + ); } // --------------------------------------------------------------------------- @@ -329,5 +334,9 @@ fn diagnostic_brain_methods_count_accessor() { #[rstest] fn diagnostic_brain_methods_name_accessor() { let diag = accessor_diagnostic(); - assert_eq!(diag.brain_methods()[0].name(), "big"); + let brain_method = diag + .brain_methods() + .first() + .expect("diagnostic should expose one brain method"); + assert_eq!(brain_method.name(), "big"); } diff --git a/common/src/brain_type_metrics/evaluation.rs b/common/src/brain_type_metrics/evaluation.rs index 79912dec..8a3cf981 100644 --- a/common/src/brain_type_metrics/evaluation.rs +++ b/common/src/brain_type_metrics/evaluation.rs @@ -14,9 +14,11 @@ //! the full design rationale. use super::TypeMetrics; - pub use super::diagnostic::{ - BrainTypeDiagnostic, format_decomposition_note, format_help, format_note, + BrainTypeDiagnostic, + format_decomposition_note, + format_help, + format_note, format_primary_message, }; @@ -80,33 +82,23 @@ pub struct BrainTypeThresholds { impl BrainTypeThresholds { /// WMC at or above which the warn rule's WMC condition is met. #[must_use] - pub fn wmc_warn(&self) -> usize { - self.wmc_warn - } + pub const fn wmc_warn(&self) -> usize { self.wmc_warn } /// WMC at or above which the deny rule triggers (OR-based). #[must_use] - pub fn wmc_deny(&self) -> usize { - self.wmc_deny - } + pub const fn wmc_deny(&self) -> usize { self.wmc_deny } /// LCOM4 at or above which the warn rule's cohesion condition is met. #[must_use] - pub fn lcom4_warn(&self) -> usize { - self.lcom4_warn - } + pub const fn lcom4_warn(&self) -> usize { self.lcom4_warn } /// LCOM4 at or above which the deny rule triggers (OR-based). #[must_use] - pub fn lcom4_deny(&self) -> usize { - self.lcom4_deny - } + pub const fn lcom4_deny(&self) -> usize { self.lcom4_deny } /// Brain method count at or above which the deny rule triggers. #[must_use] - pub fn brain_method_deny_count(&self) -> usize { - self.brain_method_deny_count - } + pub const fn brain_method_deny_count(&self) -> usize { self.brain_method_deny_count } } // --------------------------------------------------------------------------- @@ -149,7 +141,7 @@ pub struct BrainTypeThresholdsBuilder { impl BrainTypeThresholdsBuilder { /// Creates a builder with all thresholds set to their defaults. #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { wmc_warn: DEFAULT_WMC_WARN, wmc_deny: DEFAULT_WMC_DENY, @@ -161,42 +153,42 @@ impl BrainTypeThresholdsBuilder { /// Sets the WMC warn threshold. #[must_use] - pub fn wmc_warn(mut self, value: usize) -> Self { + pub const fn wmc_warn(mut self, value: usize) -> Self { self.wmc_warn = value; self } /// Sets the WMC deny threshold. #[must_use] - pub fn wmc_deny(mut self, value: usize) -> Self { + pub const fn wmc_deny(mut self, value: usize) -> Self { self.wmc_deny = value; self } /// Sets the LCOM4 warn threshold. #[must_use] - pub fn lcom4_warn(mut self, value: usize) -> Self { + pub const fn lcom4_warn(mut self, value: usize) -> Self { self.lcom4_warn = value; self } /// Sets the LCOM4 deny threshold. #[must_use] - pub fn lcom4_deny(mut self, value: usize) -> Self { + pub const fn lcom4_deny(mut self, value: usize) -> Self { self.lcom4_deny = value; self } /// Sets the brain method count deny threshold. #[must_use] - pub fn brain_method_deny_count(mut self, value: usize) -> Self { + pub const fn brain_method_deny_count(mut self, value: usize) -> Self { self.brain_method_deny_count = value; self } /// Consumes the builder and returns the completed thresholds. #[must_use] - pub fn build(self) -> BrainTypeThresholds { + pub const fn build(self) -> BrainTypeThresholds { BrainTypeThresholds { wmc_warn: self.wmc_warn, wmc_deny: self.wmc_deny, @@ -208,9 +200,7 @@ impl BrainTypeThresholdsBuilder { } impl Default for BrainTypeThresholdsBuilder { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- @@ -219,7 +209,7 @@ impl Default for BrainTypeThresholdsBuilder { /// Returns `true` when any single deny condition holds (OR-based). #[must_use] -fn is_deny_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { +const fn is_deny_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { metrics.wmc() >= thresholds.wmc_deny || metrics.brain_method_count() >= thresholds.brain_method_deny_count || metrics.lcom4() >= thresholds.lcom4_deny @@ -227,7 +217,7 @@ fn is_deny_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> /// Returns `true` when all warn conditions hold simultaneously (AND-based). #[must_use] -fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { +const fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { metrics.wmc() >= thresholds.wmc_warn && metrics.brain_method_count() >= 1 && metrics.lcom4() >= thresholds.lcom4_warn @@ -243,10 +233,10 @@ fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> /// # Examples /// /// ``` -/// use whitaker_common::brain_type_metrics::evaluation::{ -/// BrainTypeThresholdsBuilder, evaluate_brain_type, +/// use whitaker_common::brain_type_metrics::{ +/// TypeMetricsBuilder, +/// evaluation::{BrainTypeThresholdsBuilder, evaluate_brain_type}, /// }; -/// use whitaker_common::brain_type_metrics::TypeMetricsBuilder; /// /// let thresholds = BrainTypeThresholdsBuilder::new().build(); /// let metrics = TypeMetricsBuilder::new("Safe", 25, 80).build(); @@ -257,7 +247,7 @@ fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> /// ); /// ``` #[must_use] -pub fn evaluate_brain_type( +pub const fn evaluate_brain_type( metrics: &TypeMetrics, thresholds: &BrainTypeThresholds, ) -> BrainTypeDisposition { diff --git a/common/src/brain_type_metrics/evaluation_tests.rs b/common/src/brain_type_metrics/evaluation_tests.rs index b9440471..8a4f249f 100644 --- a/common/src/brain_type_metrics/evaluation_tests.rs +++ b/common/src/brain_type_metrics/evaluation_tests.rs @@ -1,8 +1,9 @@ //! Unit tests for brain type threshold evaluation. +use rstest::rstest; + use super::*; use crate::brain_type_metrics::TypeMetricsBuilder; -use rstest::rstest; // --------------------------------------------------------------------------- // Helper: build TypeMetrics with the desired shape @@ -114,7 +115,7 @@ fn builder_default_trait_matches_new() { #[case("brain method present but cohesive (LCOM4=1)", 80, 1, 1)] #[case("brain method and low cohesion but low WMC", 30, 1, 2)] fn evaluate_pass_cases( - #[case] _label: &str, + #[case] label: &str, #[case] wmc: usize, #[case] brain_count: usize, #[case] lcom4: usize, @@ -123,7 +124,8 @@ fn evaluate_pass_cases( let thresholds = BrainTypeThresholdsBuilder::new().build(); assert_eq!( evaluate_brain_type(&metrics, &thresholds), - BrainTypeDisposition::Pass + BrainTypeDisposition::Pass, + "case: {label}" ); } @@ -136,7 +138,7 @@ fn evaluate_pass_cases( #[case("above warn below deny", 80, 1, 2)] #[case("just below WMC deny", 99, 1, 2)] fn evaluate_warn_cases( - #[case] _label: &str, + #[case] label: &str, #[case] wmc: usize, #[case] brain_count: usize, #[case] lcom4: usize, @@ -145,7 +147,8 @@ fn evaluate_warn_cases( let thresholds = BrainTypeThresholdsBuilder::new().build(); assert_eq!( evaluate_brain_type(&metrics, &thresholds), - BrainTypeDisposition::Warn + BrainTypeDisposition::Warn, + "case: {label}" ); } @@ -160,7 +163,7 @@ fn evaluate_warn_cases( #[case("deny supersedes warn", 100, 1, 3)] #[case("all deny triggers active", 100, 2, 3)] fn evaluate_deny_cases( - #[case] _label: &str, + #[case] label: &str, #[case] wmc: usize, #[case] brain_count: usize, #[case] lcom4: usize, @@ -169,7 +172,8 @@ fn evaluate_deny_cases( let thresholds = BrainTypeThresholdsBuilder::new().build(); assert_eq!( evaluate_brain_type(&metrics, &thresholds), - BrainTypeDisposition::Deny + BrainTypeDisposition::Deny, + "case: {label}" ); } @@ -227,7 +231,7 @@ fn exact_brain_method_deny_count_triggers_deny() { #[case("below custom threshold", 2, false)] #[case("at custom threshold boundary", 3, true)] fn custom_brain_method_deny_count_boundary( - #[case] _label: &str, + #[case] label: &str, #[case] brain_count: usize, #[case] should_deny: bool, ) { @@ -240,8 +244,8 @@ fn custom_brain_method_deny_count_boundary( let disposition = evaluate_brain_type(&metrics, &thresholds); if should_deny { - assert_eq!(disposition, BrainTypeDisposition::Deny); + assert_eq!(disposition, BrainTypeDisposition::Deny, "case: {label}"); } else { - assert_ne!(disposition, BrainTypeDisposition::Deny); + assert_ne!(disposition, BrainTypeDisposition::Deny, "case: {label}"); } } diff --git a/common/src/brain_type_metrics/foreign_reach.rs b/common/src/brain_type_metrics/foreign_reach.rs index 16387bc7..03d15b59 100644 --- a/common/src/brain_type_metrics/foreign_reach.rs +++ b/common/src/brain_type_metrics/foreign_reach.rs @@ -50,9 +50,7 @@ impl ForeignReferenceSet { /// assert!(refs.is_empty()); /// ``` #[must_use] - pub fn new() -> Self { - Self::default() - } + pub fn new() -> Self { Self::default() } /// Records a reference to an external module or type path. /// @@ -67,7 +65,7 @@ impl ForeignReferenceSet { /// use whitaker_common::brain_type_metrics::ForeignReferenceSet; /// /// let mut refs = ForeignReferenceSet::new(); - /// refs.record_reference("std::fmt", true); // macro — filtered + /// refs.record_reference("std::fmt", true); // macro — filtered /// refs.record_reference("serde::Serialize", false); /// /// assert_eq!(refs.count(), 1); @@ -90,9 +88,7 @@ impl ForeignReferenceSet { /// assert_eq!(refs.count(), 1); /// ``` #[must_use] - pub fn count(&self) -> usize { - self.references.len() - } + pub fn count(&self) -> usize { self.references.len() } /// Returns `true` when no references have been recorded. /// @@ -105,9 +101,7 @@ impl ForeignReferenceSet { /// assert!(refs.is_empty()); /// ``` #[must_use] - pub fn is_empty(&self) -> bool { - self.references.is_empty() - } + pub fn is_empty(&self) -> bool { self.references.is_empty() } /// Returns the set of recorded references, for diagnostic display. /// @@ -121,9 +115,7 @@ impl ForeignReferenceSet { /// assert!(refs.references().contains("std::io")); /// ``` #[must_use] - pub fn references(&self) -> &BTreeSet { - &self.references - } + pub const fn references(&self) -> &BTreeSet { &self.references } } /// Counts distinct foreign references from an iterator of @@ -140,9 +132,9 @@ impl ForeignReferenceSet { /// /// let refs = vec![ /// ("std::io".into(), false), -/// ("std::io".into(), false), // duplicate +/// ("std::io".into(), false), // duplicate /// ("serde::de".into(), false), -/// ("macro_gen".into(), true), // macro — filtered +/// ("macro_gen".into(), true), // macro — filtered /// ]; /// assert_eq!(foreign_reach_count(refs), 2); /// ``` diff --git a/common/src/brain_type_metrics/mod.rs b/common/src/brain_type_metrics/mod.rs index e255feea..d87e8c53 100644 --- a/common/src/brain_type_metrics/mod.rs +++ b/common/src/brain_type_metrics/mod.rs @@ -75,21 +75,15 @@ impl MethodMetrics { /// Returns the method name. #[must_use] - pub fn name(&self) -> &str { - &self.name - } + pub fn name(&self) -> &str { &self.name } /// Returns the cognitive complexity (CC) value. #[must_use] - pub fn cognitive_complexity(&self) -> usize { - self.cognitive_complexity - } + pub const fn cognitive_complexity(&self) -> usize { self.cognitive_complexity } /// Returns the lines of code (LOC) count. #[must_use] - pub fn lines_of_code(&self) -> usize { - self.lines_of_code - } + pub const fn lines_of_code(&self) -> usize { self.lines_of_code } /// Returns `true` when this method qualifies as a "brain method". /// @@ -106,7 +100,7 @@ impl MethodMetrics { /// assert!(!m.is_brain_method(25, 200)); /// ``` #[must_use] - pub fn is_brain_method(&self, cc_threshold: usize, loc_threshold: usize) -> bool { + pub const fn is_brain_method(&self, cc_threshold: usize, loc_threshold: usize) -> bool { self.cognitive_complexity >= cc_threshold && self.lines_of_code >= loc_threshold } } @@ -209,52 +203,38 @@ pub struct TypeMetrics { impl TypeMetrics { /// Returns the type name. #[must_use] - pub fn type_name(&self) -> &str { - &self.type_name - } + pub fn type_name(&self) -> &str { &self.type_name } /// Weighted Methods Count (sum of CC across all methods). #[must_use] - pub fn wmc(&self) -> usize { - self.wmc - } + pub const fn wmc(&self) -> usize { self.wmc } /// Brain methods with their full metric details. #[must_use] - pub fn brain_methods(&self) -> &[MethodMetrics] { - &self.brain_methods - } + pub fn brain_methods(&self) -> &[MethodMetrics] { &self.brain_methods } /// Returns an iterator over the names of brain methods. /// /// Callers that need a collected `Vec` should use `.collect()`. pub fn brain_method_names(&self) -> impl Iterator { - self.brain_methods.iter().map(|m| m.name()) + self.brain_methods.iter().map(MethodMetrics::name) } /// Number of brain methods detected. #[must_use] - pub fn brain_method_count(&self) -> usize { - self.brain_methods.len() - } + pub const fn brain_method_count(&self) -> usize { self.brain_methods.len() } /// LCOM4 connected component count (1 = cohesive, >= 2 = low cohesion). #[must_use] - pub fn lcom4(&self) -> usize { - self.lcom4 - } + pub const fn lcom4(&self) -> usize { self.lcom4 } /// Count of distinct external modules or types referenced. #[must_use] - pub fn foreign_reach(&self) -> usize { - self.foreign_reach - } + pub const fn foreign_reach(&self) -> usize { self.foreign_reach } /// Total number of methods in the type. #[must_use] - pub fn method_count(&self) -> usize { - self.method_count - } + pub const fn method_count(&self) -> usize { self.method_count } } // --------------------------------------------------------------------------- @@ -326,14 +306,10 @@ impl TypeMetricsBuilder { } /// Records the LCOM4 value (connected component count). - pub fn set_lcom4(&mut self, lcom4: usize) { - self.lcom4 = Some(lcom4); - } + pub const fn set_lcom4(&mut self, lcom4: usize) { self.lcom4 = Some(lcom4); } /// Records the foreign reach count. - pub fn set_foreign_reach(&mut self, count: usize) { - self.foreign_reach = Some(count); - } + pub const fn set_foreign_reach(&mut self, count: usize) { self.foreign_reach = Some(count); } /// Consumes the builder and returns the completed [`TypeMetrics`]. /// diff --git a/common/src/brain_type_metrics/tests.rs b/common/src/brain_type_metrics/tests.rs index b994a33c..5d85e8af 100644 --- a/common/src/brain_type_metrics/tests.rs +++ b/common/src/brain_type_metrics/tests.rs @@ -1,8 +1,9 @@ //! Unit tests for brain type metric collection. -use super::*; use rstest::rstest; +use super::*; + // --------------------------------------------------------------------------- // MethodMetrics // --------------------------------------------------------------------------- @@ -99,7 +100,10 @@ fn brain_methods_one_qualifying_method() { ]; let result = brain_methods(&methods, 25, 80); assert_eq!(result.len(), 1); - assert_eq!(result[0].name(), "parse"); + let first = result + .first() + .expect("expected exactly one qualifying brain method"); + assert_eq!(first.name(), "parse"); } #[rstest] @@ -110,9 +114,8 @@ fn brain_methods_multiple_qualifying_in_order() { MethodMetrics::new("gamma", 40, 200), ]; let result = brain_methods(&methods, 25, 80); - assert_eq!(result.len(), 2); - assert_eq!(result[0].name(), "alpha"); - assert_eq!(result[1].name(), "gamma"); + let names: Vec<&str> = result.iter().map(|method| method.name()).collect(); + assert_eq!(names, ["alpha", "gamma"]); } #[rstest] diff --git a/common/src/complexity_signal.rs b/common/src/complexity_signal.rs index d71e903c..0d662a86 100644 --- a/common/src/complexity_signal.rs +++ b/common/src/complexity_signal.rs @@ -24,6 +24,11 @@ impl LineSegment { /// /// Line numbers are one-based and ranges are inclusive. /// + /// # Errors + /// + /// - Returns [`SegmentError::LineNumberMustBeOneBased`] when either line is `0`. + /// - Returns [`SegmentError::StartAfterEnd`] when `start_line` exceeds `end_line`. + /// /// # Examples /// /// ``` @@ -34,7 +39,7 @@ impl LineSegment { /// assert_eq!(segment.end_line(), 5); /// ``` #[must_use = "Inspect the segment creation result to handle invalid ranges"] - pub fn new(start_line: usize, end_line: usize, value: f64) -> Result { + pub const fn new(start_line: usize, end_line: usize, value: f64) -> Result { if start_line == 0 || end_line == 0 { return Err(SegmentError::LineNumberMustBeOneBased { start_line, @@ -58,21 +63,15 @@ impl LineSegment { /// Returns the first line covered by the segment (inclusive). #[must_use] - pub const fn start_line(self) -> usize { - self.start_line - } + pub const fn start_line(self) -> usize { self.start_line } /// Returns the last line covered by the segment (inclusive). #[must_use] - pub const fn end_line(self) -> usize { - self.end_line - } + pub const fn end_line(self) -> usize { self.end_line } /// Returns the per-line contribution. #[must_use] - pub const fn value(self) -> f64 { - self.value - } + pub const fn value(self) -> f64 { self.value } } /// Errors emitted when constructing a [`LineSegment`]. @@ -80,13 +79,23 @@ impl LineSegment { pub enum SegmentError { /// Line numbers must be one-based (line 0 is invalid). #[error("line numbers must be one-based (got start_line={start_line}, end_line={end_line})")] - LineNumberMustBeOneBased { start_line: usize, end_line: usize }, + LineNumberMustBeOneBased { + /// One-based first line requested for the segment. + start_line: usize, + /// One-based last line requested for the segment. + end_line: usize, + }, /// The segment start must not occur after its end. #[error( "segment start must not occur after end (start_line={start_line}, end_line={end_line})" )] - StartAfterEnd { start_line: usize, end_line: usize }, + StartAfterEnd { + /// One-based first line requested for the segment. + start_line: usize, + /// One-based last line requested for the segment. + end_line: usize, + }, } /// Errors emitted when building a per-line signal. @@ -96,27 +105,42 @@ pub enum SignalBuildError { #[error( "function line range must be one-based (got start_line={start_line}, end_line={end_line})" )] - FunctionLineRangeMustBeOneBased { start_line: usize, end_line: usize }, + FunctionLineRangeMustBeOneBased { + /// One-based first line of the supplied function range. + start_line: usize, + /// One-based last line of the supplied function range. + end_line: usize, + }, /// The function start must not occur after its end. #[error( "function start must not occur after end (start_line={start_line}, end_line={end_line})" )] - FunctionStartAfterEnd { start_line: usize, end_line: usize }, + FunctionStartAfterEnd { + /// One-based first line of the supplied function range. + start_line: usize, + /// One-based last line of the supplied function range. + end_line: usize, + }, /// A segment does not intersect the function's line range. #[error( - "segment lies outside function range (segment={segment_start}..={segment_end}, function={function_start}..={function_end})" + "segment lies outside function range (segment={segment_start}..={segment_end}, \ + function={function_start}..={function_end})" )] SegmentOutsideFunctionRange { + /// One-based first line covered by the offending segment. segment_start: usize, + /// One-based last line covered by the offending segment. segment_end: usize, + /// One-based first line of the function range. function_start: usize, + /// One-based last line of the function range. function_end: usize, }, } -fn validate_function_range( +const fn validate_function_range( function_start: usize, function_end: usize, ) -> Result<(), SignalBuildError> { @@ -137,7 +161,7 @@ fn validate_function_range( Ok(()) } -fn validate_segment_in_range( +const fn validate_segment_in_range( segment: &LineSegment, function_start: usize, function_end: usize, @@ -154,6 +178,10 @@ fn validate_segment_in_range( Ok(()) } +#[expect( + clippy::float_arithmetic, + reason = "bumpy-road signal processing operates on user-configured floating-point weights" +)] fn apply_segment_to_diff(segment: &LineSegment, diff: &mut [f64], function_start: usize) { let segment_start = segment.start_line().saturating_sub(function_start); let segment_end = segment.end_line().saturating_sub(function_start); @@ -167,6 +195,10 @@ fn apply_segment_to_diff(segment: &LineSegment, diff: &mut [f64], function_start } } +#[expect( + clippy::float_arithmetic, + reason = "bumpy-road signal processing operates on user-configured floating-point weights" +)] fn accumulate_signal_from_diff(diff: &[f64], len: usize) -> Vec { let mut signal = Vec::with_capacity(len); let mut running = 0.0_f64; @@ -184,12 +216,11 @@ fn accumulate_signal_from_diff(diff: &[f64], len: usize) -> Vec { /// /// # Errors /// -/// - Returns [`SignalBuildError::FunctionLineRangeMustBeOneBased`] when the -/// provided range includes line `0`. -/// - Returns [`SignalBuildError::FunctionStartAfterEnd`] when the range is -/// inverted. -/// - Returns [`SignalBuildError::SegmentOutsideFunctionRange`] when any segment -/// does not overlap the function range. +/// - Returns [`SignalBuildError::FunctionLineRangeMustBeOneBased`] when the provided range includes +/// line `0`. +/// - Returns [`SignalBuildError::FunctionStartAfterEnd`] when the range is inverted. +/// - Returns [`SignalBuildError::SegmentOutsideFunctionRange`] when any segment does not overlap +/// the function range. /// /// # Examples /// @@ -225,25 +256,22 @@ pub fn rasterize_signal( Ok(accumulate_signal_from_diff(diff.as_slice(), len)) } -#[deprecated(note = "Use rasterize_signal instead.")] -#[must_use = "Inspect the signal build result to handle invalid ranges"] -pub fn rasterise_signal( - function_lines: RangeInclusive, - segments: &[LineSegment], -) -> Result, SignalBuildError> { - rasterize_signal(function_lines, segments) -} - /// Errors emitted when smoothing a signal. #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum SmoothingError { /// The moving average window must be positive. #[error("smoothing window must be positive (got {window})")] - WindowMustBePositive { window: usize }, + WindowMustBePositive { + /// The rejected window size, in lines. + window: usize, + }, /// The moving average window must be odd so the average is centred. #[error("smoothing window must be odd (got {window})")] - WindowMustBeOdd { window: usize }, + WindowMustBeOdd { + /// The rejected window size, in lines. + window: usize, + }, } /// Applies a centred moving-average smoothing window. @@ -266,16 +294,16 @@ pub enum SmoothingError { /// assert_eq!(smoothed, vec![0.0, 1.0, 1.0, 1.0, 0.0]); /// ``` #[must_use = "Inspect the smoothing result to handle invalid window sizes"] +#[expect( + clippy::float_arithmetic, + reason = "bumpy-road signal processing operates on user-configured floating-point weights" +)] pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, SmoothingError> { if window == 0 { return Err(SmoothingError::WindowMustBePositive { window }); } - fn is_even(value: usize) -> bool { - (value & 1) == 0 - } - - if is_even(window) { + if (window & 1) == 0 { return Err(SmoothingError::WindowMustBeOdd { window }); } @@ -283,11 +311,11 @@ pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, return Ok(Vec::new()); } - let half_window = window / 2; + let half_window = window.div_euclid(2); let mut prefix = Vec::with_capacity(signal.len() + 1); prefix.push(0.0_f64); for &value in signal { - let next = prefix[prefix.len() - 1] + value; + let next = prefix.last().copied().unwrap_or(0.0) + value; prefix.push(next); } @@ -296,8 +324,9 @@ pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, for idx in 0..signal.len() { let start = idx.saturating_sub(half_window); let end = (idx + half_window).min(last_index); - let sum = prefix[end + 1] - prefix[start]; - let count = (end - start + 1) as f64; + let sum = + prefix.get(end + 1).copied().unwrap_or(0.0) - prefix.get(start).copied().unwrap_or(0.0); + let count = u32::try_from(end - start + 1).map_or_else(|_| f64::from(u32::MAX), f64::from); smoothed.push(sum / count); } @@ -306,9 +335,12 @@ pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, #[cfg(test)] mod tests { - use super::*; + //! Tests for complexity-signal segmentation, construction, and smoothing. + use rstest::rstest; + use super::*; + #[rstest] fn rasterize_signal_accumulates_overlapping_segments() { let segments = vec![ diff --git a/common/src/context.rs b/common/src/context.rs index 2d7b7a84..d5bbf953 100644 --- a/common/src/context.rs +++ b/common/src/context.rs @@ -1,7 +1,10 @@ //! Context tracking utilities for analysing traversal stacks. use crate::attributes::{ - Attribute, AttributePath, has_test_like_attribute, has_test_like_attribute_with, + Attribute, + AttributePath, + has_test_like_attribute, + has_test_like_attribute_with, }; /// Categorizes a frame within the traversal stack. @@ -31,13 +34,18 @@ impl ContextEntry { /// # Examples /// /// ``` - /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; - /// use whitaker_common::context::{ContextEntry, ContextKind}; + /// use whitaker_common::{ + /// attributes::{Attribute, AttributeKind, AttributePath}, + /// context::{ContextEntry, ContextKind}, + /// }; /// /// let entry = ContextEntry::new( /// "demo", /// ContextKind::Function, - /// vec![Attribute::new(AttributePath::from("test"), AttributeKind::Outer)], + /// vec![Attribute::new( + /// AttributePath::from("test"), + /// AttributeKind::Outer, + /// )], /// ); /// assert_eq!(entry.name(), "demo"); /// ``` @@ -55,12 +63,17 @@ impl ContextEntry { /// # Examples /// /// ``` - /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; - /// use whitaker_common::context::ContextEntry; + /// use whitaker_common::{ + /// attributes::{Attribute, AttributeKind, AttributePath}, + /// context::ContextEntry, + /// }; /// /// let entry = ContextEntry::function( /// "demo", - /// vec![Attribute::new(AttributePath::from("test"), AttributeKind::Outer)], + /// vec![Attribute::new( + /// AttributePath::from("test"), + /// AttributeKind::Outer, + /// )], /// ); /// assert!(entry.kind().matches_function()); /// ``` @@ -71,40 +84,28 @@ impl ContextEntry { /// Returns the entry name. #[must_use] - pub fn name(&self) -> &str { - &self.name - } + pub fn name(&self) -> &str { &self.name } /// Returns the entry kind. #[must_use] - pub const fn kind(&self) -> &ContextKind { - &self.kind - } + pub const fn kind(&self) -> &ContextKind { &self.kind } /// Returns a snapshot of the entry attributes. #[must_use] - pub fn attributes(&self) -> &[Attribute] { - &self.attributes - } + pub fn attributes(&self) -> &[Attribute] { &self.attributes } /// Returns a mutable reference to the attributes for in-place updates. #[must_use] - pub fn attributes_mut(&mut self) -> &mut Vec { - &mut self.attributes - } + pub const fn attributes_mut(&mut self) -> &mut Vec { &mut self.attributes } /// Adds an attribute to the entry. - pub fn push_attribute(&mut self, attribute: Attribute) { - self.attributes.push(attribute); - } + pub fn push_attribute(&mut self, attribute: Attribute) { self.attributes.push(attribute); } } impl ContextKind { /// Returns `true` when the kind is [`ContextKind::Function`]. #[must_use] - pub const fn matches_function(&self) -> bool { - matches!(self, Self::Function) - } + pub const fn matches_function(&self) -> bool { matches!(self, Self::Function) } } /// Tests whether a slice of attributes marks an item as a test function. @@ -112,16 +113,19 @@ impl ContextKind { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::context::is_test_fn; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// context::is_test_fn, +/// }; /// -/// let attrs = vec![Attribute::new(AttributePath::from("rstest"), AttributeKind::Outer)]; +/// let attrs = vec![Attribute::new( +/// AttributePath::from("rstest"), +/// AttributeKind::Outer, +/// )]; /// assert!(is_test_fn(&attrs)); /// ``` #[must_use] -pub fn is_test_fn(attrs: &[Attribute]) -> bool { - has_test_like_attribute(attrs) -} +pub fn is_test_fn(attrs: &[Attribute]) -> bool { has_test_like_attribute(attrs) } /// Tests whether a slice of attributes marks an item as a test function while /// honouring custom attribute paths. @@ -129,10 +133,15 @@ pub fn is_test_fn(attrs: &[Attribute]) -> bool { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::context::is_test_fn_with; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// context::is_test_fn_with, +/// }; /// -/// let attrs = vec![Attribute::new(AttributePath::from("custom::test"), AttributeKind::Outer)]; +/// let attrs = vec![Attribute::new( +/// AttributePath::from("custom::test"), +/// AttributeKind::Outer, +/// )]; /// let additional = vec![AttributePath::from("custom::test")]; /// assert!(is_test_fn_with(&attrs, &additional)); /// ``` @@ -146,11 +155,16 @@ pub fn is_test_fn_with(attrs: &[Attribute], additional: &[AttributePath]) -> boo /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::context::{in_test_like_context, ContextEntry}; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// context::{ContextEntry, in_test_like_context}, +/// }; /// /// let mut entry = ContextEntry::function("demo", Vec::new()); -/// entry.push_attribute(Attribute::new(AttributePath::from("test"), AttributeKind::Outer)); +/// entry.push_attribute(Attribute::new( +/// AttributePath::from("test"), +/// AttributeKind::Outer, +/// )); /// assert!(in_test_like_context(&[entry])); /// ``` #[must_use] @@ -164,11 +178,16 @@ pub fn in_test_like_context(stack: &[ContextEntry]) -> bool { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::context::{in_test_like_context_with, ContextEntry}; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// context::{ContextEntry, in_test_like_context_with}, +/// }; /// /// let mut entry = ContextEntry::function("demo", Vec::new()); -/// entry.push_attribute(Attribute::new(AttributePath::from("custom::test"), AttributeKind::Outer)); +/// entry.push_attribute(Attribute::new( +/// AttributePath::from("custom::test"), +/// AttributeKind::Outer, +/// )); /// let additional = vec![AttributePath::from("custom::test")]; /// assert!(in_test_like_context_with(&[entry], &additional)); /// ``` @@ -187,7 +206,7 @@ pub fn in_test_like_context_with(stack: &[ContextEntry], additional: &[Attribute /// # Examples /// /// ``` -/// use whitaker_common::context::{is_in_main_fn, ContextEntry}; +/// use whitaker_common::context::{ContextEntry, is_in_main_fn}; /// /// let stack = vec![ContextEntry::function("main", Vec::new())]; /// assert!(is_in_main_fn(&stack)); @@ -202,9 +221,12 @@ pub fn is_in_main_fn(stack: &[ContextEntry]) -> bool { #[cfg(test)] mod tests { + //! Tests for lint-context helpers that expose attributes and source text. + + use rstest::rstest; + use super::*; use crate::attributes::{Attribute, AttributeKind, AttributePath}; - use rstest::rstest; fn test_attribute() -> Attribute { Attribute::new(AttributePath::from("test"), AttributeKind::Outer) diff --git a/common/src/decomposition_advice/community.rs b/common/src/decomposition_advice/community.rs index 92a89060..1ee1f61b 100644 --- a/common/src/decomposition_advice/community.rs +++ b/common/src/decomposition_advice/community.rs @@ -3,8 +3,11 @@ use std::collections::BTreeMap; use super::vector::{ - MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, - MethodFeatureVector, cosine_threshold_met, dot_product, + MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, + MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, + MethodFeatureVector, + cosine_threshold_met, + dot_product, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -16,7 +19,7 @@ pub(crate) struct SimilarityEdge { impl SimilarityEdge { // Used by test_support::decomposition::adjacency_report and unit tests. - pub(crate) fn new(left: usize, right: usize, weight: u64) -> Self { + pub(crate) const fn new(left: usize, right: usize, weight: u64) -> Self { Self { left, right, @@ -25,36 +28,30 @@ impl SimilarityEdge { } #[cfg(test)] - pub(crate) fn left(&self) -> usize { - self.left - } + pub(crate) const fn left(&self) -> usize { self.left } #[cfg(test)] - pub(crate) fn right(&self) -> usize { - self.right - } + pub(crate) const fn right(&self) -> usize { self.right } #[cfg(test)] - pub(crate) fn weight(&self) -> u64 { - self.weight - } + pub(crate) const fn weight(&self) -> u64 { self.weight } } pub(crate) fn build_similarity_edges(vectors: &[MethodFeatureVector]) -> Vec { let mut edges = Vec::new(); - for left in 0..vectors.len() { - for right in (left + 1)..vectors.len() { + for (left, left_vector) in vectors.iter().enumerate() { + for (right, right_vector) in vectors.iter().enumerate().skip(left + 1) { if !cosine_threshold_met( - &vectors[left], - &vectors[right], + left_vector, + right_vector, MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, ) { continue; } - let weight = dot_product(vectors[left].weights(), vectors[right].weights()); + let weight = dot_product(left_vector.weights(), right_vector.weights()); if weight == 0 { continue; } @@ -95,20 +92,20 @@ pub(crate) fn detect_communities(vectors: &[MethodFeatureVector]) -> Vec> = groups.into_values().collect(); for community in &mut communities { - community.sort_by(|left, right| { - vectors[*left] - .method_name() - .cmp(vectors[*right].method_name()) - }); + community.sort_by(|left, right| method_name(*left).cmp(&method_name(*right))); } communities.sort_by(|left, right| { right.len().cmp(&left.len()).then_with(|| { - vectors[left[0]] - .method_name() - .cmp(vectors[right[0]].method_name()) + let left_name = left.first().and_then(|&node| method_name(node)); + let right_name = right.first().and_then(|&node| method_name(node)); + left_name.cmp(&right_name) }) }); communities @@ -137,9 +134,15 @@ pub(crate) fn build_adjacency( ) -> Vec> { let mut adjacency = vec![Vec::new(); node_count]; + // Edges referencing nodes outside `0..node_count` are ignored rather than + // panicking; validated callers never produce them. for edge in edges { - adjacency[edge.left].push((edge.right, edge.weight)); - adjacency[edge.right].push((edge.left, edge.weight)); + if let Some(neighbours) = adjacency.get_mut(edge.left) { + neighbours.push((edge.right, edge.weight)); + } + if let Some(neighbours) = adjacency.get_mut(edge.right) { + neighbours.push((edge.left, edge.weight)); + } } for neighbours in &mut adjacency { @@ -195,18 +198,7 @@ pub(crate) fn propagate_labels_report( for _ in 0..max_iterations { iteration_count += 1; - let mut changed = false; - - for &node in &active_nodes { - let Some(best_label) = best_neighbour_label(node, &labels, adjacency, vectors) else { - continue; - }; - - if best_label != labels[node] { - labels[node] = best_label; - changed = true; - } - } + let changed = run_propagation_pass(vectors, adjacency, &active_nodes, &mut labels); if !changed { log::debug!( @@ -221,7 +213,8 @@ pub(crate) fn propagate_labels_report( if iteration_count == max_iterations { log::debug!( - "label propagation reached iteration limit: nodes={}, active_nodes={}, max_iterations={}", + "label propagation reached iteration limit: nodes={}, active_nodes={}, \ + max_iterations={}", vectors.len(), active_nodes.len(), max_iterations, @@ -234,13 +227,42 @@ pub(crate) fn propagate_labels_report( } } +/// Runs one label-propagation pass over the active nodes. +/// +/// Returns `true` when at least one node adopted a new label, which tells the +/// caller whether propagation has converged. +fn run_propagation_pass( + vectors: &[MethodFeatureVector], + adjacency: &[Vec<(usize, u64)>], + active_nodes: &[usize], + labels: &mut [usize], +) -> bool { + let mut changed = false; + + for &node in active_nodes { + let Some(best_label) = best_neighbour_label(node, labels, adjacency, vectors) else { + continue; + }; + + // Active nodes are adjacency indices, so the label slot always exists. + if let Some(label_slot) = labels.get_mut(node) + && *label_slot != best_label + { + *label_slot = best_label; + changed = true; + } + } + + changed +} + fn best_neighbour_label( node: usize, labels: &[usize], adjacency: &[Vec<(usize, u64)>], vectors: &[MethodFeatureVector], ) -> Option { - let neighbours = &adjacency[node]; + let neighbours = adjacency.get(node)?; if neighbours.is_empty() { return None; } @@ -249,7 +271,11 @@ fn best_neighbour_label( let mut best: Option<(usize, u64)> = None; for &(neighbour, weight) in neighbours { - let label = labels[neighbour]; + // Neighbour indices come from validated adjacency rows, so the label + // lookup never misses; skipping keeps the scan panic free regardless. + let Some(&label) = labels.get(neighbour) else { + continue; + }; let score = score_label(&mut scores, label, weight); if should_replace_best(best, label, score, vectors) { @@ -270,10 +296,8 @@ fn labels_are_stable( return true; } - match best_neighbour_label(node, labels, adjacency, vectors) { - Some(best_label) => labels[node] == best_label, - None => true, - } + best_neighbour_label(node, labels, adjacency, vectors) + .is_none_or(|best_label| labels.get(node) == Some(&best_label)) }) } @@ -294,14 +318,20 @@ fn should_replace_best( Some((best_label, best_score)) => { // Prefer higher score; on tie, pick the lexically earlier method // name and then the smaller label index to keep runs deterministic. - if candidate_score != best_score { - candidate_score > best_score - } else { - let candidate_name = vectors[candidate_label].method_name(); - let best_name = vectors[best_label].method_name(); + if candidate_score == best_score { + // Labels are node indices, so both lookups always succeed; the + // `Option` ordering (`None` first) is only a type-level guard. + let candidate_name = vectors + .get(candidate_label) + .map(MethodFeatureVector::method_name); + let best_name = vectors + .get(best_label) + .map(MethodFeatureVector::method_name); candidate_name < best_name || (candidate_name == best_name && candidate_label < best_label) + } else { + candidate_score > best_score } } } diff --git a/common/src/decomposition_advice/community_kani/propagate_labels.rs b/common/src/decomposition_advice/community_kani/propagate_labels.rs index 809dc4ff..e69749ab 100644 --- a/common/src/decomposition_advice/community_kani/propagate_labels.rs +++ b/common/src/decomposition_advice/community_kani/propagate_labels.rs @@ -1,10 +1,8 @@ //! Bounded model-checking harnesses for `propagate_labels`. +use super::{super::propagate_labels_report, shared::bounded_iteration_count}; use crate::decomposition_advice::minimal_feature_vector; -use super::super::propagate_labels_report; -use super::shared::bounded_iteration_count; - const NODE_COUNT: usize = 3; fn symbolic_vectors() -> [crate::decomposition_advice::vector::MethodFeatureVector; NODE_COUNT] { diff --git a/common/src/decomposition_advice/note.rs b/common/src/decomposition_advice/note.rs index 3f24bfa7..a3d59603 100644 --- a/common/src/decomposition_advice/note.rs +++ b/common/src/decomposition_advice/note.rs @@ -25,7 +25,10 @@ const MAX_METHODS_PER_SUGGESTION: usize = 3; /// /// ``` /// use whitaker_common::decomposition_advice::{ -/// DecompositionContext, MethodProfileBuilder, SubjectKind, format_diagnostic_note, +/// DecompositionContext, +/// MethodProfileBuilder, +/// SubjectKind, +/// format_diagnostic_note, /// suggest_decomposition, /// }; /// @@ -63,13 +66,17 @@ pub fn format_diagnostic_note( return None; } - let visible_suggestions = &suggestions[..suggestions.len().min(MAX_SUGGESTIONS)]; let mut lines = vec![format!( "Potential decomposition for `{}`:", context.subject_name() )]; - lines.extend(visible_suggestions.iter().map(render_suggestion_line)); + lines.extend( + suggestions + .iter() + .take(MAX_SUGGESTIONS) + .map(render_suggestion_line), + ); let omitted_suggestions = suggestions.len().saturating_sub(MAX_SUGGESTIONS); if omitted_suggestions > 0 { @@ -99,17 +106,17 @@ fn render_suggestion_line(suggestion: &DecompositionSuggestion) -> String { } fn render_method_list(methods: &[String]) -> String { - let visible_methods = &methods[..methods.len().min(MAX_METHODS_PER_SUGGESTION)]; - let mut rendered = visible_methods + let rendered = methods .iter() + .take(MAX_METHODS_PER_SUGGESTION) .map(|method| format!("`{method}`")) .collect::>() .join(", "); let omitted_methods = methods.len().saturating_sub(MAX_METHODS_PER_SUGGESTION); if omitted_methods > 0 { - rendered.push_str(&format!(", +{omitted_methods} more methods")); + format!("{rendered}, +{omitted_methods} more methods") + } else { + rendered } - - rendered } diff --git a/common/src/decomposition_advice/note_tests.rs b/common/src/decomposition_advice/note_tests.rs index 7697d051..3e7cdbe1 100644 --- a/common/src/decomposition_advice/note_tests.rs +++ b/common/src/decomposition_advice/note_tests.rs @@ -1,10 +1,15 @@ //! Unit tests for decomposition diagnostic-note rendering. use super::format_diagnostic_note; -use crate::decomposition_advice::{DecompositionContext, MethodProfile, SubjectKind}; -use crate::test_support::decomposition::{ - MethodInput, decomposition_suggestions, parser_serde_fs_fixture, profile, - transport_trait_fixture, +use crate::{ + decomposition_advice::{DecompositionContext, MethodProfile, SubjectKind}, + test_support::decomposition::{ + MethodInput, + decomposition_suggestions, + parser_serde_fs_fixture, + profile, + transport_trait_fixture, + }, }; fn parser_serde_fs_suggestions() -> ( @@ -14,8 +19,8 @@ fn parser_serde_fs_suggestions() -> ( decomposition_suggestions("Foo", SubjectKind::Type, &parser_serde_fs_fixture()) } -fn render_note(subject: &str, kind: SubjectKind, methods: Vec) -> String { - let (context, suggestions) = decomposition_suggestions(subject, kind, &methods); +fn render_note(subject: &str, kind: SubjectKind, methods: &[MethodProfile]) -> String { + let (context, suggestions) = decomposition_suggestions(subject, kind, methods); format_diagnostic_note(&context, &suggestions).unwrap_or_default() } @@ -43,7 +48,7 @@ fn format_diagnostic_note_renders_type_suggestions() { #[test] fn format_diagnostic_note_renders_trait_sub_traits() { - let rendered = render_note("Transport", SubjectKind::Trait, transport_trait_fixture()); + let rendered = render_note("Transport", SubjectKind::Trait, &transport_trait_fixture()); assert!(rendered.contains("- [serde::json] sub-trait for `decode_request`, `encode_request`")); assert!(rendered.contains("- [std::io] sub-trait for `read_frame`, `write_frame`")); @@ -110,7 +115,7 @@ fn format_diagnostic_note_caps_rendered_suggestions() { }), ]; - let rendered = render_note("Coordinator", SubjectKind::Type, methods); + let rendered = render_note("Coordinator", SubjectKind::Type, &methods); assert!(rendered.contains("- [grammar] helper struct")); assert!(rendered.contains("- [serde::json] module")); @@ -173,9 +178,10 @@ fn format_diagnostic_note_caps_methods_per_suggestion() { }), ]; - let rendered = render_note("Reporter", SubjectKind::Type, methods); + let rendered = render_note("Reporter", SubjectKind::Type, &methods); assert!(rendered.contains( - "- [report] helper struct for `report_alpha`, `report_beta`, `report_delta`, +2 more methods" + "- [report] helper struct for `report_alpha`, `report_beta`, `report_delta`, +2 more \ + methods" )); } diff --git a/common/src/decomposition_advice/profile.rs b/common/src/decomposition_advice/profile.rs index 65d6bc89..5bf0192f 100644 --- a/common/src/decomposition_advice/profile.rs +++ b/common/src/decomposition_advice/profile.rs @@ -61,15 +61,11 @@ impl DecompositionContext { /// Returns the analysed subject name. #[must_use] - pub fn subject_name(&self) -> &str { - &self.subject_name - } + pub fn subject_name(&self) -> &str { &self.subject_name } /// Returns the analysed subject kind. #[must_use] - pub fn subject_kind(&self) -> SubjectKind { - self.subject_kind - } + pub const fn subject_kind(&self) -> SubjectKind { self.subject_kind } } /// Immutable per-method metadata used to build feature vectors. @@ -106,33 +102,23 @@ pub struct MethodProfile { impl MethodProfile { /// Returns the method name. #[must_use] - pub fn name(&self) -> &str { - &self.name - } + pub fn name(&self) -> &str { &self.name } /// Returns accessed fields. #[must_use] - pub fn accessed_fields(&self) -> &BTreeSet { - &self.accessed_fields - } + pub const fn accessed_fields(&self) -> &BTreeSet { &self.accessed_fields } /// Returns types used in the method signature. #[must_use] - pub fn signature_types(&self) -> &BTreeSet { - &self.signature_types - } + pub const fn signature_types(&self) -> &BTreeSet { &self.signature_types } /// Returns types used in local variables. #[must_use] - pub fn local_types(&self) -> &BTreeSet { - &self.local_types - } + pub const fn local_types(&self) -> &BTreeSet { &self.local_types } /// Returns external domains used by the method. #[must_use] - pub fn external_domains(&self) -> &BTreeSet { - &self.external_domains - } + pub const fn external_domains(&self) -> &BTreeSet { &self.external_domains } } /// Mutable builder for [`MethodProfile`]. @@ -151,7 +137,10 @@ impl MethodProfile { /// .record_local_type("PathBuf"); /// /// let profile = builder.build(); -/// assert_eq!(profile.external_domains().iter().next().map(String::as_str), Some("std::fs")); +/// assert_eq!( +/// profile.external_domains().iter().next().map(String::as_str), +/// Some("std::fs") +/// ); /// ``` #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MethodProfileBuilder { diff --git a/common/src/decomposition_advice/suggestion.rs b/common/src/decomposition_advice/suggestion.rs index 32f75910..4ab9e33b 100644 --- a/common/src/decomposition_advice/suggestion.rs +++ b/common/src/decomposition_advice/suggestion.rs @@ -2,9 +2,11 @@ use std::collections::BTreeMap; -use super::community::detect_communities; -use super::profile::{DecompositionContext, MethodProfile, SubjectKind}; -use super::vector::{FeatureCategory, MethodFeatureVector, build_feature_vector}; +use super::{ + community::detect_communities, + profile::{DecompositionContext, MethodProfile, SubjectKind}, + vector::{FeatureCategory, MethodFeatureVector, build_feature_vector}, +}; /// The extraction shape suggested for a method community. /// @@ -57,7 +59,10 @@ impl std::fmt::Display for SuggestedExtractionKind { /// /// ``` /// use whitaker_common::decomposition_advice::{ -/// DecompositionContext, MethodProfileBuilder, SubjectKind, SuggestedExtractionKind, +/// DecompositionContext, +/// MethodProfileBuilder, +/// SubjectKind, +/// SuggestedExtractionKind, /// suggest_decomposition, /// }; /// @@ -81,7 +86,10 @@ impl std::fmt::Display for SuggestedExtractionKind { /// ); /// /// assert_eq!(suggestions.len(), 2); -/// assert_eq!(suggestions[0].extraction_kind(), SuggestedExtractionKind::Module); +/// assert_eq!( +/// suggestions[0].extraction_kind(), +/// SuggestedExtractionKind::Module +/// ); /// ``` #[derive(Clone, Debug, Eq, PartialEq)] pub struct DecompositionSuggestion { @@ -94,27 +102,19 @@ pub struct DecompositionSuggestion { impl DecompositionSuggestion { /// Returns the community label. #[must_use] - pub fn label(&self) -> &str { - &self.label - } + pub fn label(&self) -> &str { &self.label } /// Returns the suggested extraction kind. #[must_use] - pub fn extraction_kind(&self) -> SuggestedExtractionKind { - self.extraction_kind - } + pub const fn extraction_kind(&self) -> SuggestedExtractionKind { self.extraction_kind } /// Returns method names in the community. #[must_use] - pub fn methods(&self) -> &[String] { - &self.methods - } + pub fn methods(&self) -> &[String] { &self.methods } /// Returns the dominant features that motivated the suggestion. #[must_use] - pub fn rationale(&self) -> &[String] { - &self.rationale - } + pub fn rationale(&self) -> &[String] { &self.rationale } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -133,7 +133,10 @@ struct AggregatedFeature { /// /// ``` /// use whitaker_common::decomposition_advice::{ -/// DecompositionContext, MethodProfileBuilder, SubjectKind, suggest_decomposition, +/// DecompositionContext, +/// MethodProfileBuilder, +/// SubjectKind, +/// suggest_decomposition, /// }; /// /// let context = DecompositionContext::new("Parser", SubjectKind::Type); @@ -197,9 +200,12 @@ fn build_suggestion( Some(DecompositionSuggestion { label: label_feature.display.clone(), extraction_kind: infer_extraction_kind(context.subject_kind(), label_feature.category), + // Community indices always come from `0..vectors.len()`, so the + // filter never drops a method; it only keeps the lookup panic free. methods: community .iter() - .map(|index| vectors[*index].method_name().to_owned()) + .filter_map(|&index| vectors.get(index)) + .map(|vector| vector.method_name().to_owned()) .collect(), rationale, }) @@ -211,9 +217,16 @@ fn aggregate_features( ) -> Vec { let mut aggregated: BTreeMap = BTreeMap::new(); - for method_index in community { - for (feature_key, weight) in vectors[*method_index].weights() { - let metadata = &vectors[*method_index].metadata()[feature_key]; + for &method_index in community { + let Some(vector) = vectors.get(method_index) else { + continue; + }; + for (feature_key, weight) in vector.weights() { + // Weights and metadata are built together, so the metadata entry + // always exists for a weighted feature key. + let Some(metadata) = vector.metadata().get(feature_key) else { + continue; + }; let entry = aggregated .entry(feature_key.clone()) @@ -238,26 +251,17 @@ fn choose_label_feature(features: &[AggregatedFeature]) -> Option<&AggregatedFea FeatureCategory::LocalType, ]; - for category in LABEL_PRIORITIES { - let mut matches: Vec<_> = features + LABEL_PRIORITIES.iter().find_map(|category| { + features .iter() .filter(|feature| feature.category == *category) - .collect(); - - if matches.is_empty() { - continue; - } - - matches.sort_by(|left, right| { - right - .score - .cmp(&left.score) - .then_with(|| left.display.cmp(&right.display)) - }); - return Some(matches[0]); - } - - None + .min_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.display.cmp(&right.display)) + }) + }) } fn choose_rationale(features: &[AggregatedFeature]) -> Vec { diff --git a/common/src/decomposition_advice/tests/adjacency.rs b/common/src/decomposition_advice/tests/adjacency.rs index 8219e2a1..c69a72c1 100644 --- a/common/src/decomposition_advice/tests/adjacency.rs +++ b/common/src/decomposition_advice/tests/adjacency.rs @@ -27,9 +27,9 @@ fn single_edge_inserted_in_both_directions() { let adjacency = build_adjacency(3, &[edge(0, 2, 10)]); assert_eq!(adjacency.len(), 3); - assert_eq!(adjacency[0], vec![(2, 10)]); - assert!(adjacency[1].is_empty()); - assert_eq!(adjacency[2], vec![(0, 10)]); + assert_eq!(adjacency.first(), Some(&vec![(2, 10)])); + assert_eq!(adjacency.get(1), Some(&Vec::new())); + assert_eq!(adjacency.get(2), Some(&vec![(0, 10)])); } #[test] @@ -39,7 +39,7 @@ fn multiple_edges_produce_sorted_neighbour_lists() { let adjacency = build_adjacency(4, &edges); // Node 1's neighbours should be sorted by neighbour index. - assert_eq!(adjacency[1], vec![(0, 5), (2, 8), (3, 3)]); + assert_eq!(adjacency.get(1), Some(&vec![(0, 5), (2, 8), (3, 3)])); } #[test] @@ -48,10 +48,10 @@ fn sparse_graph_preserves_isolated_nodes() { let adjacency = build_adjacency(4, &[edge(0, 2, 7)]); assert_eq!(adjacency.len(), 4); - assert_eq!(adjacency[0], vec![(2, 7)]); - assert!(adjacency[1].is_empty()); - assert_eq!(adjacency[2], vec![(0, 7)]); - assert!(adjacency[3].is_empty()); + assert_eq!(adjacency.first(), Some(&vec![(2, 7)])); + assert_eq!(adjacency.get(1), Some(&Vec::new())); + assert_eq!(adjacency.get(2), Some(&vec![(0, 7)])); + assert_eq!(adjacency.get(3), Some(&Vec::new())); } #[test] @@ -62,8 +62,11 @@ fn multi_edge_graph_is_symmetric() { // Every (node -> neighbour, weight) pair has its mirror. for (node, bucket) in adjacency.iter().enumerate() { for &(neighbour, weight) in bucket { + let mirror_bucket = adjacency + .get(neighbour) + .expect("neighbour index should be within adjacency bounds"); assert!( - adjacency[neighbour] + mirror_bucket .iter() .any(|&(mirror, mirror_weight)| mirror == node && mirror_weight == weight), "missing mirror for ({node} -> {neighbour}, weight {weight})", diff --git a/common/src/decomposition_advice/tests/cosine_threshold.rs b/common/src/decomposition_advice/tests/cosine_threshold.rs index e5e2cb16..35abe26c 100644 --- a/common/src/decomposition_advice/tests/cosine_threshold.rs +++ b/common/src/decomposition_advice/tests/cosine_threshold.rs @@ -7,8 +7,10 @@ //! and `MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED` constants. use crate::decomposition_advice::vector::{ - MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, - cosine_threshold_met, test_feature_vector, + MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, + MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, + cosine_threshold_met, + test_feature_vector, }; fn check_cosine_threshold(left_weights: &[(&str, u64)], right_weights: &[(&str, u64)]) -> bool { diff --git a/common/src/decomposition_advice/tests.rs b/common/src/decomposition_advice/tests/mod.rs similarity index 86% rename from common/src/decomposition_advice/tests.rs rename to common/src/decomposition_advice/tests/mod.rs index 93fc40af..8c791f04 100644 --- a/common/src/decomposition_advice/tests.rs +++ b/common/src/decomposition_advice/tests/mod.rs @@ -6,15 +6,22 @@ mod propagation; mod test_fixtures; mod vector_algebra; +use std::str::FromStr; + use self::test_fixtures::{ - ExpectedSuggestion, MethodInput, assert_suggestion, assert_type_decomposition_is_empty, - parser_serde_fs_fixture, profile, + ExpectedSuggestion, + MethodInput, + assert_suggestion, + assert_type_decomposition_is_empty, + parser_serde_fs_fixture, + profile, +}; +use super::{ + community::{build_similarity_edges, detect_communities}, + profile::{DecompositionContext, SubjectKind}, + suggestion::{SuggestedExtractionKind, suggest_decomposition}, + vector::{build_feature_vector, dot_product, identifier_keywords}, }; -use super::community::{build_similarity_edges, detect_communities}; -use super::profile::{DecompositionContext, SubjectKind}; -use super::suggestion::{SuggestedExtractionKind, suggest_decomposition}; -use super::vector::{build_feature_vector, dot_product, identifier_keywords}; -use std::str::FromStr; #[test] fn identifier_keywords_split_camel_case_and_remove_stop_words() { @@ -119,8 +126,11 @@ fn similarity_edges_include_related_methods_only() { let edges = build_similarity_edges(&vectors); assert_eq!(edges.len(), 1); - assert_eq!((edges[0].left(), edges[0].right()), (0, 1)); - assert!(edges[0].weight() > 0); + let edge = edges + .first() + .expect("similarity edges should contain the related parser pair"); + assert_eq!((edge.left(), edge.right()), (0, 1)); + assert!(edge.weight() > 0); } #[test] @@ -129,13 +139,19 @@ fn detect_communities_is_order_invariant() { let mut original_vectors: Vec<_> = fixture.iter().map(build_feature_vector).collect(); original_vectors.sort(); + let fixture_method = |index: usize| { + fixture + .get(index) + .cloned() + .expect("parser fixture should provide six methods") + }; let reordered_fixture = [ - fixture[4].clone(), - fixture[1].clone(), - fixture[5].clone(), - fixture[0].clone(), - fixture[3].clone(), - fixture[2].clone(), + fixture_method(4), + fixture_method(1), + fixture_method(5), + fixture_method(0), + fixture_method(3), + fixture_method(2), ]; let mut reordered_vectors: Vec<_> = reordered_fixture.iter().map(build_feature_vector).collect(); @@ -151,7 +167,7 @@ fn detect_communities_is_order_invariant() { fn suggest_decomposition_returns_empty_for_single_community() { assert_type_decomposition_is_empty( "Parser", - vec![ + &[ profile(MethodInput { name: "parse_tokens", fields: &["grammar"], @@ -190,8 +206,13 @@ fn suggest_decomposition_for_type_prefers_domain_module_and_field_helper_struct( let suggestions = suggest_decomposition(&context, &parser_serde_fs_fixture()); assert_eq!(suggestions.len(), 3); + let suggestion_at = |index: usize| { + suggestions + .get(index) + .expect("decomposition should yield three suggestions") + }; assert_suggestion( - &suggestions[0], + suggestion_at(0), ExpectedSuggestion { label: "grammar", extraction_kind: SuggestedExtractionKind::HelperStruct, @@ -199,7 +220,7 @@ fn suggest_decomposition_for_type_prefers_domain_module_and_field_helper_struct( }, ); assert_suggestion( - &suggestions[1], + suggestion_at(1), ExpectedSuggestion { label: "serde::json", extraction_kind: SuggestedExtractionKind::Module, @@ -207,7 +228,7 @@ fn suggest_decomposition_for_type_prefers_domain_module_and_field_helper_struct( }, ); assert_suggestion( - &suggestions[2], + suggestion_at(2), ExpectedSuggestion { label: "std::fs", extraction_kind: SuggestedExtractionKind::Module, @@ -294,12 +315,13 @@ fn suggest_decomposition_is_order_invariant_for_duplicate_method_names() { }), ]; - let reordered = vec![ - methods[2].clone(), - methods[0].clone(), - methods[3].clone(), - methods[1].clone(), - ]; + let method_at = |index: usize| { + methods + .get(index) + .cloned() + .expect("importer fixture should provide four methods") + }; + let reordered = vec![method_at(2), method_at(0), method_at(3), method_at(1)]; assert_eq!( suggest_decomposition(&context, &methods), @@ -333,7 +355,7 @@ fn suggestions_drop_singleton_noise_methods() { fn suggestions_skip_degenerate_groups_without_features() { assert_type_decomposition_is_empty( "Runner", - vec![ + &[ profile(MethodInput { name: "build", fields: &[], diff --git a/common/src/decomposition_advice/tests/propagation.rs b/common/src/decomposition_advice/tests/propagation.rs index 29f8e5f4..ca91e253 100644 --- a/common/src/decomposition_advice/tests/propagation.rs +++ b/common/src/decomposition_advice/tests/propagation.rs @@ -1,12 +1,13 @@ //! Unit tests for deterministic label propagation. use rstest::{fixture, rstest}; +use whitaker_test_macros::allow_fixture_expansion_lints; -use crate::decomposition_advice::community::{ - SimilarityEdge, build_adjacency, detect_communities, propagate_labels_report, +use crate::decomposition_advice::{ + community::{SimilarityEdge, build_adjacency, detect_communities, propagate_labels_report}, + minimal_feature_vector, + vector::{MethodFeatureVector, test_feature_vector}, }; -use crate::decomposition_advice::minimal_feature_vector; -use crate::decomposition_advice::vector::{MethodFeatureVector, test_feature_vector}; fn vectors(method_names: &[&str]) -> Vec { method_names @@ -19,10 +20,9 @@ fn edge(left: usize, right: usize, weight: u64) -> SimilarityEdge { SimilarityEdge::new(left, right, weight) } +#[allow_fixture_expansion_lints] #[fixture] -fn connected_triplet_vectors() -> Vec { - vectors(&["alpha", "beta", "gamma"]) -} +fn connected_triplet_vectors() -> Vec { vectors(&["alpha", "beta", "gamma"]) } #[fixture] fn connected_triplet_adjacency() -> Vec> { @@ -39,20 +39,17 @@ fn linear_quartet_adjacency() -> Vec> { build_adjacency(4, &[edge(0, 1, 5), edge(1, 2, 5), edge(2, 3, 5)]) } +#[allow_fixture_expansion_lints] #[fixture] -fn isolated_tail_vectors() -> Vec { - vectors(&["alpha", "beta", "gamma"]) -} +fn isolated_tail_vectors() -> Vec { vectors(&["alpha", "beta", "gamma"]) } +#[allow_fixture_expansion_lints] #[fixture] -fn isolated_tail_adjacency() -> Vec> { - build_adjacency(3, &[edge(0, 1, 5)]) -} +fn isolated_tail_adjacency() -> Vec> { build_adjacency(3, &[edge(0, 1, 5)]) } +#[allow_fixture_expansion_lints] #[fixture] -fn lexical_tie_vectors() -> Vec { - vectors(&["gamma", "alpha", "beta"]) -} +fn lexical_tie_vectors() -> Vec { vectors(&["gamma", "alpha", "beta"]) } #[fixture] fn lexical_tie_adjacency() -> Vec> { @@ -122,7 +119,7 @@ fn propagate_labels_leaves_isolated_nodes_with_original_labels( ) { let report = propagate_labels_report(&isolated_tail_vectors, &isolated_tail_adjacency, 3); - assert_eq!(report.labels[2], 2); + assert_eq!(report.labels.get(2), Some(&2)); } #[rstest] @@ -158,7 +155,7 @@ fn propagate_labels_uses_lexical_tie_break_for_equal_scores( ) { let report = propagate_labels_report(&lexical_tie_vectors, &lexical_tie_adjacency, 1); - assert_eq!(report.labels[0], 1); + assert_eq!(report.labels.first(), Some(&1)); } #[rstest] @@ -168,8 +165,8 @@ fn propagate_labels_prefers_heavier_star_neighbour_when_counts_match() { let report = propagate_labels_report(&vectors, &adjacency, 1); - assert_eq!(report.labels[0], 1); - assert_eq!(report.labels[0], report.labels[1]); + assert_eq!(report.labels.first(), Some(&1)); + assert_eq!(report.labels.first(), report.labels.get(1)); } #[rstest] @@ -179,7 +176,7 @@ fn propagate_labels_prefers_triangle_weight_over_count_tie() { let report = propagate_labels_report(&vectors, &adjacency, 1); - assert_eq!(report.labels[0], 1); + assert_eq!(report.labels.first(), Some(&1)); } #[rstest] diff --git a/common/src/decomposition_advice/tests/test_fixtures.rs b/common/src/decomposition_advice/tests/test_fixtures.rs index 1fc256dd..d58767b2 100644 --- a/common/src/decomposition_advice/tests/test_fixtures.rs +++ b/common/src/decomposition_advice/tests/test_fixtures.rs @@ -1,11 +1,20 @@ //! Shared fixture builders for decomposition-advice unit tests. -use super::super::profile::{DecompositionContext, MethodProfile, SubjectKind}; -use super::super::{DecompositionSuggestion, SuggestedExtractionKind, suggest_decomposition}; +use super::super::{ + DecompositionSuggestion, + SuggestedExtractionKind, + profile::{DecompositionContext, MethodProfile, SubjectKind}, + suggest_decomposition, +}; pub(super) use crate::test_support::decomposition::{ - MethodInput, parser_serde_fs_fixture, profile, + MethodInput, + parser_serde_fs_fixture, + profile, }; +// `Copy` keeps the expectation cheap to pass by value; it only holds +// references. +#[derive(Clone, Copy)] pub(super) struct ExpectedSuggestion<'a> { pub(super) label: &'a str, pub(super) extraction_kind: SuggestedExtractionKind, @@ -21,10 +30,10 @@ pub(super) fn assert_suggestion( assert_eq!(actual.methods(), expected.methods); } -pub(super) fn assert_type_decomposition_is_empty(subject: &str, methods: Vec) { +pub(super) fn assert_type_decomposition_is_empty(subject: &str, methods: &[MethodProfile]) { let context = DecompositionContext::new(subject, SubjectKind::Type); assert!( - suggest_decomposition(&context, &methods).is_empty(), + suggest_decomposition(&context, methods).is_empty(), "expected no decomposition suggestions for {subject}" ); } diff --git a/common/src/decomposition_advice/tests/vector_algebra.rs b/common/src/decomposition_advice/tests/vector_algebra.rs index da36c358..c57eca38 100644 --- a/common/src/decomposition_advice/tests/vector_algebra.rs +++ b/common/src/decomposition_advice/tests/vector_algebra.rs @@ -1,8 +1,9 @@ //! Validates the runtime vector algebra used by decomposition advice. -use crate::decomposition_advice::vector::{dot_product, test_feature_vector}; use rstest::rstest; +use crate::decomposition_advice::vector::{dot_product, test_feature_vector}; + #[rstest] #[case::left_smaller( test_feature_vector("left", &[("field:grammar", 6), ("keyword:parse", 2)]), diff --git a/common/src/decomposition_advice/vector.rs b/common/src/decomposition_advice/vector.rs index 4db3c7f0..9f996738 100644 --- a/common/src/decomposition_advice/vector.rs +++ b/common/src/decomposition_advice/vector.rs @@ -27,7 +27,7 @@ pub(crate) enum FeatureCategory { } impl FeatureCategory { - fn prefix(self) -> &'static str { + const fn prefix(self) -> &'static str { match self { Self::Domain => "domain", Self::Field => "field", @@ -37,7 +37,7 @@ impl FeatureCategory { } } - fn weight(self) -> u64 { + const fn weight(self) -> u64 { match self { Self::Domain => DOMAIN_WEIGHT, Self::Field => FIELD_WEIGHT, @@ -47,7 +47,7 @@ impl FeatureCategory { } } - pub(crate) fn label_priority(self) -> usize { + pub(crate) const fn label_priority(self) -> usize { match self { Self::Domain => 0, Self::Field => 1, @@ -70,13 +70,9 @@ struct FeatureIdentity { } impl FeatureMetadata { - pub(crate) fn category(&self) -> FeatureCategory { - self.category - } + pub(crate) const fn category(&self) -> FeatureCategory { self.category } - pub(crate) fn display(&self) -> &str { - &self.display - } + pub(crate) fn display(&self) -> &str { &self.display } } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -87,17 +83,11 @@ pub(crate) struct MethodFeatureVector { } impl MethodFeatureVector { - pub(crate) fn method_name(&self) -> &str { - &self.method_name - } + pub(crate) fn method_name(&self) -> &str { &self.method_name } - pub(crate) fn weights(&self) -> &BTreeMap { - &self.weights - } + pub(crate) const fn weights(&self) -> &BTreeMap { &self.weights } - pub(crate) fn metadata(&self) -> &BTreeMap { - &self.metadata - } + pub(crate) const fn metadata(&self) -> &BTreeMap { &self.metadata } pub(crate) fn norm_squared(&self) -> u64 { self.weights.values().map(|weight| weight * weight).sum() @@ -199,8 +189,10 @@ pub(crate) fn build_feature_vector(profile: &MethodProfile) -> MethodFeatureVect /// For testing and integration purposes, use the public test support wrapper: /// /// ``` -/// use whitaker_common::MethodProfileBuilder; -/// use whitaker_common::test_support::decomposition::methods_meet_cosine_threshold; +/// use whitaker_common::{ +/// MethodProfileBuilder, +/// test_support::decomposition::methods_meet_cosine_threshold, +/// }; /// /// let mut left_builder = MethodProfileBuilder::new("parse_tokens"); /// left_builder.record_accessed_field("grammar"); @@ -322,9 +314,7 @@ fn add_feature( }); } -fn canonical_feature_value(value: &str) -> String { - value.trim().to_lowercase() -} +fn canonical_feature_value(value: &str) -> String { value.trim().to_lowercase() } fn feature_identity(value: &str) -> FeatureIdentity { let canonical = canonical_feature_value(value); @@ -342,16 +332,20 @@ fn type_identity(type_name: &str) -> FeatureIdentity { } fn should_split_before(chars: &[char], index: usize) -> bool { - if index == 0 { + let Some(previous_index) = index.checked_sub(1) else { return false; - } + }; - let current = chars[index]; + let Some(¤t) = chars.get(index) else { + return false; + }; if !current.is_uppercase() { return false; } - let previous = chars[index - 1]; + let Some(&previous) = chars.get(previous_index) else { + return false; + }; previous.is_lowercase() || chars .get(index + 1) diff --git a/common/src/diagnostics.rs b/common/src/diagnostics.rs index 530cdd96..cdcb7b3c 100644 --- a/common/src/diagnostics.rs +++ b/common/src/diagnostics.rs @@ -1,5 +1,4 @@ //! Ergonomic builders for lint diagnostics and suggestions. -#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use crate::span::SourceSpan; @@ -50,21 +49,15 @@ impl Suggestion { /// Returns the human-readable message. #[must_use] - pub fn message(&self) -> &str { - &self.message - } + pub fn message(&self) -> &str { &self.message } /// Returns the replacement snippet. #[must_use] - pub fn replacement(&self) -> &str { - &self.replacement - } + pub fn replacement(&self) -> &str { &self.replacement } /// Returns the applicability classification. #[must_use] - pub const fn applicability(&self) -> Applicability { - self.applicability - } + pub const fn applicability(&self) -> Applicability { self.applicability } } /// Represents a lint diagnostic with optional notes and suggestions. @@ -81,39 +74,27 @@ pub struct Diagnostic { impl Diagnostic { /// Returns the lint code. #[must_use] - pub fn code(&self) -> &str { - &self.code - } + pub fn code(&self) -> &str { &self.code } /// Returns the primary message. #[must_use] - pub fn message(&self) -> &str { - &self.message - } + pub fn message(&self) -> &str { &self.message } /// Returns the primary span. #[must_use] - pub const fn span(&self) -> SourceSpan { - self.span - } + pub const fn span(&self) -> SourceSpan { self.span } /// Returns additional diagnostic notes. #[must_use] - pub fn notes(&self) -> &[String] { - &self.notes - } + pub fn notes(&self) -> &[String] { &self.notes } /// Returns help messages. #[must_use] - pub fn helps(&self) -> &[String] { - &self.helps - } + pub fn helps(&self) -> &[String] { &self.helps } /// Returns collected suggestions. #[must_use] - pub fn suggestions(&self) -> &[Suggestion] { - &self.suggestions - } + pub fn suggestions(&self) -> &[Suggestion] { &self.suggestions } } /// Builder for [`Diagnostic`] instances. @@ -158,9 +139,7 @@ impl DiagnosticBuilder { /// Completes the builder and returns the diagnostic. #[must_use] - pub fn build(self) -> Diagnostic { - self.diagnostic - } + pub fn build(self) -> Diagnostic { self.diagnostic } } /// Starts building a lint diagnostic for a given span. @@ -168,13 +147,20 @@ impl DiagnosticBuilder { /// # Examples /// /// ``` -/// use whitaker_common::diagnostics::{span_lint, Applicability, Suggestion}; -/// use whitaker_common::span::{SourceLocation, SourceSpan}; +/// use whitaker_common::{ +/// diagnostics::{Applicability, Suggestion, span_lint}, +/// span::{SourceLocation, SourceSpan}, +/// }; /// -/// let span = SourceSpan::new(SourceLocation::new(1, 0), SourceLocation::new(1, 4)).expect("valid span for example"); +/// let span = SourceSpan::new(SourceLocation::new(1, 0), SourceLocation::new(1, 4)) +/// .expect("valid span for example"); /// let diagnostic = span_lint("demo", "Example", span) /// .help("Consider refactoring") -/// .suggestion(Suggestion::new("Use helper", "helper()", Applicability::MaybeIncorrect)) +/// .suggestion(Suggestion::new( +/// "Use helper", +/// "helper()", +/// Applicability::MaybeIncorrect, +/// )) /// .build(); /// assert_eq!(diagnostic.code(), "demo"); /// ``` @@ -189,9 +175,12 @@ pub fn span_lint( #[cfg(test)] mod tests { + //! Tests for diagnostic construction and span rendering helpers. + + use rstest::rstest; + use super::*; use crate::span::{SourceLocation, SourceSpan}; - use rstest::rstest; #[rstest] fn builds_diagnostic() { diff --git a/common/src/dylint_entry.rs b/common/src/dylint_entry.rs new file mode 100644 index 00000000..c3e18f02 --- /dev/null +++ b/common/src/dylint_entry.rs @@ -0,0 +1,47 @@ +//! Macro support for declaring Dylint library entry points. +//! +//! Dylint locates a library's lints by calling an exported `register_lints` +//! symbol, which requires `#[unsafe(no_mangle)]`. Libraries registering more +//! than one lint cannot use `dylint_linting`'s single-lint macros, and the +//! workspace forbids in-crate `unsafe` code, so this macro provides the same +//! externally-expanded escape hatch those macros rely on: the unsafe +//! attribute originates in this crate's macro definition, keeping consumer +//! crates free of unsafe tokens. +//! +//! Scope and reuse policy: intended solely for Whitaker Dylint driver crates +//! (currently `whitaker_suite`) that must export a combined `register_lints` +//! entry point. It must not be used outside Dylint entry-point wiring. + +/// Declares the `register_lints` entry point Dylint resolves from a lint +/// library. +/// +/// The single argument is a path to a function with the signature +/// `fn(&rustc_session::Session, &mut rustc_lint::LintStore)`; it is invoked +/// with the compiler session and lint store whenever Dylint loads the +/// library. The expansion resolves `rustc_session` and `rustc_lint` at the +/// call site, so the invoking crate must have those crates as dependencies. +/// +/// # Examples +/// +/// ```ignore +/// fn register_entry(sess: &rustc_session::Session, store: &mut rustc_lint::LintStore) { +/// dylint_linting::init_config(sess); +/// store.register_lints(MY_LINT_DECLS); +/// } +/// +/// whitaker_common::declare_dylint_register_entry!(register_entry); +/// ``` +#[macro_export] +macro_rules! declare_dylint_register_entry { + ($register:path) => { + /// Dylint entry point that forwards to the library's registration + /// function. + #[unsafe(no_mangle)] + pub fn register_lints( + sess: &rustc_session::Session, + lint_store: &mut rustc_lint::LintStore, + ) { + $register(sess, lint_store); + } + }; +} diff --git a/common/src/expr.rs b/common/src/expr.rs index 88c6aa11..a6a82e1e 100644 --- a/common/src/expr.rs +++ b/common/src/expr.rs @@ -1,5 +1,4 @@ //! Lightweight expression helpers for lint analysis. -#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use crate::path::SimplePath; @@ -7,7 +6,10 @@ use crate::path::SimplePath; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Expr { /// A call expression with a resolved callee path. - Call { callee: SimplePath }, + Call { + /// The resolved path of the function being called. + callee: SimplePath, + }, /// A path expression. Path(SimplePath), /// Any other literal expression (placeholder for expansion). @@ -19,17 +21,23 @@ pub enum Expr { /// # Examples /// /// ``` -/// use whitaker_common::expr::{Expr, def_id_of_expr_callee}; -/// use whitaker_common::path::SimplePath; +/// use whitaker_common::{ +/// expr::{Expr, def_id_of_expr_callee}, +/// path::SimplePath, +/// }; /// -/// let expr = Expr::Call { callee: SimplePath::from("std::mem::drop") }; +/// let expr = Expr::Call { +/// callee: SimplePath::from("std::mem::drop"), +/// }; /// assert_eq!( -/// def_id_of_expr_callee(&expr).expect("call expression has callee path").segments(), +/// def_id_of_expr_callee(&expr) +/// .expect("call expression has callee path") +/// .segments(), /// &["std", "mem", "drop"] /// ); /// ``` #[must_use] -pub fn def_id_of_expr_callee(expr: &Expr) -> Option<&SimplePath> { +pub const fn def_id_of_expr_callee(expr: &Expr) -> Option<&SimplePath> { match expr { Expr::Call { callee } => Some(callee), _ => None, @@ -41,8 +49,7 @@ pub fn def_id_of_expr_callee(expr: &Expr) -> Option<&SimplePath> { /// # Examples /// /// ``` -/// use whitaker_common::expr::is_path_to; -/// use whitaker_common::path::SimplePath; +/// use whitaker_common::{expr::is_path_to, path::SimplePath}; /// /// let path = SimplePath::from("core::option::Option"); /// assert!(is_path_to(&path, ["core", "option", "Option"])); @@ -61,10 +68,11 @@ where /// # Examples /// /// ``` -/// use whitaker_common::expr::recv_is_option_or_result; -/// use whitaker_common::path::SimplePath; +/// use whitaker_common::{expr::recv_is_option_or_result, path::SimplePath}; /// -/// assert!(recv_is_option_or_result(&SimplePath::from("std::option::Option"))); +/// assert!(recv_is_option_or_result(&SimplePath::from( +/// "std::option::Option" +/// ))); /// assert!(recv_is_option_or_result(&SimplePath::from("Result"))); /// assert!(!recv_is_option_or_result(&SimplePath::from("crate::Thing"))); /// ``` @@ -75,9 +83,12 @@ pub fn recv_is_option_or_result(path: &SimplePath) -> bool { #[cfg(test)] mod tests { - use super::*; + //! Tests for expression-inspection helpers used by the lint drivers. + use rstest::rstest; + use super::*; + #[rstest] fn callee_extraction() { let expr = Expr::Call { diff --git a/common/src/i18n/diagnostics.rs b/common/src/i18n/diagnostics.rs index c7eed48d..10fa6a15 100644 --- a/common/src/i18n/diagnostics.rs +++ b/common/src/i18n/diagnostics.rs @@ -8,19 +8,16 @@ //! # }; //! # fn demo(localizer: &Localizer) -> Result<(), whitaker_common::i18n::I18nError> { //! # let args = Arguments::default(); -//! let messages = resolve_message_set( -//! localizer, -//! MessageKey::new("my-lint.message"), -//! &args, -//! )?; +//! let messages = resolve_message_set(localizer, MessageKey::new("my-lint.message"), &args)?; //! # assert!(!messages.primary().is_empty()); //! # Ok(()) //! # } //! ``` -use super::{Arguments, I18nError, Localizer}; use std::fmt; +use super::{Arguments, I18nError, Localizer}; + /// Identifier for a Fluent message within a localization bundle. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct MessageKey<'a>(&'a str); @@ -28,21 +25,15 @@ pub struct MessageKey<'a>(&'a str); impl<'a> MessageKey<'a> { /// Construct a new message key wrapper. #[must_use] - pub const fn new(value: &'a str) -> Self { - Self(value) - } + pub const fn new(value: &'a str) -> Self { Self(value) } } -impl<'a> AsRef for MessageKey<'a> { - fn as_ref(&self) -> &str { - self.0 - } +impl AsRef for MessageKey<'_> { + fn as_ref(&self) -> &str { self.0 } } impl fmt::Display for MessageKey<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0) } } /// Identifier for a Fluent attribute attached to a message. @@ -52,29 +43,33 @@ pub struct AttrKey<'a>(&'a str); impl<'a> AttrKey<'a> { /// Construct a new attribute key wrapper. #[must_use] - pub const fn new(value: &'a str) -> Self { - Self(value) - } + pub const fn new(value: &'a str) -> Self { Self(value) } } -impl<'a> AsRef for AttrKey<'a> { - fn as_ref(&self) -> &str { - self.0 - } +impl AsRef for AttrKey<'_> { + fn as_ref(&self) -> &str { self.0 } } impl fmt::Display for AttrKey<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0) } } /// Lookup trait used by lint crates to resolve translated diagnostic strings. pub trait BundleLookup { /// Resolve the primary message for `key` using `args`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key` is not defined for the + /// resolved locale. fn message(&self, key: MessageKey<'_>, args: &Arguments<'_>) -> Result; /// Resolve an attribute message for `key.attribute` using `args`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key.attribute` is not + /// defined for the resolved locale. fn attribute( &self, key: MessageKey<'_>, @@ -108,20 +103,16 @@ pub struct DiagnosticMessageSet { impl DiagnosticMessageSet { /// Construct a new set of lint diagnostic strings. - #[must_use] /// /// # Examples /// /// ``` /// # use whitaker_common::i18n::DiagnosticMessageSet; - /// let messages = DiagnosticMessageSet::new( - /// "primary".into(), - /// "note".into(), - /// "help".into(), - /// ); + /// let messages = DiagnosticMessageSet::new("primary".into(), "note".into(), "help".into()); /// assert_eq!(messages.primary(), "primary"); /// ``` - pub fn new(primary: String, note: String, help: String) -> Self { + #[must_use] + pub const fn new(primary: String, note: String, help: String) -> Self { Self { primary, note, @@ -130,7 +121,6 @@ impl DiagnosticMessageSet { } /// Access the primary lint diagnostic. - #[must_use] /// /// # Examples /// @@ -143,12 +133,10 @@ impl DiagnosticMessageSet { /// # ); /// assert_eq!(messages.primary(), "primary"); /// ``` - pub fn primary(&self) -> &str { - &self.primary - } + #[must_use] + pub fn primary(&self) -> &str { &self.primary } /// Access the note attached to the diagnostic. - #[must_use] /// /// # Examples /// @@ -161,12 +149,10 @@ impl DiagnosticMessageSet { /// # ); /// assert_eq!(messages.note(), "note"); /// ``` - pub fn note(&self) -> &str { - &self.note - } + #[must_use] + pub fn note(&self) -> &str { &self.note } /// Access the help text attached to the diagnostic. - #[must_use] /// /// # Examples /// @@ -179,9 +165,8 @@ impl DiagnosticMessageSet { /// # ); /// assert_eq!(messages.help(), "help"); /// ``` - pub fn help(&self) -> &str { - &self.help - } + #[must_use] + pub fn help(&self) -> &str { &self.help } /// Remove Unicode isolating marks inserted by Fluent placeholders. pub(crate) fn strip_isolating_marks(self) -> Self { @@ -199,10 +184,15 @@ impl DiagnosticMessageSet { } } -/// Resolve the primary, note, and help messages for a lint diagnostic. const NOTE_ATTR: AttrKey<'static> = AttrKey::new("note"); const HELP_ATTR: AttrKey<'static> = AttrKey::new("help"); +/// Resolve the primary, note, and help messages for a lint diagnostic. +/// +/// # Errors +/// +/// Returns [`I18nError::MissingMessage`] when the primary message, its `note` +/// attribute, or its `help` attribute is not defined for the resolved locale. #[must_use = "Use the resolved localization messages when emitting diagnostics"] pub fn resolve_message_set( lookup: &impl BundleLookup, diff --git a/common/src/i18n/helpers.rs b/common/src/i18n/helpers.rs index 86f5401d..172b5016 100644 --- a/common/src/i18n/helpers.rs +++ b/common/src/i18n/helpers.rs @@ -9,7 +9,12 @@ use std::env; use log::debug; use super::{ - Arguments, DiagnosticMessageSet, Localizer, MessageKey, resolve_localizer, resolve_message_set, + Arguments, + DiagnosticMessageSet, + Localizer, + MessageKey, + resolve_localizer, + resolve_message_set, }; /// Construct a [`Localizer`] for `lint_name` using workspace configuration. @@ -52,11 +57,7 @@ pub fn get_localizer_for_lint(lint_name: &str, configuration_locale: Option<&str /// ``` #[must_use] pub fn branch_phrase(locale: &str, branches: usize) -> String { - match locale - .split_once('-') - .map(|(lang, _)| lang) - .unwrap_or(locale) - { + match locale.split_once('-').map_or(locale, |(lang, _)| lang) { "cy" => welsh_branch_phrase(branches), "gd" => gaelic_branch_phrase(branches), _ => english_branch_phrase(branches), @@ -99,8 +100,13 @@ fn welsh_branch_phrase(branches: usize) -> String { /// /// ``` /// use whitaker_common::i18n::{ -/// Arguments, DiagnosticMessageSet, Localizer, MessageKey, MessageResolution, -/// noop_reporter, safe_resolve_message_set, +/// Arguments, +/// DiagnosticMessageSet, +/// Localizer, +/// MessageKey, +/// MessageResolution, +/// noop_reporter, +/// safe_resolve_message_set, /// }; /// /// let localizer = Localizer::new(Some("en-GB")); @@ -130,13 +136,18 @@ pub fn noop_reporter(_message: String) {} /// # Examples /// /// ``` -/// use whitaker_common::i18n::testing::RecordingEmitter; +/// use std::borrow::Cow; +/// +/// use fluent_templates::fluent_bundle::FluentValue; /// use whitaker_common::i18n::{ -/// Arguments, DiagnosticMessageSet, Localizer, MessageKey, MessageResolution, +/// Arguments, +/// DiagnosticMessageSet, +/// Localizer, +/// MessageKey, +/// MessageResolution, /// safe_resolve_message_set, +/// testing::RecordingEmitter, /// }; -/// use fluent_templates::fluent_bundle::FluentValue; -/// use std::borrow::Cow; /// /// let mut args: Arguments<'static> = Arguments::default(); /// args.insert(Cow::Borrowed("subject"), FluentValue::from("demo")); @@ -207,6 +218,8 @@ pub struct MessageResolution<'a> { #[cfg(test)] mod tests { + //! Tests for localization helper phrases such as branch pluralization. + use super::branch_phrase; use crate::i18n::FALLBACK_LOCALE; diff --git a/common/src/i18n/loader.rs b/common/src/i18n/loader.rs index 3b5cae03..30921b06 100644 --- a/common/src/i18n/loader.rs +++ b/common/src/i18n/loader.rs @@ -4,17 +4,20 @@ //! messages from Fluent bundles, along with supporting types for arguments //! and lookup errors. -use std::borrow::Cow; -use std::collections::HashMap; -use std::str::FromStr; +use std::{borrow::Cow, collections::HashMap, str::FromStr}; use fluent_templates::{Loader, fluent_bundle::FluentValue}; use thiserror::Error; -use super::locales::supports_locale; -use super::{FALLBACK_LANGUAGE, FALLBACK_LITERAL, LOADER, LanguageIdentifier}; +use super::{ + FALLBACK_LANGUAGE, + FALLBACK_LITERAL, + LOADER, + LanguageIdentifier, + locales::supports_locale, +}; -/// HashMap wrapper used when passing Fluent arguments to lookups. +/// `HashMap` wrapper used when passing Fluent arguments to lookups. pub type Arguments<'a> = HashMap, FluentValue<'a>>; /// Error raised when localization data cannot satisfy a caller request. @@ -22,7 +25,12 @@ pub type Arguments<'a> = HashMap, FluentValue<'a>>; pub enum I18nError { /// Raised when the requested message slug is missing for the resolved locale. #[error("message `{key}` missing for locale `{locale}`")] - MissingMessage { key: String, locale: String }, + MissingMessage { + /// The Fluent message key (including any attribute suffix) that was requested. + key: String, + /// The locale tag the lookup was resolved against. + locale: String, + }, } /// Resolve localization messages for a specific locale. @@ -41,7 +49,7 @@ impl Localizer { /// Create a localizer for `locale`, falling back to [`crate::i18n::FALLBACK_LOCALE`]. /// /// ``` - /// use whitaker_common::i18n::{available_locales, Localizer}; + /// use whitaker_common::i18n::{Localizer, available_locales}; /// /// let locale = Localizer::new(Some("cy")); /// assert!(available_locales().contains(&"cy".to_string())); @@ -55,56 +63,69 @@ impl Localizer { #[must_use] pub fn new(locale: Option<&str>) -> Self { match locale { - Some(value) if supports_locale(value) => match LanguageIdentifier::from_str(value) { - Ok(identifier) => { - let language_tag = identifier.to_string(); - - Self { - language: identifier, - language_tag, - fallback_used: false, - } - } - Err(_) => Self::fallback(), - }, + Some(value) if supports_locale(value) => LanguageIdentifier::from_str(value) + .map_or_else( + |_| Self::fallback(), + |identifier| { + let language_tag = identifier.to_string(); + + Self { + language: identifier, + language_tag, + fallback_used: false, + } + }, + ), _ => Self::fallback(), } } /// Return the resolved locale identifier. #[must_use] - pub fn language(&self) -> &LanguageIdentifier { - &self.language - } + pub const fn language(&self) -> &LanguageIdentifier { &self.language } /// Return the resolved locale as a string slice. #[must_use] - pub fn locale(&self) -> &str { - &self.language_tag - } + pub fn locale(&self) -> &str { &self.language_tag } /// Whether the fallback locale was used. #[must_use] - pub fn used_fallback(&self) -> bool { - self.fallback_used - } + pub const fn used_fallback(&self) -> bool { self.fallback_used } /// Fetch the translated message for `key`. - pub fn message(&self, key: &str) -> Result { - self.lookup(key, None, None) - } + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key` is not defined for the + /// resolved locale. + pub fn message(&self, key: &str) -> Result { self.lookup(key, None, None) } /// Fetch the translated message with Fluent arguments. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key` is not defined for the + /// resolved locale. pub fn message_with_args(&self, key: &str, args: &Arguments<'_>) -> Result { self.lookup(key, None, Some(args)) } /// Fetch a translated attribute, e.g. `function.primary`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key.attribute` is not + /// defined for the resolved locale. pub fn attribute(&self, key: &str, attribute: &str) -> Result { self.lookup(key, Some(attribute), None) } /// Fetch a translated attribute with Fluent arguments. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key.attribute` is not + /// defined for the resolved locale. pub fn attribute_with_args( &self, key: &str, @@ -120,17 +141,15 @@ impl Localizer { attribute: Option<&str>, args: Option<&Arguments<'_>>, ) -> Result { - let lookup_key = attribute - .map(|attr| format!("{key}.{attr}")) - .unwrap_or_else(|| key.to_string()); + let lookup_key = attribute.map_or_else(|| key.to_owned(), |attr| format!("{key}.{attr}")); - let maybe_value = match args { - Some(arguments) => { + let maybe_value = args.map_or_else( + || LOADER.try_lookup(&self.language, lookup_key.as_str()), + |arguments| { let owned_arguments = promote_arguments(arguments); LOADER.try_lookup_with_args(&self.language, lookup_key.as_str(), &owned_arguments) - } - None => LOADER.try_lookup(&self.language, lookup_key.as_str()), - }; + }, + ); maybe_value.ok_or_else(|| I18nError::MissingMessage { key: lookup_key, @@ -141,7 +160,7 @@ impl Localizer { fn fallback() -> Self { Self { language: FALLBACK_LANGUAGE.clone(), - language_tag: FALLBACK_LITERAL.to_string(), + language_tag: FALLBACK_LITERAL.to_owned(), fallback_used: true, } } @@ -152,13 +171,13 @@ fn promote_arguments( ) -> HashMap, FluentValue<'static>> { arguments .iter() - .map(|(key, value)| (Cow::Owned(key.as_ref().to_string()), promote_value(value))) + .map(|(key, value)| (Cow::Owned(key.as_ref().to_owned()), promote_value(value))) .collect() } fn promote_value(value: &FluentValue<'_>) -> FluentValue<'static> { match value { - FluentValue::String(text) => FluentValue::String(Cow::Owned(text.as_ref().to_string())), + FluentValue::String(text) => FluentValue::String(Cow::Owned(text.as_ref().to_owned())), FluentValue::Number(number) => FluentValue::Number(number.clone()), FluentValue::Custom(custom) => FluentValue::Custom(custom.duplicate()), FluentValue::None => FluentValue::None, diff --git a/common/src/i18n/locales.rs b/common/src/i18n/locales.rs index f58b0fea..f7f8362e 100644 --- a/common/src/i18n/locales.rs +++ b/common/src/i18n/locales.rs @@ -4,34 +4,32 @@ //! bundles and provides utilities for checking whether a given locale tag is //! supported. -use once_cell::sync::Lazy; - use fluent_templates::{Loader, loader::LanguageIdentifier}; use super::LOADER; -static ALL_LOCALES: Lazy> = Lazy::new(|| { - let mut locales: Vec = LOADER.locales().map(|id| id.to_string()).collect(); +static ALL_LOCALES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let mut locales: Vec = LOADER + .locales() + .map(std::string::ToString::to_string) + .collect(); locales.sort_unstable(); locales }); /// Return a sorted slice of the available locales. #[must_use] -pub fn available_locales() -> &'static [String] { - ALL_LOCALES.as_slice() -} +pub fn available_locales() -> &'static [String] { ALL_LOCALES.as_slice() } /// Check whether a locale tag is supported by the embedded bundles. #[must_use] pub fn supports_locale(locale: &str) -> bool { - match locale.parse::() { - Ok(identifier) => { + locale + .parse::() + .is_ok_and(|identifier| { let canonical = identifier.to_string(); ALL_LOCALES .binary_search_by(|candidate| candidate.as_str().cmp(canonical.as_str())) .is_ok() - } - Err(_) => false, - } + }) } diff --git a/common/src/i18n/mod.rs b/common/src/i18n/mod.rs index 383a426c..d4dc5ad6 100644 --- a/common/src/i18n/mod.rs +++ b/common/src/i18n/mod.rs @@ -12,15 +12,15 @@ //! //! See [`resolve_message_set`] for fetching a lint’s primary/note/help trio. -use fluent_templates::static_loader; use std::path::PathBuf; -use unic_langid::langid; /// Re-export the Fluent value type for constructing diagnostic arguments. /// See [`resolve_message_set`] for loading messages that consume these /// arguments. pub use fluent_templates::fluent_bundle::FluentValue; pub(crate) use fluent_templates::loader::LanguageIdentifier; +use fluent_templates::static_loader; +use unic_langid::langid; const FALLBACK_LITERAL: &str = "en-GB"; /// Directory name used for Fluent locale resources. @@ -35,15 +35,18 @@ static_loader! { }; } +/// Locale tag used when no supported locale is requested (`en-GB`). pub const FALLBACK_LOCALE: &str = FALLBACK_LITERAL; pub(crate) const FALLBACK_LANGUAGE: LanguageIdentifier = langid!("en-GB"); /// Return the crate-local Fluent resource root used by packaging and tests. +#[must_use] pub fn locales_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(LOCALES_DIR_NAME) } /// Return the relative path inside the package tarball for a locale bundle. +#[must_use] pub fn packaged_locale_path(locale: &str, file: &str) -> PathBuf { PathBuf::from(format!( "{}-{}", @@ -56,6 +59,7 @@ pub fn packaged_locale_path(locale: &str, file: &str) -> PathBuf { } /// Return the default packaged locale path for Whitaker diagnostics. +#[must_use] pub fn packaged_fallback_locale_path() -> PathBuf { packaged_locale_path(FALLBACK_LOCALE, LOCALES_FTL_FILE) } @@ -70,15 +74,22 @@ pub mod testing; /// Diagnostic localization helpers. /// See [`resolve_message_set`] for fetching primary, note, and help strings. pub use diagnostics::{ - AttrKey, BundleLookup, DiagnosticMessageSet, MessageKey, resolve_message_set, + AttrKey, + BundleLookup, + DiagnosticMessageSet, + MessageKey, + resolve_message_set, }; pub use helpers::{ - MessageResolution, branch_phrase, get_localizer_for_lint, noop_reporter, + MessageResolution, + branch_phrase, + get_localizer_for_lint, + noop_reporter, safe_resolve_message_set, }; pub use loader::{Arguments, I18nError, Localizer}; pub use locales::{available_locales, supports_locale}; -pub use selection::{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer}; +pub use selection::{LocaleSelection, LocaleSource, normalize_locale, resolve_localizer}; #[cfg(test)] mod tests; diff --git a/common/src/i18n/selection.rs b/common/src/i18n/selection.rs index ce7b0243..73f81692 100644 --- a/common/src/i18n/selection.rs +++ b/common/src/i18n/selection.rs @@ -1,7 +1,7 @@ //! Locale resolver wiring explicit overrides, environment variables, and //! configuration before falling back to the bundled localizer. -use std::fmt; +use std::{borrow::Cow, fmt}; use log::{debug, warn}; @@ -50,39 +50,27 @@ impl LocaleSelection { /// Returns the effective locale source. #[must_use] - pub const fn source(&self) -> LocaleSource { - self.source - } + pub const fn source(&self) -> LocaleSource { self.source } /// Returns the locale requested by the resolved source, if any. #[must_use] - pub fn requested(&self) -> Option<&str> { - self.requested.as_deref() - } + pub fn requested(&self) -> Option<&str> { self.requested.as_deref() } /// Returns the resolved locale tag. #[must_use] - pub fn locale(&self) -> &str { - self.localizer.locale() - } + pub fn locale(&self) -> &str { self.localizer.locale() } /// Whether the fallback locale was used. #[must_use] - pub fn used_fallback(&self) -> bool { - self.localizer.used_fallback() - } + pub const fn used_fallback(&self) -> bool { self.localizer.used_fallback() } /// Returns the resolved [`Localizer`]. #[must_use] - pub fn localizer(&self) -> &Localizer { - &self.localizer - } + pub const fn localizer(&self) -> &Localizer { &self.localizer } /// Consumes the selection, yielding the [`Localizer`]. #[must_use] - pub fn into_localizer(self) -> Localizer { - self.localizer - } + pub fn into_localizer(self) -> Localizer { self.localizer } /// Emit a debug log summarizing the resolved locale. pub fn log_outcome(&self, target: &str) { @@ -97,7 +85,7 @@ impl LocaleSelection { /// Attempt to resolve a locale candidate from the given source. fn try_resolve_candidate(source: LocaleSource, raw: Option<&str>) -> Option { - let candidate = normalise_locale(raw)?; + let candidate = normalize_locale(raw)?; if supports_locale(candidate) { return Some(LocaleSelection::new( @@ -130,28 +118,37 @@ pub fn resolve_localizer( configuration: Option<&str>, ) -> LocaleSelection { let candidates = [ - (LocaleSource::ExplicitArgument, explicit), - (LocaleSource::EnvironmentVariable, environment.as_deref()), - (LocaleSource::Configuration, configuration), + (LocaleSource::ExplicitArgument, explicit.map(Cow::Borrowed)), + ( + LocaleSource::EnvironmentVariable, + environment.map(Cow::Owned), + ), + ( + LocaleSource::Configuration, + configuration.map(Cow::Borrowed), + ), ]; candidates .into_iter() - .find_map(|(source, raw)| try_resolve_candidate(source, raw)) + .find_map(|(source, raw)| try_resolve_candidate(source, raw.as_deref())) .unwrap_or_else(|| LocaleSelection::new(Localizer::new(None), LocaleSource::Fallback, None)) } /// Trim whitespace and discard empty locale candidates. #[must_use] -pub fn normalise_locale(input: Option<&str>) -> Option<&str> { +pub fn normalize_locale(input: Option<&str>) -> Option<&str> { input.map(str::trim).filter(|value| !value.is_empty()) } #[cfg(test)] mod tests { - use super::*; + //! Tests for locale negotiation and fallback selection. + use rstest::rstest; + use super::*; + #[derive(Clone, Copy, Debug)] struct ResolutionCase { explicit: Option<&'static str>, @@ -163,9 +160,7 @@ mod tests { } impl ResolutionCase { - fn environment(&self) -> Option { - self.environment.map(String::from) - } + fn environment(&self) -> Option { self.environment.map(String::from) } } #[rstest] @@ -223,7 +218,7 @@ mod tests { #[case(Some(" "), None)] #[case(Some("cy"), Some("cy"))] #[case(Some(" cy "), Some("cy"))] - fn normalises_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) { - assert_eq!(normalise_locale(input), expected); + fn normalizes_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) { + assert_eq!(normalize_locale(input), expected); } } diff --git a/common/src/i18n/testing.rs b/common/src/i18n/testing.rs index 212faa01..dc88abc1 100644 --- a/common/src/i18n/testing.rs +++ b/common/src/i18n/testing.rs @@ -4,8 +4,7 @@ //! [`RecordingEmitter`] for exercising error paths and verifying diagnostic //! output during localization tests. -use std::borrow::Cow; -use std::cell::RefCell; +use std::{borrow::Cow, cell::RefCell}; pub use super::helpers::{MessageResolution, safe_resolve_message_set}; use super::{Arguments, AttrKey, BundleLookup, I18nError, MessageKey}; @@ -70,12 +69,8 @@ pub struct RecordingEmitter { impl RecordingEmitter { /// Access the recorded messages emitted during localization failures. #[must_use] - pub fn recorded_messages(&self) -> Vec { - self.messages.borrow().clone() - } + pub fn recorded_messages(&self) -> Vec { self.messages.borrow().clone() } /// Record a delayed bug message for later assertions. - pub fn record(&self, message: String) { - self.messages.borrow_mut().push(message); - } + pub fn record(&self, message: String) { self.messages.borrow_mut().push(message); } } diff --git a/common/src/i18n/tests.rs b/common/src/i18n/tests.rs index 27d98a4e..0a6c286f 100644 --- a/common/src/i18n/tests.rs +++ b/common/src/i18n/tests.rs @@ -1,9 +1,18 @@ +//! Tests for the i18n localizer: message lookup, argument substitution, +//! and locale fallback behaviour. + use std::borrow::Cow; -use super::FluentValue; use rstest::rstest; -use super::{Arguments, FALLBACK_LOCALE, Localizer, available_locales, supports_locale}; +use super::{ + Arguments, + FALLBACK_LOCALE, + FluentValue, + Localizer, + available_locales, + supports_locale, +}; #[rstest] #[case(None, FALLBACK_LOCALE, true)] @@ -20,9 +29,9 @@ fn resolves_locales(#[case] input: Option<&str>, #[case] expected: &str, #[case] #[test] fn enumerates_available_locales() { let locales = available_locales(); - assert!(locales.contains(&"en-GB".to_string())); - assert!(locales.contains(&"cy".to_string())); - assert!(locales.contains(&"gd".to_string())); + assert!(locales.contains(&"en-GB".to_owned())); + assert!(locales.contains(&"cy".to_owned())); + assert!(locales.contains(&"gd".to_owned())); } #[test] diff --git a/common/src/lcom4/extract.rs b/common/src/lcom4/extract.rs index 5b58afbb..507be690 100644 --- a/common/src/lcom4/extract.rs +++ b/common/src/lcom4/extract.rs @@ -195,9 +195,10 @@ mod tests { //! rstest-based unit tests for [`super::MethodInfoBuilder`] and //! [`super::collect_method_infos`]. - use super::*; use rstest::rstest; + use super::*; + /// Applies field and call records to a builder and asserts against /// expected sets. fn assert_extraction( @@ -353,10 +354,13 @@ mod tests { let infos = collect_method_infos(vec![b1, b2, b3]); assert_eq!(infos.len(), 3); - assert_eq!(infos[0].name(), "alpha"); - assert_eq!(infos[1].name(), "beta"); - assert_eq!(infos[2].name(), "gamma"); - assert!(infos[0].accessed_fields().contains("x")); - assert!(infos[1].called_methods().contains("alpha")); + let alpha = infos.first().expect("collected infos should include alpha"); + let beta = infos.get(1).expect("collected infos should include beta"); + let gamma = infos.get(2).expect("collected infos should include gamma"); + assert_eq!(alpha.name(), "alpha"); + assert_eq!(beta.name(), "beta"); + assert_eq!(gamma.name(), "gamma"); + assert!(alpha.accessed_fields().contains("x")); + assert!(beta.called_methods().contains("alpha")); } } diff --git a/common/src/lcom4/mod.rs b/common/src/lcom4/mod.rs index 8e8d3adf..ac9961ce 100644 --- a/common/src/lcom4/mod.rs +++ b/common/src/lcom4/mod.rs @@ -28,6 +28,7 @@ use std::collections::{BTreeSet, HashMap}; /// /// ``` /// use std::collections::BTreeSet; +/// /// use whitaker_common::lcom4::MethodInfo; /// /// let method = MethodInfo::new( @@ -55,6 +56,7 @@ impl MethodInfo { /// /// ``` /// use std::collections::BTreeSet; + /// /// use whitaker_common::lcom4::MethodInfo; /// /// let m = MethodInfo::new( @@ -83,15 +85,14 @@ impl MethodInfo { /// /// ``` /// use std::collections::BTreeSet; + /// /// use whitaker_common::lcom4::MethodInfo; /// /// let m = MethodInfo::new("read", BTreeSet::new(), BTreeSet::new()); /// assert_eq!(m.name(), "read"); /// ``` #[must_use] - pub fn name(&self) -> &str { - &self.name - } + pub fn name(&self) -> &str { &self.name } /// Returns the set of field names accessed by this method. /// @@ -99,19 +100,14 @@ impl MethodInfo { /// /// ``` /// use std::collections::BTreeSet; + /// /// use whitaker_common::lcom4::MethodInfo; /// - /// let m = MethodInfo::new( - /// "read", - /// BTreeSet::from(["buf".into()]), - /// BTreeSet::new(), - /// ); + /// let m = MethodInfo::new("read", BTreeSet::from(["buf".into()]), BTreeSet::new()); /// assert!(m.accessed_fields().contains("buf")); /// ``` #[must_use] - pub fn accessed_fields(&self) -> &BTreeSet { - &self.accessed_fields - } + pub const fn accessed_fields(&self) -> &BTreeSet { &self.accessed_fields } /// Returns the set of method names called directly by this method. /// @@ -119,6 +115,7 @@ impl MethodInfo { /// /// ``` /// use std::collections::BTreeSet; + /// /// use whitaker_common::lcom4::MethodInfo; /// /// let m = MethodInfo::new( @@ -129,9 +126,7 @@ impl MethodInfo { /// assert!(m.called_methods().contains("validate")); /// ``` #[must_use] - pub fn called_methods(&self) -> &BTreeSet { - &self.called_methods - } + pub const fn called_methods(&self) -> &BTreeSet { &self.called_methods } } /// Disjoint-set forest for connected component counting. @@ -153,15 +148,29 @@ impl UnionFind { } fn find(&mut self, x: usize) -> usize { - if self.parent[x] != x { - self.parent[x] = self.find(self.parent[x]); + // Out-of-range nodes are treated as their own root; callers only pass + // indices from `0..n`, so the fallback never fires in practice. + let parent = self.parent.get(x).copied().unwrap_or(x); + if parent == x { + return x; } - self.parent[x] + let root = self.find(parent); + self.set_parent(x, root); + root } /// Returns `true` when the first root has strictly lower rank. fn lower_rank(&self, root_a: usize, root_b: usize) -> bool { - self.rank[root_a] < self.rank[root_b] + let rank_a = self.rank.get(root_a).copied().unwrap_or(0); + let rank_b = self.rank.get(root_b).copied().unwrap_or(0); + rank_a < rank_b + } + + /// Repoints `node` at `root`, ignoring out-of-range nodes. + fn set_parent(&mut self, node: usize, root: usize) { + if let Some(parent_slot) = self.parent.get_mut(node) { + *parent_slot = root; + } } fn union(&mut self, x: usize, y: usize) { @@ -171,22 +180,24 @@ impl UnionFind { return; } if self.lower_rank(root_x, root_y) { - self.parent[root_x] = root_y; + self.set_parent(root_x, root_y); } else if self.lower_rank(root_y, root_x) { - self.parent[root_y] = root_x; + self.set_parent(root_y, root_x); } else { - self.parent[root_y] = root_x; - self.rank[root_x] += 1; + self.set_parent(root_y, root_x); + if let Some(rank_slot) = self.rank.get_mut(root_x) { + *rank_slot += 1; + } } } fn component_count(&mut self) -> usize { - let n = self.parent.len(); + let node_count = self.parent.len(); // Flatten the forest so every node points directly to its root. - for i in 0..n { - self.find(i); + for node in 0..node_count { + self.find(node); } - let mut roots: Vec = self.parent[..n].to_vec(); + let mut roots = self.parent.clone(); roots.sort_unstable(); roots.dedup(); roots.len() @@ -269,6 +280,7 @@ fn union_by_method_calls(methods: &[MethodInfo], uf: &mut UnionFind) { /// /// ``` /// use std::collections::BTreeSet; +/// /// use whitaker_common::lcom4::{MethodInfo, cohesion_components}; /// /// let methods = vec![ @@ -281,6 +293,7 @@ fn union_by_method_calls(methods: &[MethodInfo], uf: &mut UnionFind) { /// /// ``` /// use std::collections::BTreeSet; +/// /// use whitaker_common::lcom4::{MethodInfo, cohesion_components}; /// /// let methods = vec![ diff --git a/common/src/lcom4/tests.rs b/common/src/lcom4/tests.rs index 83b57cd0..1105e210 100644 --- a/common/src/lcom4/tests.rs +++ b/common/src/lcom4/tests.rs @@ -1,9 +1,11 @@ //! rstest-based unit tests for [`super::cohesion_components`] and supporting types. -use super::*; -use rstest::rstest; use std::collections::BTreeSet; +use rstest::rstest; + +use super::*; + // --- Fixtures (shared setup) --- #[rstest::fixture] @@ -11,7 +13,7 @@ fn method_with_fields() -> fn(&str, &[&str]) -> MethodInfo { |name: &str, fields: &[&str]| -> MethodInfo { MethodInfo::new( name, - fields.iter().map(|s| (*s).to_string()).collect(), + fields.iter().map(|s| (*s).to_owned()).collect(), BTreeSet::new(), ) } @@ -23,7 +25,7 @@ fn method_with_calls() -> fn(&str, &[&str]) -> MethodInfo { MethodInfo::new( name, BTreeSet::new(), - calls.iter().map(|s| (*s).to_string()).collect(), + calls.iter().map(|s| (*s).to_owned()).collect(), ) } } @@ -33,8 +35,8 @@ fn method_with_fields_and_calls() -> fn(&str, &[&str], &[&str]) -> MethodInfo { |name: &str, fields: &[&str], calls: &[&str]| -> MethodInfo { MethodInfo::new( name, - fields.iter().map(|s| (*s).to_string()).collect(), - calls.iter().map(|s| (*s).to_string()).collect(), + fields.iter().map(|s| (*s).to_owned()).collect(), + calls.iter().map(|s| (*s).to_owned()).collect(), ) } } diff --git a/common/src/lib.rs b/common/src/lib.rs index bb7214a0..4f102dd6 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -9,6 +9,7 @@ pub mod complexity_signal; pub mod context; pub mod decomposition_advice; pub mod diagnostics; +pub mod dylint_entry; pub mod expr; pub mod i18n; pub mod lcom4; @@ -18,47 +19,114 @@ pub mod span; pub mod test_support; pub use attributes::{ - Attribute, AttributeKind, AttributePath, PARSED_ATTRIBUTE_PLACEHOLDER, has_test_like_attribute, - has_test_like_attribute_with, outer_attributes, split_doc_attributes, -}; -pub use brain_trait_metrics::evaluation::{ - BrainTraitDiagnostic, BrainTraitDisposition, BrainTraitThresholds, BrainTraitThresholdsBuilder, - evaluate_brain_trait, + Attribute, + AttributeKind, + AttributePath, + PARSED_ATTRIBUTE_PLACEHOLDER, + has_test_like_attribute, + has_test_like_attribute_with, + outer_attributes, + split_doc_attributes, }; pub use brain_trait_metrics::{ - TraitItemKind, TraitItemMetrics, TraitMetrics, TraitMetricsBuilder, default_method_cc_sum, - default_method_count, required_method_count, trait_item_count, -}; -pub use brain_type_metrics::evaluation::{ - BrainTypeDiagnostic, BrainTypeDisposition, BrainTypeThresholds, BrainTypeThresholdsBuilder, - evaluate_brain_type, format_help, format_note, format_primary_message, + TraitItemKind, + TraitItemMetrics, + TraitMetrics, + TraitMetricsBuilder, + default_method_cc_sum, + default_method_count, + evaluation::{ + BrainTraitDiagnostic, + BrainTraitDisposition, + BrainTraitThresholds, + BrainTraitThresholdsBuilder, + evaluate_brain_trait, + }, + required_method_count, + trait_item_count, }; pub use brain_type_metrics::{ - CognitiveComplexityBuilder, ForeignReferenceSet, MethodMetrics, TypeMetrics, - TypeMetricsBuilder, brain_methods, foreign_reach_count, weighted_methods_count, + CognitiveComplexityBuilder, + ForeignReferenceSet, + MethodMetrics, + TypeMetrics, + TypeMetricsBuilder, + brain_methods, + evaluation::{ + BrainTypeDiagnostic, + BrainTypeDisposition, + BrainTypeThresholds, + BrainTypeThresholdsBuilder, + evaluate_brain_type, + format_help, + format_note, + format_primary_message, + }, + foreign_reach_count, + weighted_methods_count, }; pub use context::{ - ContextEntry, ContextKind, in_test_like_context, in_test_like_context_with, is_in_main_fn, - is_test_fn, is_test_fn_with, + ContextEntry, + ContextKind, + in_test_like_context, + in_test_like_context_with, + is_in_main_fn, + is_test_fn, + is_test_fn_with, }; pub use decomposition_advice::{ - DecompositionContext, DecompositionSuggestion, MethodProfile, MethodProfileBuilder, - SubjectKind, SuggestedExtractionKind, format_diagnostic_note, suggest_decomposition, + DecompositionContext, + DecompositionSuggestion, + MethodProfile, + MethodProfileBuilder, + SubjectKind, + SuggestedExtractionKind, + format_diagnostic_note, + suggest_decomposition, }; pub use diagnostics::{Applicability, Diagnostic, DiagnosticBuilder, Suggestion, span_lint}; pub use expr::{Expr, def_id_of_expr_callee, is_path_to, recv_is_option_or_result}; pub use i18n::{ - Arguments, FALLBACK_LOCALE, I18nError, LocaleSelection, LocaleSource, Localizer, - MessageResolution, available_locales, branch_phrase, get_localizer_for_lint, noop_reporter, - normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale, + Arguments, + FALLBACK_LOCALE, + I18nError, + LocaleSelection, + LocaleSource, + Localizer, + MessageResolution, + available_locales, + branch_phrase, + get_localizer_for_lint, + noop_reporter, + normalize_locale, + resolve_localizer, + safe_resolve_message_set, + supports_locale, }; pub use lcom4::{MethodInfo, MethodInfoBuilder, cohesion_components, collect_method_infos}; pub use path::SimplePath; pub use rstest::{ - ArgAtom, ArgFingerprint, CalleeShape, ExpansionTrace, ExprShape, LocalSlot, - ParagraphFingerprint, ParagraphNormalizer, ParameterBinding, RstestDetectionOptions, - RstestParameter, RstestParameterKind, SpanRecoveryFrame, StmtShape, UserEditableSpan, - classify_rstest_parameter, fixture_local_names, is_rstest_fixture, is_rstest_fixture_with, - is_rstest_test, is_rstest_test_with, recover_user_editable_span, + ArgAtom, + ArgFingerprint, + CalleeShape, + ExpansionTrace, + ExprShape, + LocalSlot, + ParagraphFingerprint, + ParagraphNormalizer, + ParameterBinding, + RstestDetectionOptions, + RstestParameter, + RstestParameterKind, + SpanRecoveryFrame, + StmtShape, + UserEditableSpan, + classify_rstest_parameter, + fixture_local_names, + is_rstest_fixture, + is_rstest_fixture_with, + is_rstest_test, + is_rstest_test_with, + recover_user_editable_span, }; pub use span::{SourceLocation, SourceSpan, SpanError, span_line_count, span_to_lines}; diff --git a/common/src/path.rs b/common/src/path.rs index 7bca6e56..78e6561a 100644 --- a/common/src/path.rs +++ b/common/src/path.rs @@ -70,9 +70,11 @@ impl SimplePath { let mut candidate_iter = candidate.into_iter(); for expected in &self.segments { - match candidate_iter.next() { - Some(candidate_segment) if expected == candidate_segment.as_ref() => continue, - _ => return false, + let Some(candidate_segment) = candidate_iter.next() else { + return false; + }; + if expected != candidate_segment.as_ref() { + return false; } } @@ -86,15 +88,11 @@ impl SimplePath { } impl From<&str> for SimplePath { - fn from(path: &str) -> Self { - Self::parse(path) - } + fn from(path: &str) -> Self { Self::parse(path) } } impl From for SimplePath { - fn from(path: String) -> Self { - Self::parse(&path) - } + fn from(path: String) -> Self { Self::parse(&path) } } impl fmt::Display for SimplePath { @@ -105,10 +103,14 @@ impl fmt::Display for SimplePath { #[cfg(test)] mod tests { - use super::*; - use rstest::rstest; + //! Tests for path normalization and module-path utilities. + use std::collections::VecDeque; + use rstest::rstest; + + use super::*; + #[rstest] fn filters_empty_segments() { let path = SimplePath::from("::crate::::Item::"); diff --git a/common/src/rstest/argument_fingerprint.rs b/common/src/rstest/argument_fingerprint.rs index b18cdc3d..5df065d0 100644 --- a/common/src/rstest/argument_fingerprint.rs +++ b/common/src/rstest/argument_fingerprint.rs @@ -4,11 +4,20 @@ #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ArgAtom { /// An argument supplied by an `rstest` fixture-local parameter. - FixtureLocal { name: String }, + FixtureLocal { + /// The fixture-local parameter name as written in the test signature. + name: String, + }, /// A stable literal argument, stored as canonical source text. - ConstLit { text: String }, + ConstLit { + /// Canonical source text of the literal. + text: String, + }, /// A stable constant path, stored as a canonical definition path. - ConstPath { def_path: String }, + ConstPath { + /// Canonical definition path of the referenced constant. + def_path: String, + }, /// A present argument shape that later lowering does not support. Unsupported, } @@ -22,7 +31,12 @@ impl ArgAtom { /// use whitaker_common::rstest::ArgAtom; /// /// let atom = ArgAtom::fixture_local("db"); - /// assert_eq!(atom, ArgAtom::FixtureLocal { name: "db".to_string() }); + /// assert_eq!( + /// atom, + /// ArgAtom::FixtureLocal { + /// name: "db".to_string() + /// } + /// ); /// ``` #[must_use] pub fn fixture_local(name: impl Into) -> Self { @@ -37,12 +51,15 @@ impl ArgAtom { /// use whitaker_common::rstest::ArgAtom; /// /// let atom = ArgAtom::const_lit("42"); - /// assert_eq!(atom, ArgAtom::ConstLit { text: "42".to_string() }); + /// assert_eq!( + /// atom, + /// ArgAtom::ConstLit { + /// text: "42".to_string() + /// } + /// ); /// ``` #[must_use] - pub fn const_lit(text: impl Into) -> Self { - Self::ConstLit { text: text.into() } - } + pub fn const_lit(text: impl Into) -> Self { Self::ConstLit { text: text.into() } } /// Builds a stable constant-path argument atom. /// @@ -76,9 +93,7 @@ impl ArgAtom { /// assert_eq!(ArgAtom::unsupported(), ArgAtom::Unsupported); /// ``` #[must_use] - pub const fn unsupported() -> Self { - Self::Unsupported - } + pub const fn unsupported() -> Self { Self::Unsupported } } /// A positional fingerprint for one helper-call argument list. @@ -95,10 +110,7 @@ impl ArgFingerprint { /// ``` /// use whitaker_common::rstest::{ArgAtom, ArgFingerprint}; /// - /// let fingerprint = ArgFingerprint::new([ - /// ArgAtom::fixture_local("db"), - /// ArgAtom::const_lit("42"), - /// ]); + /// let fingerprint = ArgFingerprint::new([ArgAtom::fixture_local("db"), ArgAtom::const_lit("42")]); /// /// assert_eq!(fingerprint.atoms().len(), 2); /// ``` @@ -114,13 +126,9 @@ impl ArgFingerprint { /// Returns the stored atoms in positional order. #[must_use] - pub fn atoms(&self) -> &[ArgAtom] { - &self.atoms - } + pub fn atoms(&self) -> &[ArgAtom] { &self.atoms } /// Consumes the fingerprint and returns the stored atoms. #[must_use] - pub fn into_atoms(self) -> Vec { - self.atoms - } + pub fn into_atoms(self) -> Vec { self.atoms } } diff --git a/common/src/rstest/detection.rs b/common/src/rstest/detection.rs index b382307f..7dc80e80 100644 --- a/common/src/rstest/detection.rs +++ b/common/src/rstest/detection.rs @@ -30,8 +30,7 @@ impl ExpansionTrace { /// # Examples /// /// ``` - /// use whitaker_common::attributes::AttributePath; - /// use whitaker_common::rstest::ExpansionTrace; + /// use whitaker_common::{attributes::AttributePath, rstest::ExpansionTrace}; /// /// let trace = ExpansionTrace::new([AttributePath::from("rstest")]); /// assert_eq!(trace.frames(), &[AttributePath::from("rstest")]); @@ -48,9 +47,7 @@ impl ExpansionTrace { /// Returns the stored expansion frames. #[must_use] - pub fn frames(&self) -> &[AttributePath] { - &self.frames - } + pub fn frames(&self) -> &[AttributePath] { &self.frames } } /// Runtime options for strict `rstest` detection. @@ -66,17 +63,19 @@ impl RstestDetectionOptions { /// # Examples /// /// ``` - /// use whitaker_common::attributes::AttributePath; - /// use whitaker_common::rstest::RstestDetectionOptions; + /// use whitaker_common::{attributes::AttributePath, rstest::RstestDetectionOptions}; /// /// let options = RstestDetectionOptions::new( - /// vec![AttributePath::from("case"), AttributePath::from("rstest::case")], + /// vec![ + /// AttributePath::from("case"), + /// AttributePath::from("rstest::case"), + /// ], /// true, /// ); /// assert!(options.use_expansion_trace_fallback()); /// ``` #[must_use] - pub fn new( + pub const fn new( provider_param_attributes: Vec, use_expansion_trace_fallback: bool, ) -> Self { @@ -88,15 +87,11 @@ impl RstestDetectionOptions { /// Returns the configured provider-parameter attribute paths. #[must_use] - pub fn provider_param_attributes(&self) -> &[AttributePath] { - &self.provider_param_attributes - } + pub fn provider_param_attributes(&self) -> &[AttributePath] { &self.provider_param_attributes } /// Returns whether expansion-trace fallback is enabled. #[must_use] - pub const fn use_expansion_trace_fallback(&self) -> bool { - self.use_expansion_trace_fallback - } + pub const fn use_expansion_trace_fallback(&self) -> bool { self.use_expansion_trace_fallback } } impl Default for RstestDetectionOptions { @@ -117,10 +112,15 @@ impl Default for RstestDetectionOptions { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::rstest::is_rstest_test; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// rstest::is_rstest_test, +/// }; /// -/// let attrs = vec![Attribute::new(AttributePath::from("rstest"), AttributeKind::Outer)]; +/// let attrs = vec![Attribute::new( +/// AttributePath::from("rstest"), +/// AttributeKind::Outer, +/// )]; /// assert!(is_rstest_test(&attrs)); /// ``` #[must_use] @@ -134,10 +134,15 @@ pub fn is_rstest_test(attrs: &[Attribute]) -> bool { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::rstest::{ExpansionTrace, RstestDetectionOptions, is_rstest_test_with}; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// rstest::{ExpansionTrace, RstestDetectionOptions, is_rstest_test_with}, +/// }; /// -/// let attrs = vec![Attribute::new(AttributePath::from("allow"), AttributeKind::Outer)]; +/// let attrs = vec![Attribute::new( +/// AttributePath::from("allow"), +/// AttributeKind::Outer, +/// )]; /// let trace = ExpansionTrace::new([AttributePath::from("rstest")]); /// let options = RstestDetectionOptions::new(Vec::new(), true); /// assert!(is_rstest_test_with(&attrs, Some(&trace), &options)); @@ -157,10 +162,15 @@ pub fn is_rstest_test_with( /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::rstest::is_rstest_fixture; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// rstest::is_rstest_fixture, +/// }; /// -/// let attrs = vec![Attribute::new(AttributePath::from("fixture"), AttributeKind::Outer)]; +/// let attrs = vec![Attribute::new( +/// AttributePath::from("fixture"), +/// AttributeKind::Outer, +/// )]; /// assert!(is_rstest_fixture(&attrs)); /// ``` #[must_use] @@ -174,10 +184,15 @@ pub fn is_rstest_fixture(attrs: &[Attribute]) -> bool { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::rstest::{ExpansionTrace, RstestDetectionOptions, is_rstest_fixture_with}; +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// rstest::{ExpansionTrace, RstestDetectionOptions, is_rstest_fixture_with}, +/// }; /// -/// let attrs = vec![Attribute::new(AttributePath::from("allow"), AttributeKind::Outer)]; +/// let attrs = vec![Attribute::new( +/// AttributePath::from("allow"), +/// AttributeKind::Outer, +/// )]; /// let trace = ExpansionTrace::new([AttributePath::from("rstest::fixture")]); /// let options = RstestDetectionOptions::new(Vec::new(), true); /// assert!(is_rstest_fixture_with(&attrs, Some(&trace), &options)); @@ -199,7 +214,7 @@ fn matches_direct_or_trace( ) -> bool { has_matching_attribute(attrs, candidates) || (options.use_expansion_trace_fallback() - && trace.is_some_and(|trace| has_matching_trace(trace, candidates))) + && trace.is_some_and(|expansion| has_matching_trace(expansion, candidates))) } fn has_matching_attribute(attrs: &[Attribute], candidates: &[&[&str]]) -> bool { diff --git a/common/src/rstest/mod.rs b/common/src/rstest/mod.rs index 279ba547..0be2cdd8 100644 --- a/common/src/rstest/mod.rs +++ b/common/src/rstest/mod.rs @@ -18,14 +18,26 @@ mod span; pub use argument_fingerprint::{ArgAtom, ArgFingerprint}; pub use detection::{ - ExpansionTrace, RstestDetectionOptions, is_rstest_fixture, is_rstest_fixture_with, - is_rstest_test, is_rstest_test_with, + ExpansionTrace, + RstestDetectionOptions, + is_rstest_fixture, + is_rstest_fixture_with, + is_rstest_test, + is_rstest_test_with, }; pub use paragraph_fingerprint::{ - CalleeShape, ExprShape, LocalSlot, ParagraphFingerprint, ParagraphNormalizer, StmtShape, + CalleeShape, + ExprShape, + LocalSlot, + ParagraphFingerprint, + ParagraphNormalizer, + StmtShape, }; pub use parameter::{ - ParameterBinding, RstestParameter, RstestParameterKind, classify_rstest_parameter, + ParameterBinding, + RstestParameter, + RstestParameterKind, + classify_rstest_parameter, fixture_local_names, }; pub use span::{SpanRecoveryFrame, UserEditableSpan, recover_user_editable_span}; diff --git a/common/src/rstest/paragraph_fingerprint.rs b/common/src/rstest/paragraph_fingerprint.rs index 787295f2..6dff0fdd 100644 --- a/common/src/rstest/paragraph_fingerprint.rs +++ b/common/src/rstest/paragraph_fingerprint.rs @@ -18,15 +18,11 @@ impl LocalSlot { /// assert_eq!(slot.index(), 0); /// ``` #[must_use] - pub const fn new(index: u32) -> Self { - Self(index) - } + pub const fn new(index: u32) -> Self { Self(index) } /// Returns the stable slot ordinal. #[must_use] - pub const fn index(self) -> u32 { - self.0 - } + pub const fn index(self) -> u32 { self.0 } } /// Assigns deterministic local slots by first appearance order. @@ -64,14 +60,14 @@ impl ParagraphNormalizer { /// reuse the original slot. #[must_use] pub fn local_slot(&mut self, local_name: impl Into) -> LocalSlot { - let local_name = local_name.into(); - if let Some(slot) = self.slots.get(&local_name) { + let owned_name = local_name.into(); + if let Some(slot) = self.slots.get(&owned_name) { return *slot; } let slot = LocalSlot::new(self.next_slot); self.next_slot += 1; - self.slots.insert(local_name, slot); + self.slots.insert(owned_name, slot); slot } } @@ -97,9 +93,7 @@ impl CalleeShape { /// assert_eq!(callee, CalleeShape::DefPath("crate::make_user".to_string())); /// ``` #[must_use] - pub fn def_path(def_path: impl Into) -> Self { - Self::DefPath(def_path.into()) - } + pub fn def_path(def_path: impl Into) -> Self { Self::DefPath(def_path.into()) } /// Builds an unknown callee shape. /// @@ -111,18 +105,26 @@ impl CalleeShape { /// assert_eq!(CalleeShape::unknown(), CalleeShape::Unknown); /// ``` #[must_use] - pub const fn unknown() -> Self { - Self::Unknown - } + pub const fn unknown() -> Self { Self::Unknown } } /// A normalized expression shape used by paragraph statements. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ExprShape { /// A function call with known or unknown callee identity and arity. - Call { callee: CalleeShape, argc: usize }, + Call { + /// The normalized callee identity. + callee: CalleeShape, + /// The number of call arguments. + argc: usize, + }, /// A method call with method name and arity. - MethodCall { method: String, argc: usize }, + MethodCall { + /// The method name as written at the call site. + method: String, + /// The number of call arguments, excluding the receiver. + argc: usize, + }, /// A stable path expression. Path, /// A stable literal expression. @@ -149,9 +151,7 @@ impl ExprShape { /// ); /// ``` #[must_use] - pub const fn call(callee: CalleeShape, argc: usize) -> Self { - Self::Call { callee, argc } - } + pub const fn call(callee: CalleeShape, argc: usize) -> Self { Self::Call { callee, argc } } /// Builds a method-call expression shape. #[must_use] @@ -164,31 +164,31 @@ impl ExprShape { /// Builds a path expression shape. #[must_use] - pub const fn path() -> Self { - Self::Path - } + pub const fn path() -> Self { Self::Path } /// Builds a literal expression shape. #[must_use] - pub const fn lit() -> Self { - Self::Lit - } + pub const fn lit() -> Self { Self::Lit } /// Builds an explicit unsupported expression shape. #[must_use] - pub const fn other() -> Self { - Self::Other - } + pub const fn other() -> Self { Self::Other } } /// A normalized statement shape used for paragraph grouping. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum StmtShape { /// A `let` statement represented by its initializer shape. - Let { init: ExprShape }, + Let { + /// The normalized shape of the initializer expression. + init: ExprShape, + }, /// A mutating call, optionally tied to a normalized local receiver slot. MutCall { + /// The normalized slot of the local receiver, when the receiver is a + /// known local binding. receiver: Option, + /// The normalized callee identity. callee: CalleeShape, }, } @@ -203,13 +203,13 @@ impl StmtShape { /// /// assert_eq!( /// StmtShape::let_binding(ExprShape::lit()), - /// StmtShape::Let { init: ExprShape::Lit }, + /// StmtShape::Let { + /// init: ExprShape::Lit + /// }, /// ); /// ``` #[must_use] - pub const fn let_binding(init: ExprShape) -> Self { - Self::Let { init } - } + pub const fn let_binding(init: ExprShape) -> Self { Self::Let { init } } /// Builds a mutating-call statement shape. #[must_use] @@ -231,7 +231,10 @@ impl ParagraphFingerprint { /// /// ``` /// use whitaker_common::rstest::{ - /// CalleeShape, ExprShape, ParagraphFingerprint, ParagraphNormalizer, + /// CalleeShape, + /// ExprShape, + /// ParagraphFingerprint, + /// ParagraphNormalizer, /// StmtShape, /// }; /// @@ -261,13 +264,9 @@ impl ParagraphFingerprint { /// Returns the stored statement shapes in paragraph order. #[must_use] - pub fn shapes(&self) -> &[StmtShape] { - &self.shapes - } + pub fn shapes(&self) -> &[StmtShape] { &self.shapes } /// Consumes the fingerprint and returns the stored statement shapes. #[must_use] - pub fn into_shapes(self) -> Vec { - self.shapes - } + pub fn into_shapes(self) -> Vec { self.shapes } } diff --git a/common/src/rstest/parameter.rs b/common/src/rstest/parameter.rs index fe4e0337..2a2f3769 100644 --- a/common/src/rstest/parameter.rs +++ b/common/src/rstest/parameter.rs @@ -1,8 +1,9 @@ //! Pure parameter classification helpers for `rstest`-driven functions. +use std::collections::BTreeSet; + use super::RstestDetectionOptions; use crate::attributes::Attribute; -use std::collections::BTreeSet; /// Represents the supported parameter binding shapes for version-one `rstest` /// classification. @@ -27,17 +28,22 @@ impl RstestParameter { /// # Examples /// /// ``` - /// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; - /// use whitaker_common::rstest::{ParameterBinding, RstestParameter}; + /// use whitaker_common::{ + /// attributes::{Attribute, AttributeKind, AttributePath}, + /// rstest::{ParameterBinding, RstestParameter}, + /// }; /// /// let parameter = RstestParameter::new( /// ParameterBinding::Ident("db".to_string()), - /// vec![Attribute::new(AttributePath::from("case"), AttributeKind::Outer)], + /// vec![Attribute::new( + /// AttributePath::from("case"), + /// AttributeKind::Outer, + /// )], /// ); /// assert_eq!(parameter.attributes().len(), 1); /// ``` #[must_use] - pub fn new(binding: ParameterBinding, attributes: Vec) -> Self { + pub const fn new(binding: ParameterBinding, attributes: Vec) -> Self { Self { binding, attributes, @@ -70,21 +76,15 @@ impl RstestParameter { /// assert_eq!(parameter.binding_name(), None); /// ``` #[must_use] - pub fn unsupported() -> Self { - Self::new(ParameterBinding::Unsupported, Vec::new()) - } + pub const fn unsupported() -> Self { Self::new(ParameterBinding::Unsupported, Vec::new()) } /// Returns the binding metadata. #[must_use] - pub const fn binding(&self) -> &ParameterBinding { - &self.binding - } + pub const fn binding(&self) -> &ParameterBinding { &self.binding } /// Returns the parameter attributes. #[must_use] - pub fn attributes(&self) -> &[Attribute] { - &self.attributes - } + pub fn attributes(&self) -> &[Attribute] { &self.attributes } /// Returns the identifier binding name when available. #[must_use] @@ -100,7 +100,10 @@ impl RstestParameter { #[derive(Clone, Debug, PartialEq, Eq)] pub enum RstestParameterKind { /// A fixture-local identifier binding. - FixtureLocal { name: String }, + FixtureLocal { + /// The identifier bound by the fixture parameter. + name: String, + }, /// A provider-driven input such as `#[case]` or `#[values]`. Provider, /// A binding shape that version one does not support. @@ -112,14 +115,22 @@ pub enum RstestParameterKind { /// # Examples /// /// ``` -/// use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -/// use whitaker_common::rstest::{ -/// RstestDetectionOptions, RstestParameter, RstestParameterKind, classify_rstest_parameter, +/// use whitaker_common::{ +/// attributes::{Attribute, AttributeKind, AttributePath}, +/// rstest::{ +/// RstestDetectionOptions, +/// RstestParameter, +/// RstestParameterKind, +/// classify_rstest_parameter, +/// }, /// }; /// /// let parameter = RstestParameter::new( /// whitaker_common::rstest::ParameterBinding::Ident("db".to_string()), -/// vec![Attribute::new(AttributePath::from("case"), AttributeKind::Outer)], +/// vec![Attribute::new( +/// AttributePath::from("case"), +/// AttributeKind::Outer, +/// )], /// ); /// let kind = classify_rstest_parameter(¶meter, &RstestDetectionOptions::default()); /// assert_eq!(kind, RstestParameterKind::Provider); @@ -148,7 +159,10 @@ pub fn classify_rstest_parameter( /// ``` /// use whitaker_common::rstest::{RstestDetectionOptions, RstestParameter, fixture_local_names}; /// -/// let parameters = vec![RstestParameter::ident("db"), RstestParameter::ident("clock")]; +/// let parameters = vec![ +/// RstestParameter::ident("db"), +/// RstestParameter::ident("clock"), +/// ]; /// let names = fixture_local_names(¶meters, &RstestDetectionOptions::default()); /// assert!(names.contains("db")); /// assert!(names.contains("clock")); diff --git a/common/src/rstest/span.rs b/common/src/rstest/span.rs index 7dfc4175..fa521e26 100644 --- a/common/src/rstest/span.rs +++ b/common/src/rstest/span.rs @@ -13,8 +13,10 @@ impl SpanRecoveryFrame { /// # Examples /// /// ``` - /// use whitaker_common::rstest::SpanRecoveryFrame; - /// use whitaker_common::span::{SourceLocation, SourceSpan}; + /// use whitaker_common::{ + /// rstest::SpanRecoveryFrame, + /// span::{SourceLocation, SourceSpan}, + /// }; /// /// let span = SourceSpan::new(SourceLocation::new(3, 1), SourceLocation::new(3, 8)) /// .expect("example span should be valid"); @@ -32,21 +34,15 @@ impl SpanRecoveryFrame { /// Returns the stored frame value. #[must_use] - pub const fn value(&self) -> &T { - &self.value - } + pub const fn value(&self) -> &T { &self.value } /// Consumes the frame and returns the stored value. #[must_use] - pub fn into_value(self) -> T { - self.value - } + pub fn into_value(self) -> T { self.value } /// Returns whether the frame still originates from macro expansion. #[must_use] - pub const fn from_expansion(&self) -> bool { - self.from_expansion - } + pub const fn from_expansion(&self) -> bool { self.from_expansion } } /// The result of recovering a user-editable span from an ordered frame chain. @@ -66,14 +62,19 @@ impl UserEditableSpan { /// # Examples /// /// ``` - /// use whitaker_common::rstest::UserEditableSpan; - /// use whitaker_common::span::{SourceLocation, SourceSpan}; + /// use whitaker_common::{ + /// rstest::UserEditableSpan, + /// span::{SourceLocation, SourceSpan}, + /// }; /// /// let span = SourceSpan::new(SourceLocation::new(5, 1), SourceLocation::new(5, 7)) /// .expect("example span should be valid"); /// /// assert_eq!(UserEditableSpan::Recovered(span).into_option(), Some(span)); - /// assert_eq!(UserEditableSpan::::MacroOnly.into_option(), None); + /// assert_eq!( + /// UserEditableSpan::::MacroOnly.into_option(), + /// None + /// ); /// ``` #[must_use] pub fn into_option(self) -> Option { @@ -92,8 +93,10 @@ impl UserEditableSpan { /// # Examples /// /// ``` -/// use whitaker_common::rstest::{SpanRecoveryFrame, UserEditableSpan, recover_user_editable_span}; -/// use whitaker_common::span::{SourceLocation, SourceSpan}; +/// use whitaker_common::{ +/// rstest::{SpanRecoveryFrame, UserEditableSpan, recover_user_editable_span}, +/// span::{SourceLocation, SourceSpan}, +/// }; /// /// let macro_span = SourceSpan::new(SourceLocation::new(2, 1), SourceLocation::new(2, 5)) /// .expect("example span should be valid"); diff --git a/common/src/rstest/tests/fingerprint.rs b/common/src/rstest/tests/fingerprint.rs index 563c3900..fdbf96d1 100644 --- a/common/src/rstest/tests/fingerprint.rs +++ b/common/src/rstest/tests/fingerprint.rs @@ -1,10 +1,17 @@ //! Unit tests for shared `rstest` fingerprint data models. +use rstest::rstest; + use crate::rstest::{ - ArgAtom, ArgFingerprint, CalleeShape, ExprShape, LocalSlot, ParagraphFingerprint, - ParagraphNormalizer, StmtShape, + ArgAtom, + ArgFingerprint, + CalleeShape, + ExprShape, + LocalSlot, + ParagraphFingerprint, + ParagraphNormalizer, + StmtShape, }; -use rstest::rstest; #[rstest] fn argument_fingerprints_compare_identical_atom_sequences() { diff --git a/common/src/rstest/tests/fingerprint_props.rs b/common/src/rstest/tests/fingerprint_props.rs index 6c1c2e70..8893fbf4 100644 --- a/common/src/rstest/tests/fingerprint_props.rs +++ b/common/src/rstest/tests/fingerprint_props.rs @@ -1,8 +1,9 @@ //! Property tests for shared `rstest` fingerprint data models. -use crate::rstest::{ArgAtom, ArgFingerprint, LocalSlot, ParagraphNormalizer}; use proptest::prelude::*; +use crate::rstest::{ArgAtom, ArgFingerprint, LocalSlot, ParagraphNormalizer}; + proptest! { /// Slot indices are assigned in strict first-appearance order: the first /// distinct name always receives slot 0, the second slot 1, etc. @@ -15,7 +16,8 @@ proptest! { for name in &names { let slot = norm.local_slot(name.as_str()); if !seen.contains(name) { - let expected = seen.len() as u32; + let expected = u32::try_from(seen.len()) + .expect("at most 32 generated names, so the count fits in u32"); prop_assert_eq!( slot.index(), expected, @@ -37,7 +39,7 @@ proptest! { ) { let mut norm = ParagraphNormalizer::new(); for p in &prefix { - let _ = norm.local_slot(p.as_str()); + let _prefix_slot = norm.local_slot(p.as_str()); } let first = norm.local_slot(name.as_str()); let second = norm.local_slot(name.as_str()); diff --git a/common/src/rstest/tests/mod.rs b/common/src/rstest/tests/mod.rs index 9df51af8..009ae801 100644 --- a/common/src/rstest/tests/mod.rs +++ b/common/src/rstest/tests/mod.rs @@ -3,16 +3,30 @@ mod fingerprint; mod fingerprint_props; +use std::collections::BTreeSet; + +use rstest::rstest; + use super::{ - ExpansionTrace, ParameterBinding, RstestDetectionOptions, RstestParameter, RstestParameterKind, - SpanRecoveryFrame, UserEditableSpan, classify_rstest_parameter, fixture_local_names, - is_rstest_fixture, is_rstest_fixture_with, is_rstest_test, is_rstest_test_with, + ExpansionTrace, + ParameterBinding, + RstestDetectionOptions, + RstestParameter, + RstestParameterKind, + SpanRecoveryFrame, + UserEditableSpan, + classify_rstest_parameter, + fixture_local_names, + is_rstest_fixture, + is_rstest_fixture_with, + is_rstest_test, + is_rstest_test_with, recover_user_editable_span, }; -use crate::attributes::{Attribute, AttributeKind, AttributePath}; -use crate::span::{SourceLocation, SourceSpan}; -use rstest::rstest; -use std::collections::BTreeSet; +use crate::{ + attributes::{Attribute, AttributeKind, AttributePath}, + span::{SourceLocation, SourceSpan}, +}; fn outer(path: &str) -> Attribute { Attribute::new(AttributePath::from(path), AttributeKind::Outer) @@ -20,7 +34,7 @@ fn outer(path: &str) -> Attribute { fn provider_parameter(path: &str) -> RstestParameter { RstestParameter::new( - ParameterBinding::Ident("value".to_string()), + ParameterBinding::Ident("value".to_owned()), vec![outer(path)], ) } @@ -74,7 +88,7 @@ fn classifies_identifier_parameters_as_fixture_locals() { assert_eq!( classify_rstest_parameter(¶meter, &RstestDetectionOptions::default()), RstestParameterKind::FixtureLocal { - name: "db".to_string() + name: "db".to_owned() } ); } @@ -125,7 +139,7 @@ fn rejects_unknown_custom_provider_parameters() { assert_eq!( classify_rstest_parameter(¶meter, &options), RstestParameterKind::FixtureLocal { - name: "value".to_string() + name: "value".to_owned() } ); } @@ -197,55 +211,62 @@ fn collects_supported_fixture_local_names_in_order() { assert_eq!( fixture_local_names(¶meters, &RstestDetectionOptions::default()), - BTreeSet::from(["clock".to_string(), "db".to_string()]) + BTreeSet::from(["clock".to_owned(), "db".to_owned()]) ); } -fn source_span(line: usize, start: usize, end: usize) -> SourceSpan { - SourceSpan::new( - SourceLocation::new(line, start), - SourceLocation::new(line, end), - ) - .expect("test spans should always be valid") +/// Builds a single-line [`SourceSpan`] for test data. +/// +/// This is a macro rather than a helper function so the fallible construction +/// is inlined into the calling `#[rstest]` body, where a failure is the test +/// verdict, and so it can be used inside `#[case(...)]` attributes. +macro_rules! source_span { + ($line:expr, $start:expr, $end:expr) => { + SourceSpan::new( + SourceLocation::new($line, $start), + SourceLocation::new($line, $end), + ) + .expect("test spans should always be valid") + }; } fn assert_span_recovery( frame_specs: impl IntoIterator, - expected: UserEditableSpan, + expected: &UserEditableSpan, ) { let frames: Vec> = frame_specs .into_iter() .map(|(span, is_macro)| SpanRecoveryFrame::new(span, is_macro)) .collect(); - assert_eq!(recover_user_editable_span(&frames), expected); + assert_eq!(&recover_user_editable_span(&frames), expected); } #[rstest] #[case::keeps_direct_user_editable_span( - vec![(source_span(1, 1, 8), false)], - UserEditableSpan::Direct(source_span(1, 1, 8)), + vec![(source_span!(1, 1, 8), false)], + UserEditableSpan::Direct(source_span!(1, 1, 8)), )] #[case::recovers_macro_frame_to_first_user_span( - vec![(source_span(2, 1, 8), true), (source_span(10, 1, 12), false)], - UserEditableSpan::Recovered(source_span(10, 1, 12)), + vec![(source_span!(2, 1, 8), true), (source_span!(10, 1, 12), false)], + UserEditableSpan::Recovered(source_span!(10, 1, 12)), )] #[case::recovers_first_user_span_from_nested_macro_chain( vec![ - (source_span(2, 1, 4), true), - (source_span(3, 1, 5), true), - (source_span(14, 1, 6), false), - (source_span(20, 1, 9), false), + (source_span!(2, 1, 4), true), + (source_span!(3, 1, 5), true), + (source_span!(14, 1, 6), false), + (source_span!(20, 1, 9), false), ], - UserEditableSpan::Recovered(source_span(14, 1, 6)), + UserEditableSpan::Recovered(source_span!(14, 1, 6)), )] #[case::treats_empty_frame_list_as_macro_only(vec![], UserEditableSpan::MacroOnly)] #[case::treats_all_expansion_frames_as_macro_only( - vec![(source_span(4, 1, 4), true), (source_span(5, 1, 6), true)], + vec![(source_span!(4, 1, 4), true), (source_span!(5, 1, 6), true)], UserEditableSpan::MacroOnly, )] fn recovers_user_editable_span_from_frame_sequences( #[case] frames: Vec<(SourceSpan, bool)>, #[case] expected: UserEditableSpan, ) { - assert_span_recovery(frames, expected); + assert_span_recovery(frames, &expected); } diff --git a/common/src/span.rs b/common/src/span.rs index 4b320976..516e65da 100644 --- a/common/src/span.rs +++ b/common/src/span.rs @@ -1,5 +1,4 @@ //! Utilities for working with source locations and spans. -#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use std::ops::RangeInclusive; @@ -29,25 +28,19 @@ impl SourceLocation { /// assert_eq!(location.line(), 3); /// ``` #[must_use] - pub const fn new(line: usize, column: usize) -> Self { - Self { line, column } - } + pub const fn new(line: usize, column: usize) -> Self { Self { line, column } } /// Returns the one-based line number. #[must_use] - pub const fn line(self) -> usize { - self.line - } + pub const fn line(self) -> usize { self.line } /// Returns the one-based column number. #[must_use] - pub const fn column(self) -> usize { - self.column - } + pub const fn column(self) -> usize { self.column } /// Returns true when this location is positioned after other. #[must_use] - pub const fn is_after(self, other: SourceLocation) -> bool { + pub const fn is_after(self, other: Self) -> bool { self.line > other.line || (self.line == other.line && self.column > other.column) } } @@ -62,16 +55,21 @@ pub struct SourceSpan { impl SourceSpan { /// Constructs a new span from two locations. /// + /// # Errors + /// + /// Returns [`SpanError::StartAfterEnd`] when `start` is positioned after `end`. + /// /// # Examples /// /// ``` /// use whitaker_common::span::{SourceLocation, SourceSpan}; /// - /// let span = SourceSpan::new(SourceLocation::new(1, 0), SourceLocation::new(3, 2)).expect("valid span for example"); + /// let span = SourceSpan::new(SourceLocation::new(1, 0), SourceLocation::new(3, 2)) + /// .expect("valid span for example"); /// assert_eq!(span.start().line(), 1); /// ``` #[must_use = "Inspect the span creation result to handle invalid ranges"] - pub fn new(start: SourceLocation, end: SourceLocation) -> Result { + pub const fn new(start: SourceLocation, end: SourceLocation) -> Result { if start.is_after(end) { Err(SpanError::StartAfterEnd) } else { @@ -81,15 +79,11 @@ impl SourceSpan { /// Returns the start location. #[must_use] - pub const fn start(self) -> SourceLocation { - self.start - } + pub const fn start(self) -> SourceLocation { self.start } /// Returns the end location. #[must_use] - pub const fn end(self) -> SourceLocation { - self.end - } + pub const fn end(self) -> SourceLocation { self.end } } /// Converts a span into the inclusive range of line numbers it covers. @@ -99,11 +93,12 @@ impl SourceSpan { /// ``` /// use whitaker_common::span::{SourceLocation, SourceSpan, span_to_lines}; /// -/// let span = SourceSpan::new(SourceLocation::new(4, 0), SourceLocation::new(6, 5)).expect("valid span for example"); +/// let span = SourceSpan::new(SourceLocation::new(4, 0), SourceLocation::new(6, 5)) +/// .expect("valid span for example"); /// assert_eq!(span_to_lines(span), 4..=6); /// ``` #[must_use] -pub fn span_to_lines(span: SourceSpan) -> RangeInclusive { +pub const fn span_to_lines(span: SourceSpan) -> RangeInclusive { span.start.line()..=span.end.line() } @@ -114,19 +109,21 @@ pub fn span_to_lines(span: SourceSpan) -> RangeInclusive { /// ``` /// use whitaker_common::span::{SourceLocation, SourceSpan, span_line_count}; /// -/// let span = SourceSpan::new(SourceLocation::new(2, 0), SourceLocation::new(5, 1)).expect("valid span for example"); +/// let span = SourceSpan::new(SourceLocation::new(2, 0), SourceLocation::new(5, 1)) +/// .expect("valid span for example"); /// assert_eq!(span_line_count(span), 4); /// ``` #[must_use] -pub fn span_line_count(span: SourceSpan) -> usize { - span.end.line() - span.start.line() + 1 -} +pub const fn span_line_count(span: SourceSpan) -> usize { span.end.line() - span.start.line() + 1 } #[cfg(test)] mod tests { - use super::*; + //! Tests for source-span construction and location arithmetic. + use rstest::rstest; + use super::*; + #[rstest] fn span_construction_validates_order() { let err = SourceSpan::new(SourceLocation::new(3, 1), SourceLocation::new(2, 0)); diff --git a/common/src/test_support/decomposition.rs b/common/src/test_support/decomposition.rs index b4f534bd..b3987900 100644 --- a/common/src/test_support/decomposition.rs +++ b/common/src/test_support/decomposition.rs @@ -11,16 +11,21 @@ mod label_propagation; #[path = "decomposition_vector_algebra.rs"] mod vector_algebra; +pub use self::{ + adjacency::{AdjacencyError, AdjacencyReport, EdgeInput, adjacency_report}, + label_propagation::{LabelPropagationReport, label_propagation_report}, + vector_algebra::{MethodVectorAlgebraReport, method_vector_algebra}, +}; use crate::decomposition_advice::{ - DecompositionContext, DecompositionSuggestion, MethodProfile, MethodProfileBuilder, - SubjectKind, methods_meet_cosine_threshold as runtime_methods_meet_cosine_threshold, + DecompositionContext, + DecompositionSuggestion, + MethodProfile, + MethodProfileBuilder, + SubjectKind, + methods_meet_cosine_threshold as runtime_methods_meet_cosine_threshold, suggest_decomposition, }; -pub use self::adjacency::{AdjacencyError, AdjacencyReport, EdgeInput, adjacency_report}; -pub use self::label_propagation::{LabelPropagationReport, label_propagation_report}; -pub use self::vector_algebra::{MethodVectorAlgebraReport, method_vector_algebra}; - /// Input data for building a [`MethodProfile`] in tests. /// /// # Examples @@ -38,11 +43,17 @@ pub use self::vector_algebra::{MethodVectorAlgebraReport, method_vector_algebra} /// /// assert_eq!(profile.name(), "parse_tokens"); /// ``` +#[derive(Clone, Copy)] pub struct MethodInput<'a> { + /// Method name recorded on the resulting profile. pub name: &'a str, + /// Struct fields the method accesses. pub fields: &'a [&'a str], + /// Types that appear in the method signature. pub signature_types: &'a [&'a str], + /// Types bound locally within the method body. pub local_types: &'a [&'a str], + /// External domains (modules or crates) the method touches. pub domains: &'a [&'a str], } diff --git a/common/src/test_support/decomposition_adjacency.rs b/common/src/test_support/decomposition_adjacency.rs index 6a87f7f5..6e196f9e 100644 --- a/common/src/test_support/decomposition_adjacency.rs +++ b/common/src/test_support/decomposition_adjacency.rs @@ -1,8 +1,9 @@ //! Observable adjacency-construction seams for decomposition advice tests. -use crate::decomposition_advice::community::{SimilarityEdge, build_adjacency}; use thiserror::Error; +use crate::decomposition_advice::community::{SimilarityEdge, build_adjacency}; + /// Declarative edge input for test scenarios. /// /// # Examples @@ -89,9 +90,7 @@ impl AdjacencyReport { /// assert_eq!(report.node_count(), 4); /// ``` #[must_use] - pub fn node_count(&self) -> usize { - self.node_count - } + pub const fn node_count(&self) -> usize { self.node_count } /// Returns the neighbour list for `node`, sorted by neighbour index. /// @@ -100,9 +99,15 @@ impl AdjacencyReport { /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; /// - /// let report = adjacency_report(3, &[ - /// EdgeInput { left: 0, right: 2, weight: 7 }, - /// ]).expect("valid input"); + /// let report = adjacency_report( + /// 3, + /// &[EdgeInput { + /// left: 0, + /// right: 2, + /// weight: 7, + /// }], + /// ) + /// .expect("valid input"); /// assert_eq!(report.neighbours_of(0), Some(&[(2, 7)][..])); /// assert_eq!(report.neighbours_of(10), None); /// ``` @@ -116,9 +121,15 @@ impl AdjacencyReport { /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; /// - /// let report = adjacency_report(3, &[ - /// EdgeInput { left: 0, right: 1, weight: 5 }, - /// ]).expect("valid input"); + /// let report = adjacency_report( + /// 3, + /// &[EdgeInput { + /// left: 0, + /// right: 1, + /// weight: 5, + /// }], + /// ) + /// .expect("valid input"); /// assert!(report.all_indices_in_bounds()); /// ``` #[must_use] @@ -136,9 +147,15 @@ impl AdjacencyReport { /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; /// - /// let report = adjacency_report(3, &[ - /// EdgeInput { left: 0, right: 2, weight: 7 }, - /// ]).expect("valid input"); + /// let report = adjacency_report( + /// 3, + /// &[EdgeInput { + /// left: 0, + /// right: 2, + /// weight: 7, + /// }], + /// ) + /// .expect("valid input"); /// assert!(report.is_symmetric()); /// ``` #[must_use] @@ -156,17 +173,31 @@ impl AdjacencyReport { /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; /// - /// let report = adjacency_report(4, &[ - /// EdgeInput { left: 0, right: 1, weight: 5 }, - /// EdgeInput { left: 0, right: 3, weight: 3 }, - /// ]).expect("valid input"); + /// let report = adjacency_report( + /// 4, + /// &[ + /// EdgeInput { + /// left: 0, + /// right: 1, + /// weight: 5, + /// }, + /// EdgeInput { + /// left: 0, + /// right: 3, + /// weight: 3, + /// }, + /// ], + /// ) + /// .expect("valid input"); /// assert!(report.is_sorted()); /// ``` #[must_use] pub fn is_sorted(&self) -> bool { - self.neighbours - .iter() - .all(|bucket| bucket.windows(2).all(|pair| pair[0].0 <= pair[1].0)) + self.neighbours.iter().all(|bucket| { + bucket + .windows(2) + .all(|pair| matches!(pair, [(left, _), (right, _)] if left <= right)) + }) } } @@ -182,7 +213,8 @@ fn has_mirror( ) -> bool { debug_assert!( neighbour < neighbours.len(), - "has_mirror: neighbour index out of bounds - callers (e.g. adjacency_report) must guarantee valid indices" + "has_mirror: neighbour index out of bounds - callers (e.g. adjacency_report) must \ + guarantee valid indices" ); neighbours.get(neighbour).map_or(false, |list| { list.iter() @@ -201,9 +233,15 @@ fn has_mirror( /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; /// -/// let report = adjacency_report(3, &[ -/// EdgeInput { left: 0, right: 2, weight: 7 }, -/// ]).expect("valid input"); +/// let report = adjacency_report( +/// 3, +/// &[EdgeInput { +/// left: 0, +/// right: 2, +/// weight: 7, +/// }], +/// ) +/// .expect("valid input"); /// assert_eq!(report.node_count(), 3); /// ``` /// @@ -263,6 +301,8 @@ pub(crate) fn validate_edges( #[cfg(test)] mod tests { + //! Tests for adjacency-report validation of decomposition edge inputs. + use super::{AdjacencyError, EdgeInput, adjacency_report}; #[test] diff --git a/common/src/test_support/decomposition_label_propagation.rs b/common/src/test_support/decomposition_label_propagation.rs index 774b3da0..f5759671 100644 --- a/common/src/test_support/decomposition_label_propagation.rs +++ b/common/src/test_support/decomposition_label_propagation.rs @@ -1,23 +1,21 @@ //! Observable label-propagation seams for decomposition advice tests. +use super::adjacency::{AdjacencyError, EdgeInput, validate_edges}; use crate::decomposition_advice::{ - MethodProfileBuilder, build_feature_vector, + MethodProfileBuilder, + build_feature_vector, community::{ LabelPropagationReport as RuntimeLabelPropagationReport, propagate_labels_report as runtime_propagate_labels_report, }, }; -use super::adjacency::{AdjacencyError, EdgeInput, validate_edges}; - /// Observable label-propagation results for declarative graph input. /// /// # Examples /// /// ```rust -/// use whitaker_common::test_support::decomposition::{ -/// EdgeInput, label_propagation_report, -/// }; +/// use whitaker_common::test_support::decomposition::{EdgeInput, label_propagation_report}; /// /// let report = label_propagation_report( /// &["gamma", "alpha", "beta"], @@ -41,28 +39,20 @@ pub struct LabelPropagationReport { impl LabelPropagationReport { /// Returns the final label vector. #[must_use] - pub fn labels(&self) -> &[usize] { - &self.runtime.labels - } + pub fn labels(&self) -> &[usize] { &self.runtime.labels } /// Returns the propagated label for `node`, or `None` if it is out of /// range. #[must_use] - pub fn label_of(&self, node: usize) -> Option { - self.labels().get(node).copied() - } + pub fn label_of(&self, node: usize) -> Option { self.labels().get(node).copied() } /// Returns the number of propagation passes performed. #[must_use] - pub fn iteration_count(&self) -> usize { - self.runtime.iteration_count - } + pub const fn iteration_count(&self) -> usize { self.runtime.iteration_count } /// Returns `true` when the graph contains at least one active node. #[must_use] - pub fn has_active_nodes(&self) -> bool { - self.has_active_nodes - } + pub const fn has_active_nodes(&self) -> bool { self.has_active_nodes } /// Returns `true` when every label is a valid node index. #[must_use] diff --git a/common/src/test_support/decomposition_vector_algebra.rs b/common/src/test_support/decomposition_vector_algebra.rs index 8dc8d6c7..3aa9f5bf 100644 --- a/common/src/test_support/decomposition_vector_algebra.rs +++ b/common/src/test_support/decomposition_vector_algebra.rs @@ -1,7 +1,9 @@ //! Observable vector-algebra seams for decomposition advice tests. -use crate::MethodProfile; -use crate::decomposition_advice::{build_feature_vector, dot_product}; +use crate::{ + MethodProfile, + decomposition_advice::{build_feature_vector, dot_product}, +}; /// Observable runtime vector-algebra results for two methods. /// @@ -43,7 +45,11 @@ impl MethodVectorAlgebraReport { /// This is the dot product of the left and right method vectors. /// /// ```rust - /// use whitaker_common::test_support::decomposition::{MethodInput, method_vector_algebra, profile}; + /// use whitaker_common::test_support::decomposition::{ + /// MethodInput, + /// method_vector_algebra, + /// profile, + /// }; /// /// let left = profile(MethodInput { /// name: "parse_tokens", @@ -64,16 +70,18 @@ impl MethodVectorAlgebraReport { /// assert_eq!(report.left_dot_right(), 40); /// ``` #[must_use] - pub fn left_dot_right(self) -> u64 { - self.left_dot_right - } + pub const fn left_dot_right(self) -> u64 { self.left_dot_right } /// Returns the result of [`MethodVectorAlgebraReport::right_dot_left`]. /// /// This is the dot product of the right and left method vectors. /// /// ```rust - /// use whitaker_common::test_support::decomposition::{MethodInput, method_vector_algebra, profile}; + /// use whitaker_common::test_support::decomposition::{ + /// MethodInput, + /// method_vector_algebra, + /// profile, + /// }; /// /// let left = profile(MethodInput { /// name: "parse_tokens", @@ -94,16 +102,18 @@ impl MethodVectorAlgebraReport { /// assert_eq!(report.right_dot_left(), 40); /// ``` #[must_use] - pub fn right_dot_left(self) -> u64 { - self.right_dot_left - } + pub const fn right_dot_left(self) -> u64 { self.right_dot_left } /// Returns the result of [`MethodVectorAlgebraReport::left_norm_squared`]. /// /// This is the squared L2 norm of the left method vector. /// /// ```rust - /// use whitaker_common::test_support::decomposition::{MethodInput, method_vector_algebra, profile}; + /// use whitaker_common::test_support::decomposition::{ + /// MethodInput, + /// method_vector_algebra, + /// profile, + /// }; /// /// let left = profile(MethodInput { /// name: "parse_tokens", @@ -124,16 +134,18 @@ impl MethodVectorAlgebraReport { /// assert_eq!(report.left_norm_squared(), 44); /// ``` #[must_use] - pub fn left_norm_squared(self) -> u64 { - self.left_norm_squared - } + pub const fn left_norm_squared(self) -> u64 { self.left_norm_squared } /// Returns the result of [`MethodVectorAlgebraReport::right_norm_squared`]. /// /// This is the squared L2 norm of the right method vector. /// /// ```rust - /// use whitaker_common::test_support::decomposition::{MethodInput, method_vector_algebra, profile}; + /// use whitaker_common::test_support::decomposition::{ + /// MethodInput, + /// method_vector_algebra, + /// profile, + /// }; /// /// let left = profile(MethodInput { /// name: "parse_tokens", @@ -154,9 +166,7 @@ impl MethodVectorAlgebraReport { /// assert_eq!(report.right_norm_squared(), 44); /// ``` #[must_use] - pub fn right_norm_squared(self) -> u64 { - self.right_norm_squared - } + pub const fn right_norm_squared(self) -> u64 { self.right_norm_squared } } /// Computes the shipped vector-algebra helper values for two methods. diff --git a/common/src/test_support/fixtures.rs b/common/src/test_support/fixtures.rs index 292b23df..88ff45ef 100644 --- a/common/src/test_support/fixtures.rs +++ b/common/src/test_support/fixtures.rs @@ -4,9 +4,7 @@ //! supporting assets into a temporary workspace so UI harnesses only need to //! focus on executing the lint runner. -use std::fs; -use std::io; -use std::path::Path; +use std::{fs, io, path::Path}; const MAX_DIRECTORY_DEPTH: usize = 64; @@ -19,10 +17,10 @@ const MAX_DIRECTORY_DEPTH: usize = 64; /// # Examples /// /// ``` -/// use whitaker_common::test_support::fixtures::copy_fixture; -/// use std::fs; -/// use std::path::PathBuf; +/// use std::{fs, path::PathBuf}; +/// /// use tempfile::tempdir; +/// use whitaker_common::test_support::fixtures::copy_fixture; /// /// # fn demo() -> std::io::Result<()> { /// let fixtures = tempdir()?; @@ -34,6 +32,12 @@ const MAX_DIRECTORY_DEPTH: usize = 64; /// # Ok(()) /// # } /// ``` +/// +/// # Errors +/// +/// Returns an [`io::Error`] when the fixture or stderr path lacks a usable +/// file name, or when copying the fixture, its `.stderr` expectation, or a +/// support directory fails. pub fn copy_fixture(fixture_root: &Path, source: &Path, destination_root: &Path) -> io::Result<()> { let file_name = source .file_name() @@ -71,9 +75,10 @@ pub fn copy_fixture(fixture_root: &Path, source: &Path, destination_root: &Path) /// # Examples /// /// ``` -/// use whitaker_common::test_support::fixtures::copy_directory; /// use std::fs; +/// /// use tempfile::tempdir; +/// use whitaker_common::test_support::fixtures::copy_directory; /// /// # fn demo() -> std::io::Result<()> { /// let source = tempdir()?; @@ -84,6 +89,12 @@ pub fn copy_fixture(fixture_root: &Path, source: &Path, destination_root: &Path) /// # Ok(()) /// # } /// ``` +/// +/// # Errors +/// +/// Returns an [`io::Error`] when `source` is not a directory, when a symlink +/// is encountered, when nesting exceeds `MAX_DIRECTORY_DEPTH`, or when any +/// underlying filesystem operation fails. pub fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> { copy_directory_with_depth(source, destination, MAX_DIRECTORY_DEPTH) } @@ -102,8 +113,8 @@ fn copy_directory_with_depth( ensure_not_symlink(source, metadata.file_type())?; fs::create_dir_all(destination)?; - for entry in fs::read_dir(source)? { - let entry = entry?; + for entry_result in fs::read_dir(source)? { + let entry = entry_result?; let entry_path = entry.path(); let file_type = entry_path.symlink_metadata()?.file_type(); @@ -158,12 +169,18 @@ fn depth_limit_error(path: &Path) -> io::Error { #[cfg(test)] mod tests { - use super::*; - use std::fs; - use std::io; - use std::path::{Path, PathBuf}; + //! Tests for fixture staging helpers used by lint test suites. + + use std::{ + fs, + io, + path::{Path, PathBuf}, + }; + use tempfile::{TempDir, tempdir}; + use super::*; + #[test] fn copy_fixture_clones_support_assets() { let root = tempdir().expect("fixture root"); @@ -211,28 +228,29 @@ mod tests { fn setup_copy_fixture_test( with_stderr: bool, with_support: bool, - ) -> (TempDir, PathBuf, TempDir) { - let root = tempdir().expect("fixture root"); + ) -> io::Result<(TempDir, PathBuf, TempDir)> { + let root = tempdir()?; let fixture = root.path().join("case.rs"); - fs::write(&fixture, "fn main() {}").expect("fixture file"); + fs::write(&fixture, "fn main() {}")?; if with_stderr { - fs::write(root.path().join("case.stderr"), "stderr").expect("stderr file"); + fs::write(root.path().join("case.stderr"), "stderr")?; } if with_support { let support_dir = root.path().join("case"); - fs::create_dir_all(&support_dir).expect("support dir"); - fs::write(support_dir.join("helper.rs"), "fn helper() {}").expect("support helper"); + fs::create_dir_all(&support_dir)?; + fs::write(support_dir.join("helper.rs"), "fn helper() {}")?; } - let destination = tempdir().expect("destination root"); - (root, fixture, destination) + let destination = tempdir()?; + Ok((root, fixture, destination)) } #[test] fn copy_fixture_without_stderr_succeeds() { - let (root, fixture, destination) = setup_copy_fixture_test(false, false); + let (root, fixture, destination) = + setup_copy_fixture_test(false, false).expect("stage fixture without stderr"); copy_fixture(root.path(), &fixture, destination.path()) .expect("copy succeeds without stderr"); @@ -243,7 +261,8 @@ mod tests { #[test] fn copy_fixture_without_support_directory_succeeds() { - let (root, fixture, destination) = setup_copy_fixture_test(true, false); + let (root, fixture, destination) = + setup_copy_fixture_test(true, false).expect("stage fixture with stderr"); copy_fixture(root.path(), &fixture, destination.path()) .expect("copy succeeds without support dir"); diff --git a/common/src/test_support/mod.rs b/common/src/test_support/mod.rs index 37676df8..52cdf777 100644 --- a/common/src/test_support/mod.rs +++ b/common/src/test_support/mod.rs @@ -6,218 +6,117 @@ //! //! ## Available helpers //! -//! - [`fixtures`]: Copies UI fixtures (source files, `.stderr` expectations and -//! support directories) into isolated workspaces for dylint UI harnesses. -//! - [`decomposition`]: Reusable decomposition-advice fixtures for unit and -//! behaviour tests. -//! - [`env_test_guard`]: Serializes tests that temporarily mutate process-wide -//! environment variables. -//! - [`ui`]: Discovers fixtures, prepares isolated workspaces, and runs dylint -//! UI tests with consistent panic handling. -//! - [`LocaleOverride`]: Temporarily mutates `DYLINT_LOCALE` so locale-sensitive -//! tests can execute without leaking global state between cases. +//! - [`fixtures`]: Copies UI fixtures (source files, `.stderr` expectations and support +//! directories) into isolated workspaces for dylint UI harnesses. +//! - [`decomposition`]: Reusable decomposition-advice fixtures for unit and behaviour tests. +//! - [`env_test_guard`]: Serializes tests that temporarily mutate process-wide environment +//! variables. +//! - [`ui`]: Discovers fixtures, prepares isolated workspaces, and runs dylint UI tests with +//! consistent panic handling. +//! - [`with_locale`], [`with_env_var`], and [`with_env_var_removed`]: Scope temporary environment +//! mutations (such as `DYLINT_LOCALE` overrides) to a callback so tests cannot leak global state +//! between cases. pub mod decomposition; pub mod fixtures; pub mod ui; +use std::{ + ffi::OsStr, + sync::{Mutex, MutexGuard, OnceLock, PoisonError}, +}; + pub use fixtures::{copy_directory, copy_fixture}; pub use ui::{ - FixtureEnvironment, discover_fixtures, prepare_fixture, read_directory_config, - read_fixture_config, resolve_fixture_config, run_fixtures_with, run_test_runner, + FixtureEnvironment, + discover_fixtures, + prepare_fixture, + read_directory_config, + read_fixture_config, + resolve_fixture_config, + run_fixtures_with, + run_test_runner, }; -use std::ffi::{OsStr, OsString}; -use std::sync::{Mutex, MutexGuard, OnceLock}; - /// Serializes tests that mutate process-wide environment variables. /// /// Use this guard around helpers such as `temp_env::with_var` or /// `temp_env::with_vars_unset` when the test would otherwise race with other /// cases changing the same global process state. +/// +/// The mutex guards `()`, so a poisoned lock carries no corrupted state: it +/// only records that some earlier test panicked while holding the +/// serialization token. Recovering the guard is therefore sound, and keeps one +/// failing test from cascading into every later one. pub fn env_test_guard() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() - .unwrap_or_else(|error| panic!("expected environment test lock: {error}")) + .unwrap_or_else(PoisonError::into_inner) } -/// Guard that sets one environment variable and restores its prior state. +/// Runs `callback` with one environment variable temporarily set. /// -/// The guard acquires [`env_test_guard`] only while mutating the process -/// environment during construction and drop. It deliberately does not hold the -/// mutex for the full guard lifetime, so callers can execute callbacks that -/// perform their own guarded environment setup without deadlocking. Use this -/// as the shared environment-mutation helper for tests that need temporary -/// global environment changes with `env_test_guard`-serialized setup and -/// teardown semantics. -pub struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - /// Sets `key` to `value`, returning a guard that restores the previous - /// value or removes the variable when dropped. - /// - /// # Examples - /// - /// ```rust - /// use whitaker_common::test_support::EnvVarGuard; - /// - /// let _guard = EnvVarGuard::set("WHITAKER_TEST_ENV_VAR", "enabled"); - /// assert_eq!( - /// std::env::var("WHITAKER_TEST_ENV_VAR").expect("test env var should be set"), - /// "enabled", - /// ); - /// ``` - #[must_use] - pub fn set(key: &'static str, value: impl AsRef) -> Self { - let _env_guard = env_test_guard(); - let previous = std::env::var_os(key); - // SAFETY: `env_test_guard` serializes this environment mutation. - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - - /// Removes `key`, returning a guard that restores the previous value when - /// dropped. - /// - /// # Examples - /// - /// ```rust - /// use whitaker_common::test_support::EnvVarGuard; - /// - /// let _guard = EnvVarGuard::remove("WHITAKER_REMOVED_TEST_ENV_VAR"); - /// assert!(std::env::var_os("WHITAKER_REMOVED_TEST_ENV_VAR").is_none()); - /// ``` - #[must_use] - pub fn remove(key: &'static str) -> Self { - let _env_guard = env_test_guard(); - let previous = std::env::var_os(key); - // SAFETY: `env_test_guard` serializes this environment mutation. - unsafe { - std::env::remove_var(key); - } - Self { key, previous } - } +/// The mutation is scoped to the callback and the prior value is restored +/// afterwards, even on panic. Serialization is provided by `temp_env`'s +/// re-entrant global lock, so nested scoped mutations from the same thread +/// do not deadlock. +/// +/// # Examples +/// +/// ```rust +/// use whitaker_common::test_support::with_env_var; +/// +/// with_env_var("WHITAKER_TEST_ENV_VAR", "enabled", || { +/// assert_eq!( +/// std::env::var("WHITAKER_TEST_ENV_VAR").expect("test env var should be set"), +/// "enabled", +/// ); +/// }); +/// ``` +pub fn with_env_var(key: &str, value: impl AsRef, callback: impl FnOnce() -> R) -> R { + temp_env::with_var(key, Some(value.as_ref()), callback) } -impl Drop for EnvVarGuard { - fn drop(&mut self) { - let _env_guard = env_test_guard(); - match &self.previous { - Some(previous) => { - // SAFETY: `env_test_guard` serializes this environment mutation. - unsafe { - std::env::set_var(self.key, previous); - } - } - None => { - // SAFETY: `env_test_guard` serializes this environment mutation. - unsafe { - std::env::remove_var(self.key); - } - } - } - } -} -/// Guard that overrides `DYLINT_LOCALE` for the lifetime of the instance. +/// Runs `callback` with one environment variable temporarily removed. /// -/// The guard captures any existing value and restores it when dropped. The -/// mutation itself must be executed under a serialized test harness (for -/// example via the `serial_test::serial` attribute) to ensure the unsafe -/// environment access remains race-free. +/// The prior value (if any) is restored after the callback completes or +/// panics. /// /// # Examples /// -/// ```ignore -/// use whitaker_common::test_support::LocaleOverride; -/// use serial_test::serial; +/// ```rust +/// use whitaker_common::test_support::with_env_var_removed; /// -/// #[test] -/// #[serial] -/// fn ui_runs_in_welsh_locale() { -/// let _guard = LocaleOverride::set("cy"); -/// // Execute lint UI harness here. -/// } +/// with_env_var_removed("WHITAKER_REMOVED_TEST_ENV_VAR", || { +/// assert!(std::env::var_os("WHITAKER_REMOVED_TEST_ENV_VAR").is_none()); +/// }); /// ``` -pub struct LocaleOverride { - previous: Option, -} - -impl LocaleOverride { - /// Sets `DYLINT_LOCALE` to `locale`, returning a guard that will restore - /// the prior value (if any) when dropped. - pub fn set(locale: &str) -> Self { - let previous = std::env::var_os("DYLINT_LOCALE"); - // SAFETY: Callers must serialize the surrounding test using a - // synchronization primitive such as the `serial_test::serial` - // attribute. The guard is thread-local and dropped before another - // serialized test begins, so no two threads mutate the environment - // concurrently. - unsafe { - std::env::set_var("DYLINT_LOCALE", locale); - } - Self { previous } - } - - /// Removes `DYLINT_LOCALE`, returning a guard that reinstates the prior - /// value (if any) when dropped. - /// - /// # Examples - /// - /// ```ignore - /// use whitaker_common::test_support::LocaleOverride; - /// use serial_test::serial; - /// use std::ffi::OsString; - /// - /// #[test] - /// #[serial] - /// fn clears_then_restores_locale() { - /// unsafe { - /// std::env::set_var("DYLINT_LOCALE", "cy"); - /// } - /// { - /// let _guard = LocaleOverride::clear(); - /// assert!(std::env::var_os("DYLINT_LOCALE").is_none()); - /// } - /// assert_eq!( - /// std::env::var_os("DYLINT_LOCALE"), - /// Some(OsString::from("cy")) - /// ); - /// } - /// ``` - pub fn clear() -> Self { - let previous = std::env::var_os("DYLINT_LOCALE"); - // SAFETY: Callers must serialize the surrounding test using a - // synchronization primitive such as the `serial_test::serial` - // attribute. Clearing the environment therefore cannot race with other - // threads. - unsafe { - std::env::remove_var("DYLINT_LOCALE"); - } - Self { previous } - } +pub fn with_env_var_removed(key: &str, callback: impl FnOnce() -> R) -> R { + temp_env::with_var_unset(key, callback) } -impl Drop for LocaleOverride { - fn drop(&mut self) { - if let Some(value) = &self.previous { - // SAFETY: By construction the guard only lives within a serialized - // test, so restoring the prior value cannot race with another - // thread mutating the environment. - unsafe { - std::env::set_var("DYLINT_LOCALE", value); - } - } else { - // SAFETY: Serialized execution also guarantees removal has no - // concurrent callers. - unsafe { - std::env::remove_var("DYLINT_LOCALE"); - } - } +/// Runs `callback` with `DYLINT_LOCALE` overridden. +/// +/// `Some(locale)` sets the variable for the duration of the callback; +/// `None` removes it. Any prior value is restored afterwards, so +/// locale-sensitive tests cannot leak global state between cases. +/// +/// # Examples +/// +/// ```rust +/// use whitaker_common::test_support::with_locale; +/// +/// with_locale(Some("cy"), || { +/// assert_eq!( +/// std::env::var("DYLINT_LOCALE").expect("locale should be set"), +/// "cy", +/// ); +/// }); +/// ``` +pub fn with_locale(locale: Option<&str>, callback: impl FnOnce() -> R) -> R { + match locale { + Some(locale_value) => with_env_var("DYLINT_LOCALE", locale_value, callback), + None => with_env_var_removed("DYLINT_LOCALE", callback), } } diff --git a/common/src/test_support/ui.rs b/common/src/test_support/ui.rs index 690c1e37..a27c3969 100644 --- a/common/src/test_support/ui.rs +++ b/common/src/test_support/ui.rs @@ -4,14 +4,18 @@ //! clone them into an isolated workspace, and execute each case via //! `dylint_testing` while capturing panics into deterministic error messages. -use crate::test_support::copy_fixture; +use std::{ + fs, + io, + path::{Path, PathBuf}, +}; + use camino::Utf8Path; use glob::glob; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; use tempfile::{TempDir, tempdir}; +use crate::test_support::copy_fixture; + /// Temporary workspace prepared for a single UI fixture run. pub struct FixtureEnvironment { _tempdir: TempDir, @@ -21,17 +25,19 @@ pub struct FixtureEnvironment { impl FixtureEnvironment { /// Returns the root directory containing the cloned fixture files. - pub fn workdir(&self) -> &Path { - &self.workdir - } + #[must_use] + pub fn workdir(&self) -> &Path { &self.workdir } /// Moves the optional `dylint.toml` contents out of the environment. - pub fn take_config(&mut self) -> Option { - self.config.take() - } + pub const fn take_config(&mut self) -> Option { self.config.take() } } /// Discovers `.rs` fixtures inside `directory`, returning the paths unsorted. +/// +/// # Errors +/// +/// Returns an [`io::Error`] when the glob pattern is invalid or a matched +/// path cannot be read. pub fn discover_fixtures(directory: &Utf8Path) -> io::Result> { let pattern = directory.join("*.rs").to_string(); let walker = glob(&pattern).map_err(|error| io::Error::other(error.to_string()))?; @@ -48,6 +54,10 @@ pub fn discover_fixtures(directory: &Utf8Path) -> io::Result> { } /// Runs fixtures discovered under `directory` using the provided `runner`. +/// +/// # Errors +/// +/// Returns the first error produced by fixture discovery or by `runner`. pub fn run_fixtures_with( crate_name: &str, directory: &Utf8Path, @@ -67,6 +77,11 @@ where } /// Copies `source` into a temporary directory, including stderr/config files. +/// +/// # Errors +/// +/// Returns an [`io::Error`] when the temporary directory cannot be created or +/// the fixture files cannot be copied or read. pub fn prepare_fixture(directory: &Utf8Path, source: &Path) -> io::Result { let tempdir = tempdir()?; copy_fixture(directory.as_std_path(), source, tempdir.path())?; @@ -79,31 +94,48 @@ pub fn prepare_fixture(directory: &Utf8Path, source: &Path) -> io::Result: "` when the +/// runner unwinds. pub fn run_test_runner(fixture_name: &str, runner: F) -> Result<(), String> where F: FnOnce(), { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(runner)).map_err(|payload| match payload - .downcast::( - ) { - Ok(message) => format!("{fixture_name}: {message}"), - Err(payload) => match payload.downcast::<&'static str>() { - Ok(message) => format!("{fixture_name}: {message}"), - Err(_) => format!("{fixture_name}: dylint UI tests panicked without a message"), - }, - }) + std::panic::catch_unwind(std::panic::AssertUnwindSafe(runner)) + .map_err(|payload| format!("{fixture_name}: {}", panic_message(payload))) +} + +/// Renders a panic payload as a human-readable message. +fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(other_payload) => other_payload.downcast::<&'static str>().map_or_else( + |_| String::from("dylint UI tests panicked without a message"), + |message| (*message).to_owned(), + ), + } } /// Resolves the configuration content for `source`, preferring per-fixture files. +/// +/// # Errors +/// +/// Returns an [`io::Error`] when either configuration file cannot be read. pub fn resolve_fixture_config(directory: &Utf8Path, source: &Path) -> io::Result> { - if let Some(config) = read_fixture_config(source)? { - Ok(Some(config)) - } else { - read_directory_config(directory) - } + read_fixture_config(source)?.map_or_else( + || read_directory_config(directory), + |config| Ok(Some(config)), + ) } /// Loads `case.dylint.toml` for a fixture when present. +/// +/// # Errors +/// +/// Returns an [`io::Error`] when the fixture has no usable file stem or the +/// configuration file cannot be read. pub fn read_fixture_config(source: &Path) -> io::Result> { let stem = source .file_stem() @@ -119,6 +151,11 @@ pub fn read_fixture_config(source: &Path) -> io::Result> { } /// Loads `ui/dylint.toml` style directory-level configuration when present. +/// +/// # Errors +/// +/// Returns an [`io::Error`] when the configuration file exists but cannot be +/// read. pub fn read_directory_config(directory: &Utf8Path) -> io::Result> { let path = directory.as_std_path().join("dylint.toml"); if path.exists() { @@ -130,12 +167,23 @@ pub fn read_directory_config(directory: &Utf8Path) -> io::Result> #[cfg(test)] mod tests { - use super::*; - use camino::Utf8PathBuf; + //! Tests for the UI test harness helpers. + use std::fs; - fn utf8_path(buf: &Path) -> Utf8PathBuf { - Utf8PathBuf::from_path_buf(buf.to_path_buf()).expect("utf8 path") + use camino::Utf8PathBuf; + + use super::*; + + /// Converts a [`Path`] into a [`Utf8PathBuf`] for test data. + /// + /// This is a macro rather than a helper function so the fallible + /// conversion is inlined into the calling `#[test]` body, where a + /// non-UTF-8 temporary directory is the test verdict. + macro_rules! utf8_path { + ($path:expr) => { + Utf8PathBuf::from_path_buf(::std::path::Path::to_path_buf($path)).expect("utf8 path") + }; } #[test] @@ -143,21 +191,21 @@ mod tests { let dir = tempdir().expect("fixture directory"); fs::write(dir.path().join("b.rs"), "fn main() {}").expect("write first fixture"); fs::write(dir.path().join("a.rs"), "fn main() {}").expect("write second fixture"); - let directory = utf8_path(dir.path()); + let directory = utf8_path!(dir.path()); let mut visited = Vec::new(); run_fixtures_with("crate", &directory, |_, _, source| { let name = source .file_name() .and_then(|value| value.to_str()) - .ok_or_else(|| "utf8 file name".to_string())? + .ok_or_else(|| "utf8 file name".to_owned())? .to_owned(); visited.push(name); Ok(()) }) .expect("fixtures run"); - assert_eq!(visited, vec!["a.rs".to_string(), "b.rs".to_string()]); + assert_eq!(visited, vec!["a.rs".to_owned(), "b.rs".to_owned()]); } #[test] @@ -165,19 +213,20 @@ mod tests { let dir = tempdir().expect("fixture directory"); fs::write(dir.path().join("first.rs"), "").expect("first fixture"); fs::write(dir.path().join("second.txt"), "").expect("second fixture"); - let directory = utf8_path(dir.path()); + let directory = utf8_path!(dir.path()); let mut fixtures = discover_fixtures(&directory).expect("discover fixtures"); fixtures.sort(); assert_eq!(fixtures.len(), 1); - assert!(fixtures[0].ends_with("first.rs")); + let discovered = fixtures.first().expect("one fixture should be discovered"); + assert!(discovered.ends_with("first.rs")); } #[test] fn discover_fixtures_returns_empty_directory() { let dir = tempdir().expect("fixture directory"); - let directory = utf8_path(dir.path()); + let directory = utf8_path!(dir.path()); let fixtures = discover_fixtures(&directory).expect("discover fixtures"); @@ -199,7 +248,7 @@ mod tests { #[test] fn read_directory_config_loads_global_file() { let dir = tempdir().expect("fixture directory"); - let directory = utf8_path(dir.path()); + let directory = utf8_path!(dir.path()); fs::write(directory.as_std_path().join("dylint.toml"), "max_lines = 5") .expect("global config"); @@ -210,7 +259,7 @@ mod tests { #[test] fn resolve_fixture_config_prefers_fixture_specific_file() { let dir = tempdir().expect("fixture directory"); - let directory = utf8_path(dir.path()); + let directory = utf8_path!(dir.path()); let fixture = directory.as_std_path().join("case.rs"); fs::write(&fixture, "").expect("fixture"); fs::write( diff --git a/common/tests/brain_trait_evaluation_behaviour.rs b/common/tests/brain_trait_evaluation_behaviour.rs index 9ebbaa48..5be7074b 100644 --- a/common/tests/brain_trait_evaluation_behaviour.rs +++ b/common/tests/brain_trait_evaluation_behaviour.rs @@ -1,13 +1,22 @@ //! Behaviour-driven coverage for brain trait threshold evaluation. +use std::cell::{Cell, RefCell}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; -use whitaker_common::brain_trait_metrics::evaluation::{ - BrainTraitDiagnostic, BrainTraitDisposition, BrainTraitThresholds, BrainTraitThresholdsBuilder, - evaluate_brain_trait, format_primary_message, +use whitaker_common::brain_trait_metrics::{ + TraitMetrics, + TraitMetricsBuilder, + evaluation::{ + BrainTraitDiagnostic, + BrainTraitDisposition, + BrainTraitThresholds, + BrainTraitThresholdsBuilder, + evaluate_brain_trait, + format_primary_message, + }, }; -use whitaker_common::brain_trait_metrics::{TraitMetrics, TraitMetricsBuilder}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug)] struct EvaluationWorld { @@ -46,8 +55,14 @@ fn add_distributed_defaults(builder: &mut TraitMetricsBuilder, count: usize, cc_ if count == 0 { return; } - let base_cc = cc_sum / count; - let remainder = cc_sum % count; + // Derive the per-method base and remainder by repeated subtraction so + // the test avoids the disallowed `/` and `%` operators. + let mut base_cc = 0; + let mut remainder = cc_sum; + while remainder >= count { + base_cc += 1; + remainder -= count; + } for i in 0..count { let cc = base_cc + if i == count - 1 { remainder } else { 0 }; builder.add_default_method(format!("default_{i}"), cc, false); @@ -89,10 +104,9 @@ impl EvaluationWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> EvaluationWorld { - EvaluationWorld::default() -} +fn world() -> EvaluationWorld { EvaluationWorld::default() } // --- Given steps --- @@ -179,15 +193,17 @@ fn then_disposition_deny(world: &EvaluationWorld) { #[then("the primary message contains {text}")] fn then_primary_message_contains(world: &EvaluationWorld, text: String) -> Result<(), String> { - let msg = world.primary_message.borrow(); - let msg = msg + let message_ref = world.primary_message.borrow(); + let message = message_ref .as_deref() .ok_or("primary message must be formatted first")?; - assert!( - msg.contains(&text), - "expected primary message to contain '{text}', got: {msg}" - ); - Ok(()) + if message.contains(&text) { + Ok(()) + } else { + Err(format!( + "expected primary message to contain '{text}', got: {message}" + )) + } } // Scenario indices must match their declaration order in @@ -196,41 +212,25 @@ fn then_primary_message_contains(world: &EvaluationWorld, text: String) -> Resul // here. #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 0)] -fn scenario_within_limits_passes(world: EvaluationWorld) { - let _ = world; -} +fn scenario_within_limits_passes(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 1)] -fn scenario_all_warn_conditions(world: EvaluationWorld) { - let _ = world; -} +fn scenario_all_warn_conditions(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 2)] -fn scenario_many_methods_alone(world: EvaluationWorld) { - let _ = world; -} +fn scenario_many_methods_alone(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 3)] -fn scenario_high_cc_alone(world: EvaluationWorld) { - let _ = world; -} +fn scenario_high_cc_alone(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 4)] -fn scenario_deny_threshold(world: EvaluationWorld) { - let _ = world; -} +fn scenario_deny_threshold(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 5)] -fn scenario_deny_supersedes_warn(world: EvaluationWorld) { - let _ = world; -} +fn scenario_deny_supersedes_warn(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 6)] -fn scenario_associated_items_excluded(world: EvaluationWorld) { - let _ = world; -} +fn scenario_associated_items_excluded(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_evaluation.feature", index = 7)] -fn scenario_diagnostic_surfaces_values(world: EvaluationWorld) { - let _ = world; -} +fn scenario_diagnostic_surfaces_values(world: EvaluationWorld) { let _ = world; } diff --git a/common/tests/brain_trait_metrics_behaviour.rs b/common/tests/brain_trait_metrics_behaviour.rs index e8839f4e..05eb7b9f 100644 --- a/common/tests/brain_trait_metrics_behaviour.rs +++ b/common/tests/brain_trait_metrics_behaviour.rs @@ -1,9 +1,11 @@ //! Behaviour-driven coverage for brain trait metric collection. +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; use whitaker_common::brain_trait_metrics::{TraitMetrics, TraitMetricsBuilder}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Clone, Debug)] enum PendingTraitItem { @@ -24,14 +26,13 @@ struct TraitMetricsWorld { metrics: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> TraitMetricsWorld { - TraitMetricsWorld::default() -} +fn world() -> TraitMetricsWorld { TraitMetricsWorld::default() } fn with_metrics(world: &TraitMetricsWorld, assert_fn: impl FnOnce(&TraitMetrics)) { - let metrics = world.metrics.borrow(); - match metrics.as_ref() { + let metrics_ref = world.metrics.borrow(); + match metrics_ref.as_ref() { Some(metrics) => assert_fn(metrics), None => panic!("metrics must be built before running assertions"), } @@ -124,35 +125,55 @@ fn when_metrics_are_built(world: &TraitMetricsWorld) { #[then("total trait items is {count}")] fn then_total_trait_items(world: &TraitMetricsWorld, count: usize) { with_metrics(world, |metrics| { - assert_eq!(metrics.total_item_count(), count); + assert_eq!( + metrics.total_item_count(), + count, + "expected total trait item count of {count}" + ); }); } #[then("required method count is {count}")] fn then_required_method_count(world: &TraitMetricsWorld, count: usize) { with_metrics(world, |metrics| { - assert_eq!(metrics.required_method_count(), count); + assert_eq!( + metrics.required_method_count(), + count, + "expected required method count of {count}" + ); }); } #[then("default method count is {count}")] fn then_default_method_count(world: &TraitMetricsWorld, count: usize) { with_metrics(world, |metrics| { - assert_eq!(metrics.default_method_count(), count); + assert_eq!( + metrics.default_method_count(), + count, + "expected default method count of {count}" + ); }); } #[then("default method CC sum is {sum}")] fn then_default_method_cc_sum(world: &TraitMetricsWorld, sum: usize) { with_metrics(world, |metrics| { - assert_eq!(metrics.default_method_cc_sum(), sum); + assert_eq!( + metrics.default_method_cc_sum(), + sum, + "expected default method CC sum of {sum}" + ); }); } #[then("implementor burden is {count}")] fn then_implementor_burden(world: &TraitMetricsWorld, count: usize) { with_metrics(world, |metrics| { - assert_eq!(metrics.implementor_burden(), count); + assert_eq!( + metrics.implementor_burden(), + count, + "expected implementor burden of {count}" + ); }); } @@ -160,31 +181,19 @@ fn then_implementor_burden(world: &TraitMetricsWorld, count: usize) { // `tests/features/brain_trait_metrics.feature`. #[scenario(path = "tests/features/brain_trait_metrics.feature", index = 0)] -fn scenario_mixed_trait_items(world: TraitMetricsWorld) { - let _ = world; -} +fn scenario_mixed_trait_items(world: TraitMetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_metrics.feature", index = 1)] -fn scenario_without_default_methods(world: TraitMetricsWorld) { - let _ = world; -} +fn scenario_without_default_methods(world: TraitMetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_metrics.feature", index = 2)] -fn scenario_empty_trait(world: TraitMetricsWorld) { - let _ = world; -} +fn scenario_empty_trait(world: TraitMetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_metrics.feature", index = 3)] -fn scenario_expansion_filter(world: TraitMetricsWorld) { - let _ = world; -} +fn scenario_expansion_filter(world: TraitMetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_metrics.feature", index = 4)] -fn scenario_implementor_burden(world: TraitMetricsWorld) { - let _ = world; -} +fn scenario_implementor_burden(world: TraitMetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_trait_metrics.feature", index = 5)] -fn scenario_only_default_methods(world: TraitMetricsWorld) { - let _ = world; -} +fn scenario_only_default_methods(world: TraitMetricsWorld) { let _ = world; } diff --git a/common/tests/brain_type_evaluation_behaviour.rs b/common/tests/brain_type_evaluation_behaviour.rs index 4602ef4d..22811c1b 100644 --- a/common/tests/brain_type_evaluation_behaviour.rs +++ b/common/tests/brain_type_evaluation_behaviour.rs @@ -1,13 +1,23 @@ //! Behaviour-driven coverage for brain type threshold evaluation. +use std::cell::{Cell, RefCell}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; -use whitaker_common::brain_type_metrics::evaluation::{ - BrainTypeDiagnostic, BrainTypeDisposition, BrainTypeThresholds, BrainTypeThresholdsBuilder, - evaluate_brain_type, format_primary_message, +use whitaker_common::brain_type_metrics::{ + MethodMetrics, + TypeMetrics, + TypeMetricsBuilder, + evaluation::{ + BrainTypeDiagnostic, + BrainTypeDisposition, + BrainTypeThresholds, + BrainTypeThresholdsBuilder, + evaluate_brain_type, + format_primary_message, + }, }; -use whitaker_common::brain_type_metrics::{MethodMetrics, TypeMetrics, TypeMetricsBuilder}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug)] struct EvaluationWorld { @@ -85,10 +95,9 @@ impl EvaluationWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> EvaluationWorld { - EvaluationWorld::default() -} +fn world() -> EvaluationWorld { EvaluationWorld::default() } // --- Given steps --- @@ -100,9 +109,7 @@ fn given_type_metrics(world: &EvaluationWorld, name: String, wmc: usize, brain_c } #[given("the type has LCOM4 {lcom4}")] -fn given_lcom4(world: &EvaluationWorld, lcom4: usize) { - world.lcom4.set(lcom4); -} +fn given_lcom4(world: &EvaluationWorld, lcom4: usize) { world.lcom4.set(lcom4); } #[given("the default brain type thresholds")] fn given_default_thresholds(world: &EvaluationWorld) { @@ -184,13 +191,13 @@ fn then_disposition_deny(world: &EvaluationWorld) { reason = "primary message is required for this behaviour test" )] fn then_primary_message_contains(world: &EvaluationWorld, text: String) { - let msg = world.primary_message.borrow(); - let msg = msg + let message_ref = world.primary_message.borrow(); + let message = message_ref .as_deref() .expect("primary message must be formatted first"); assert!( - msg.contains(&text), - "expected primary message to contain '{text}', got: {msg}" + message.contains(&text), + "expected primary message to contain '{text}', got: {message}" ); } @@ -200,46 +207,28 @@ fn then_primary_message_contains(world: &EvaluationWorld, text: String) { // here. #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 0)] -fn scenario_within_limits_passes(world: EvaluationWorld) { - let _ = world; -} +fn scenario_within_limits_passes(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 1)] -fn scenario_all_warn_conditions(world: EvaluationWorld) { - let _ = world; -} +fn scenario_all_warn_conditions(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 2)] -fn scenario_high_wmc_alone(world: EvaluationWorld) { - let _ = world; -} +fn scenario_high_wmc_alone(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 3)] -fn scenario_brain_method_without_wmc(world: EvaluationWorld) { - let _ = world; -} +fn scenario_brain_method_without_wmc(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 4)] -fn scenario_wmc_deny_threshold(world: EvaluationWorld) { - let _ = world; -} +fn scenario_wmc_deny_threshold(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 5)] -fn scenario_multiple_brain_methods_deny(world: EvaluationWorld) { - let _ = world; -} +fn scenario_multiple_brain_methods_deny(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 6)] -fn scenario_high_lcom4_deny(world: EvaluationWorld) { - let _ = world; -} +fn scenario_high_lcom4_deny(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 7)] -fn scenario_deny_supersedes_warn(world: EvaluationWorld) { - let _ = world; -} +fn scenario_deny_supersedes_warn(world: EvaluationWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_evaluation.feature", index = 8)] -fn scenario_diagnostic_surfaces_values(world: EvaluationWorld) { - let _ = world; -} +fn scenario_diagnostic_surfaces_values(world: EvaluationWorld) { let _ = world; } diff --git a/common/tests/brain_type_metrics_behaviour.rs b/common/tests/brain_type_metrics_behaviour.rs index f4ee76ee..ea98687d 100644 --- a/common/tests/brain_type_metrics_behaviour.rs +++ b/common/tests/brain_type_metrics_behaviour.rs @@ -1,12 +1,18 @@ //! Behaviour-driven coverage for brain type metric collection. +use std::cell::{Cell, RefCell}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; use whitaker_common::brain_type_metrics::{ - ForeignReferenceSet, MethodMetrics, TypeMetricsBuilder, brain_methods, foreign_reach_count, + ForeignReferenceSet, + MethodMetrics, + TypeMetricsBuilder, + brain_methods, + foreign_reach_count, weighted_methods_count, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug)] struct MetricsWorld { @@ -47,10 +53,9 @@ impl Default for MetricsWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> MetricsWorld { - MetricsWorld::default() -} +fn world() -> MetricsWorld { MetricsWorld::default() } // --- Helpers --- @@ -66,7 +71,11 @@ fn record_foreign_ref(world: &MetricsWorld, path: &str, is_from_expansion: bool) } fn assert_brain_count(world: &MetricsWorld, n: usize) { - assert_eq!(world.type_metrics_brain_count.get(), Some(n)); + assert_eq!( + world.type_metrics_brain_count.get(), + Some(n), + "expected {n} brain methods to be recorded" + ); } // --- Given steps --- @@ -80,9 +89,7 @@ fn given_method(world: &MetricsWorld, name: String, cc: usize, loc: usize) { } #[given("the brain method CC threshold is {threshold}")] -fn given_cc_threshold(world: &MetricsWorld, threshold: usize) { - world.cc_threshold.set(threshold); -} +fn given_cc_threshold(world: &MetricsWorld, threshold: usize) { world.cc_threshold.set(threshold); } #[given("the brain method LOC threshold is {threshold}")] fn given_loc_threshold(world: &MetricsWorld, threshold: usize) { @@ -90,9 +97,7 @@ fn given_loc_threshold(world: &MetricsWorld, threshold: usize) { } #[given("the LCOM4 value is {value}")] -fn given_lcom4(world: &MetricsWorld, value: usize) { - world.lcom4.set(Some(value)); -} +fn given_lcom4(world: &MetricsWorld, value: usize) { world.lcom4.set(Some(value)); } #[given("the foreign reach count is {count}")] fn given_foreign_reach_count(world: &MetricsWorld, count: usize) { @@ -202,14 +207,10 @@ fn then_type_wmc(world: &MetricsWorld, value: usize) { } #[then("the type has {n} brain method")] -fn then_type_brain_count_singular(world: &MetricsWorld, n: usize) { - assert_brain_count(world, n); -} +fn then_type_brain_count_singular(world: &MetricsWorld, n: usize) { assert_brain_count(world, n); } #[then("the type has {n} brain methods")] -fn then_type_brain_count_plural(world: &MetricsWorld, n: usize) { - assert_brain_count(world, n); -} +fn then_type_brain_count_plural(world: &MetricsWorld, n: usize) { assert_brain_count(world, n); } #[then("the type LCOM4 is {value}")] fn then_type_lcom4(world: &MetricsWorld, value: usize) { @@ -232,51 +233,31 @@ fn then_foreign_reach(world: &MetricsWorld, count: usize) { // here. #[scenario(path = "tests/features/brain_type_metrics.feature", index = 0)] -fn scenario_wmc_sum(world: MetricsWorld) { - let _ = world; -} +fn scenario_wmc_sum(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 1)] -fn scenario_brain_method_qualifies(world: MetricsWorld) { - let _ = world; -} +fn scenario_brain_method_qualifies(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 2)] -fn scenario_below_both_thresholds(world: MetricsWorld) { - let _ = world; -} +fn scenario_below_both_thresholds(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 3)] -fn scenario_only_cc_threshold(world: MetricsWorld) { - let _ = world; -} +fn scenario_only_cc_threshold(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 4)] -fn scenario_only_loc_threshold(world: MetricsWorld) { - let _ = world; -} +fn scenario_only_loc_threshold(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 5)] -fn scenario_empty_type_zero_wmc(world: MetricsWorld) { - let _ = world; -} +fn scenario_empty_type_zero_wmc(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 6)] -fn scenario_type_metrics_aggregate(world: MetricsWorld) { - let _ = world; -} +fn scenario_type_metrics_aggregate(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 7)] -fn scenario_foreign_refs_deduplicated(world: MetricsWorld) { - let _ = world; -} +fn scenario_foreign_refs_deduplicated(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 8)] -fn scenario_macro_expanded_foreign_filtered(world: MetricsWorld) { - let _ = world; -} +fn scenario_macro_expanded_foreign_filtered(world: MetricsWorld) { let _ = world; } #[scenario(path = "tests/features/brain_type_metrics.feature", index = 9)] -fn scenario_foreign_reach_convenience(world: MetricsWorld) { - let _ = world; -} +fn scenario_foreign_reach_convenience(world: MetricsWorld) { let _ = world; } diff --git a/common/tests/cognitive_complexity_behaviour.rs b/common/tests/cognitive_complexity_behaviour.rs index 16f9846e..9d715ec3 100644 --- a/common/tests/cognitive_complexity_behaviour.rs +++ b/common/tests/cognitive_complexity_behaviour.rs @@ -1,9 +1,11 @@ //! Behaviour-driven coverage for cognitive complexity computation. +use std::cell::{Cell, RefCell}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; use whitaker_common::brain_type_metrics::cognitive_complexity::CognitiveComplexityBuilder; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug)] struct CcWorld { @@ -20,19 +22,23 @@ impl Default for CcWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> CcWorld { - CcWorld::default() -} +fn world() -> CcWorld { CcWorld::default() } // --- Helpers --- /// Borrows the builder mutably and applies a closure to it. -fn with_builder(world: &CcWorld, f: impl FnOnce(&mut CognitiveComplexityBuilder)) { +fn with_builder( + world: &CcWorld, + f: impl FnOnce(&mut CognitiveComplexityBuilder), +) -> Result<(), String> { let mut slot = world.builder.borrow_mut(); - f(slot + let builder = slot .as_mut() - .unwrap_or_else(|| panic!("builder already consumed"))); + .ok_or_else(|| String::from("builder already consumed"))?; + f(builder); + Ok(()) } // --- Given steps --- @@ -44,55 +50,59 @@ fn given_new_builder(world: &CcWorld) { } #[given("a structural increment not from expansion")] -fn given_structural_not_expanded(world: &CcWorld) { - with_builder(world, |b| b.record_structural_increment(false)); +fn given_structural_not_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.record_structural_increment(false)) } #[given("a structural increment from expansion")] -fn given_structural_expanded(world: &CcWorld) { - with_builder(world, |b| b.record_structural_increment(true)); +fn given_structural_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.record_structural_increment(true)) } #[given("a nesting increment not from expansion")] -fn given_nesting_not_expanded(world: &CcWorld) { - with_builder(world, |b| b.record_nesting_increment(false)); +fn given_nesting_not_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.record_nesting_increment(false)) } #[given("a fundamental increment not from expansion")] -fn given_fundamental_not_expanded(world: &CcWorld) { - with_builder(world, |b| b.record_fundamental_increment(false)); +fn given_fundamental_not_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.record_fundamental_increment(false)) } #[given("a fundamental increment from expansion")] -fn given_fundamental_expanded(world: &CcWorld) { - with_builder(world, |b| b.record_fundamental_increment(true)); +fn given_fundamental_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.record_fundamental_increment(true)) } #[given("nesting is pushed not from expansion")] -fn given_push_nesting_not_expanded(world: &CcWorld) { - with_builder(world, |b| b.push_nesting(false)); +fn given_push_nesting_not_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.push_nesting(false)) } #[given("nesting is pushed from expansion")] -fn given_push_nesting_expanded(world: &CcWorld) { - with_builder(world, |b| b.push_nesting(true)); +fn given_push_nesting_expanded(world: &CcWorld) -> Result<(), String> { + with_builder(world, |b| b.push_nesting(true)) } #[given("nesting is popped")] -fn given_pop_nesting(world: &CcWorld) { - with_builder(world, |b| b.pop_nesting()); +fn given_pop_nesting(world: &CcWorld) -> Result<(), String> { + with_builder( + world, + whitaker_common::CognitiveComplexityBuilder::pop_nesting, + ) } // --- When steps --- -#[when("the complexity is finalised")] -fn when_finalised(world: &CcWorld) { +#[when("the complexity is finalized")] +fn when_finalized(world: &CcWorld) -> Result<(), String> { let builder = world .builder .borrow_mut() .take() - .unwrap_or_else(|| panic!("builder already consumed")); + .ok_or_else(|| String::from("builder already consumed"))?; world.score_result.set(Some(builder.build())); + Ok(()) } // --- Then steps --- @@ -108,41 +118,25 @@ fn then_score_is(world: &CcWorld, expected: usize) { // here. #[scenario(path = "tests/features/cognitive_complexity.feature", index = 0)] -fn scenario_empty_function(world: CcWorld) { - let _ = world; -} +fn scenario_empty_function(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 1)] -fn scenario_single_if(world: CcWorld) { - let _ = world; -} +fn scenario_single_if(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 2)] -fn scenario_nested_if(world: CcWorld) { - let _ = world; -} +fn scenario_nested_if(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 3)] -fn scenario_macro_structural_excluded(world: CcWorld) { - let _ = world; -} +fn scenario_macro_structural_excluded(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 4)] -fn scenario_macro_nesting_no_inflate(world: CcWorld) { - let _ = world; -} +fn scenario_macro_nesting_no_inflate(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 5)] -fn scenario_boolean_operators(world: CcWorld) { - let _ = world; -} +fn scenario_boolean_operators(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 6)] -fn scenario_mixed_real_and_expansion(world: CcWorld) { - let _ = world; -} +fn scenario_mixed_real_and_expansion(world: CcWorld) { let _ = world; } #[scenario(path = "tests/features/cognitive_complexity.feature", index = 7)] -fn scenario_fundamental_from_expansion_excluded(world: CcWorld) { - let _ = world; -} +fn scenario_fundamental_from_expansion_excluded(world: CcWorld) { let _ = world; } diff --git a/common/tests/complexity_signal_behaviour.rs b/common/tests/complexity_signal_behaviour.rs index ad05d252..72c8c7f8 100644 --- a/common/tests/complexity_signal_behaviour.rs +++ b/common/tests/complexity_signal_behaviour.rs @@ -1,11 +1,17 @@ //! Behaviour-driven coverage for per-line complexity signal building and smoothing. +use std::cell::{Cell, RefCell}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; use whitaker_common::complexity_signal::{ - LineSegment, SignalBuildError, SmoothingError, rasterize_signal, smooth_moving_average, + LineSegment, + SignalBuildError, + SmoothingError, + rasterize_signal, + smooth_moving_average, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct SignalWorld { @@ -24,17 +30,11 @@ impl SignalWorld { self.function_end.set(Some(end)); } - fn push_segment(&self, segment: LineSegment) { - self.segments.borrow_mut().push(segment); - } + fn push_segment(&self, segment: LineSegment) { self.segments.borrow_mut().push(segment); } - fn set_raw_signal(&self, signal: Vec) { - self.raw_signal.replace(Some(signal)); - } + fn set_raw_signal(&self, signal: Vec) { self.raw_signal.replace(Some(signal)); } - fn set_smoothing_window(&self, window: usize) { - self.smoothing_window.set(Some(window)); - } + fn set_smoothing_window(&self, window: usize) { self.smoothing_window.set(Some(window)); } #[expect( clippy::expect_used, @@ -97,16 +97,19 @@ impl SignalWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> SignalWorld { - SignalWorld::default() -} +fn world() -> SignalWorld { SignalWorld::default() } /// Parses a comma-separated list of floating-point values. /// /// The feature text uses values like `0.0, 1.0, 2.0`. Whitespace is ignored and /// empty segments are skipped. -fn parse_f64_list(values: &str) -> Vec { +/// +/// # Errors +/// +/// Returns a description of the first segment that is not a valid `f64`. +fn parse_f64_list(values: &str) -> Result, String> { values .split(',') .map(str::trim) @@ -114,15 +117,41 @@ fn parse_f64_list(values: &str) -> Vec { .map(|chunk| { chunk .parse::() - .unwrap_or_else(|error| panic!("failed to parse `{chunk}` as f64: {error}")) + .map_err(|error| format!("failed to parse `{chunk}` as f64: {error}")) }) .collect() } +/// Maximum permitted distance between two floats in units of least precision. +const MAX_ULP_DISTANCE: u64 = 4; + +/// Maps a float to a monotonically ordered integer for ULP comparison. +/// +/// Negative values have their bits inverted and non-negative values have the +/// sign bit set, so the resulting integers order the same way as the floats. +const fn monotonic_bits(value: f64) -> u64 { + let bits = value.to_bits(); + if (bits & (1 << 63)) != 0 { + !bits + } else { + bits | (1 << 63) + } +} + +/// Returns the distance between two floats in units of least precision, or +/// `None` when either value is NaN. +const fn ulp_distance(left: f64, right: f64) -> Option { + if left.is_nan() || right.is_nan() { + return None; + } + Some(monotonic_bits(left).abs_diff(monotonic_bits(right))) +} + /// Asserts that two floating-point vectors are equal within a tiny tolerance. /// /// This helper is intended for deterministic test values that may experience -/// insignificant rounding differences. +/// insignificant rounding differences. Comparison uses units of least +/// precision (ULPs), which avoids floating-point arithmetic in the test. fn assert_vec_approx_eq(actual: &[f64], expected: &[f64]) { assert_eq!( actual.len(), @@ -132,11 +161,12 @@ fn assert_vec_approx_eq(actual: &[f64], expected: &[f64]) { actual_len = actual.len() ); - for (idx, (actual, expected)) in actual.iter().zip(expected.iter()).enumerate() { - let delta = (actual - expected).abs(); + for (idx, (actual_value, expected_value)) in actual.iter().zip(expected.iter()).enumerate() { + let distance = ulp_distance(*actual_value, *expected_value); assert!( - delta <= 1e-12, - "expected element {idx} to be {expected}, got {actual} (delta {delta})", + distance.is_some_and(|ulps| ulps <= MAX_ULP_DISTANCE), + "expected element {idx} to be {expected_value}, got {actual_value} (ULP distance \ + {distance:?})", ); } } @@ -147,81 +177,75 @@ fn given_function_range(world: &SignalWorld, start: usize, end: usize) { } #[given("a segment from line {start} to {end} with value {value}")] -fn given_segment(world: &SignalWorld, start: usize, end: usize, value: f64) { +fn given_segment(world: &SignalWorld, start: usize, end: usize, value: f64) -> Result<(), String> { let segment = LineSegment::new(start, end, value) - .unwrap_or_else(|error| panic!("segment inputs should be valid: {error}")); + .map_err(|error| format!("segment inputs should be valid: {error}"))?; world.push_segment(segment); + Ok(()) } #[given("the raw signal is {values}")] -fn given_raw_signal(world: &SignalWorld, values: String) { - world.set_raw_signal(parse_f64_list(&values)); +fn given_raw_signal(world: &SignalWorld, values: String) -> Result<(), String> { + world.set_raw_signal(parse_f64_list(&values)?); + Ok(()) } #[given("the smoothing window is {window}")] -fn given_window(world: &SignalWorld, window: usize) { - world.set_smoothing_window(window); -} +fn given_window(world: &SignalWorld, window: usize) { world.set_smoothing_window(window); } #[when("I build the per-line signal")] -fn when_build(world: &SignalWorld) { - world.build_signal(); -} +fn when_build(world: &SignalWorld) { world.build_signal(); } #[when("I smooth the signal")] -fn when_smooth(world: &SignalWorld) { - world.smooth(); -} +fn when_smooth(world: &SignalWorld) { world.smooth(); } #[then("the built signal equals {expected}")] -fn then_built_signal(world: &SignalWorld, expected: String) { +fn then_built_signal(world: &SignalWorld, expected: String) -> Result<(), String> { let actual = world .built_signal() - .unwrap_or_else(|error| panic!("signal build should succeed: {error}")); - let expected = parse_f64_list(&expected); - assert_vec_approx_eq(&actual, &expected); + .map_err(|error| format!("signal build should succeed: {error}"))?; + let expected_values = parse_f64_list(&expected)?; + assert_vec_approx_eq(&actual, &expected_values); + Ok(()) } #[then("signal building fails")] fn then_build_fails(world: &SignalWorld) { - assert!(world.built_signal().is_err()); + assert!( + world.built_signal().is_err(), + "expected signal building to fail" + ); } #[then("the smoothed signal equals {expected}")] -fn then_smoothed_signal(world: &SignalWorld, expected: String) { +fn then_smoothed_signal(world: &SignalWorld, expected: String) -> Result<(), String> { let actual = world .smoothed_signal() - .unwrap_or_else(|error| panic!("smoothing should succeed: {error}")); - let expected = parse_f64_list(&expected); - assert_vec_approx_eq(&actual, &expected); + .map_err(|error| format!("smoothing should succeed: {error}"))?; + let expected_values = parse_f64_list(&expected)?; + assert_vec_approx_eq(&actual, &expected_values); + Ok(()) } #[then("smoothing fails")] fn then_smoothing_fails(world: &SignalWorld) { - assert!(world.smoothed_signal().is_err()); + assert!( + world.smoothed_signal().is_err(), + "expected smoothing to fail" + ); } #[scenario(path = "tests/features/complexity_signal.feature", index = 0)] -fn scenario_overlapping_segments(world: SignalWorld) { - let _ = world; -} +fn scenario_overlapping_segments(world: SignalWorld) { let _ = world; } #[scenario(path = "tests/features/complexity_signal.feature", index = 1)] -fn scenario_out_of_range_segments(world: SignalWorld) { - let _ = world; -} +fn scenario_out_of_range_segments(world: SignalWorld) { let _ = world; } #[scenario(path = "tests/features/complexity_signal.feature", index = 2)] -fn scenario_smoothing_happy_path(world: SignalWorld) { - let _ = world; -} +fn scenario_smoothing_happy_path(world: SignalWorld) { let _ = world; } #[scenario(path = "tests/features/complexity_signal.feature", index = 3)] -fn scenario_smoothing_even_window(world: SignalWorld) { - let _ = world; -} +fn scenario_smoothing_even_window(world: SignalWorld) { let _ = world; } #[scenario(path = "tests/features/complexity_signal.feature", index = 4)] -fn scenario_smoothing_zero_window(world: SignalWorld) { - let _ = world; -} +fn scenario_smoothing_zero_window(world: SignalWorld) { let _ = world; } diff --git a/common/tests/context_behaviour.rs b/common/tests/context_behaviour.rs index 27e8f64d..80c9190b 100644 --- a/common/tests/context_behaviour.rs +++ b/common/tests/context_behaviour.rs @@ -3,11 +3,15 @@ //! Validates detection of standard test attributes (`#[rstest]`, `#[tokio::test]`) //! and custom attributes configured via the additional attribute set. +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -use whitaker_common::context::{ContextEntry, in_test_like_context_with, is_test_fn_with}; +use whitaker_common::{ + attributes::{Attribute, AttributeKind, AttributePath}, + context::{ContextEntry, in_test_like_context_with, is_test_fn_with}, +}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Clone, Debug, Default)] struct FunctionFixture { @@ -40,37 +44,29 @@ impl FunctionFixture { self.additional.borrow_mut().clear(); } - fn attributes(&self) -> std::cell::Ref<'_, Vec> { - self.attributes.borrow() - } + fn attributes(&self) -> std::cell::Ref<'_, Vec> { self.attributes.borrow() } - fn context(&self) -> std::cell::Ref<'_, Vec> { - self.context.borrow() - } + fn context(&self) -> std::cell::Ref<'_, Vec> { self.context.borrow() } - fn additional(&self) -> std::cell::Ref<'_, Vec> { - self.additional.borrow() - } + fn additional(&self) -> std::cell::Ref<'_, Vec> { self.additional.borrow() } fn configure_additional(&self, path: &str) { self.additional.borrow_mut().push(AttributePath::from(path)); } } +#[allow_fixture_expansion_lints] #[fixture] -fn function() -> FunctionFixture { - FunctionFixture::new() -} +fn function() -> FunctionFixture { FunctionFixture::new() } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct Evaluation { is_test: bool, in_context: bool, } +#[allow_fixture_expansion_lints] #[fixture] -fn evaluation() -> Evaluation { - Evaluation::default() -} +fn evaluation() -> Evaluation { Evaluation::default() } #[given("a function annotated with rstest")] fn given_rstest(function: &FunctionFixture) { @@ -85,11 +81,9 @@ fn given_tokio(function: &FunctionFixture) { } #[given("a function without test attributes")] -fn given_plain(function: &FunctionFixture) { - function.clear(); -} +fn given_plain(function: &FunctionFixture) { function.clear(); } -#[given("the lint recognises {path} as a test attribute")] +#[given("the lint recognizes {path} as a test attribute")] fn given_custom_attribute(function: &FunctionFixture, path: String) { function.configure_additional(&path); } @@ -111,7 +105,7 @@ fn when_check(function: &FunctionFixture) -> Evaluation { } } -#[then("the function is recognised as test-like")] +#[then("the function is recognized as test-like")] fn then_positive(evaluation: &Evaluation) { assert!(evaluation.is_test); } @@ -121,7 +115,7 @@ fn then_context_positive(evaluation: &Evaluation) { assert!(evaluation.in_context); } -#[then("the function is recognised as not test-like")] +#[then("the function is recognized as not test-like")] fn then_negative(evaluation: &Evaluation) { assert!(!evaluation.is_test); } @@ -147,6 +141,6 @@ fn scenario_ignores_plain(function: FunctionFixture, evaluation: Evaluation) { } #[scenario(path = "tests/features/context_detection.feature", index = 3)] -fn scenario_recognises_custom(function: FunctionFixture, evaluation: Evaluation) { +fn scenario_recognizes_custom(function: FunctionFixture, evaluation: Evaluation) { let _ = (function, evaluation); } diff --git a/common/tests/cosine_threshold_behaviour.rs b/common/tests/cosine_threshold_behaviour.rs index e30f1f5c..ddf9c78a 100644 --- a/common/tests/cosine_threshold_behaviour.rs +++ b/common/tests/cosine_threshold_behaviour.rs @@ -1,12 +1,14 @@ //! Behaviour-driven coverage for the decomposition cosine threshold. +use std::{cell::RefCell, collections::BTreeMap, str::FromStr}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::collections::BTreeMap; -use std::str::FromStr; -use whitaker_common::MethodProfileBuilder; -use whitaker_common::test_support::decomposition::methods_meet_cosine_threshold; +use whitaker_common::{ + MethodProfileBuilder, + test_support::decomposition::methods_meet_cosine_threshold, +}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Clone, Copy, Debug)] enum MethodSide { @@ -15,7 +17,7 @@ enum MethodSide { } impl MethodSide { - fn key(self) -> &'static str { + const fn key(self) -> &'static str { match self { Self::Left => "left", Self::Right => "right", @@ -39,9 +41,7 @@ impl FromStr for MethodSide { struct CsvList(Vec); impl CsvList { - fn into_vec(self) -> Vec { - self.0 - } + fn into_vec(self) -> Vec { self.0 } } impl FromStr for CsvList { @@ -64,10 +64,9 @@ struct CosineThresholdWorld { threshold_met: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> CosineThresholdWorld { - CosineThresholdWorld::default() -} +fn world() -> CosineThresholdWorld { CosineThresholdWorld::default() } fn ensure_method_builder(world: &CosineThresholdWorld, side: MethodSide, method_name: &str) { world.methods.borrow_mut().insert( @@ -189,16 +188,10 @@ fn then_methods_are_not_similar(world: &CosineThresholdWorld) -> Result<(), Stri // `tests/features/cosine_threshold.feature`. #[scenario(path = "tests/features/cosine_threshold.feature", index = 0)] -fn scenario_strong_overlap(world: CosineThresholdWorld) { - let _ = world; -} +fn scenario_strong_overlap(world: CosineThresholdWorld) { let _ = world; } #[scenario(path = "tests/features/cosine_threshold.feature", index = 1)] -fn scenario_below_threshold(world: CosineThresholdWorld) { - let _ = world; -} +fn scenario_below_threshold(world: CosineThresholdWorld) { let _ = world; } #[scenario(path = "tests/features/cosine_threshold.feature", index = 2)] -fn scenario_zero_vector(world: CosineThresholdWorld) { - let _ = world; -} +fn scenario_zero_vector(world: CosineThresholdWorld) { let _ = world; } diff --git a/common/tests/decomposition_adjacency_behaviour.rs b/common/tests/decomposition_adjacency_behaviour.rs index c0f8cd79..8c67c27d 100644 --- a/common/tests/decomposition_adjacency_behaviour.rs +++ b/common/tests/decomposition_adjacency_behaviour.rs @@ -1,11 +1,16 @@ //! Behaviour-driven coverage for decomposition adjacency construction. +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; use whitaker_common::test_support::decomposition::{ - AdjacencyError, AdjacencyReport, EdgeInput, adjacency_report, + AdjacencyError, + AdjacencyReport, + EdgeInput, + adjacency_report, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct AdjacencyWorld { @@ -14,10 +19,9 @@ struct AdjacencyWorld { result: RefCell>>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> AdjacencyWorld { - AdjacencyWorld::default() -} +fn world() -> AdjacencyWorld { AdjacencyWorld::default() } #[given("a graph with {count} nodes")] fn given_graph_with_nodes(world: &AdjacencyWorld, count: usize) { @@ -46,15 +50,28 @@ fn with_report( assert_fn: impl FnOnce(&AdjacencyReport) -> Result<(), String>, ) -> Result<(), String> { let result = world.result.borrow(); - let report = result + let outcome = result .as_ref() .ok_or_else(|| String::from("adjacency must be built before assertions"))?; - match report { + match outcome { Ok(report) => assert_fn(report), Err(message) => Err(format!("expected successful build, got error: {message}")), } } +fn with_neighbours_of_node( + world: &AdjacencyWorld, + node: usize, + assert_fn: impl FnOnce(&[(usize, u64)]) -> Result<(), String>, +) -> Result<(), String> { + with_report(world, |report| { + let neighbours = report + .neighbours_of(node) + .ok_or_else(|| format!("node {node} is out of bounds"))?; + assert_fn(neighbours) + }) +} + #[then("the adjacency is symmetric")] fn then_adjacency_is_symmetric(world: &AdjacencyWorld) -> Result<(), String> { with_report(world, |report| { @@ -93,10 +110,7 @@ fn then_build_is_rejected(world: &AdjacencyWorld) -> Result<(), String> { #[then("node {node} has no neighbours")] fn then_node_has_no_neighbours(world: &AdjacencyWorld, node: usize) -> Result<(), String> { - with_report(world, |report| { - let neighbours = report - .neighbours_of(node) - .ok_or_else(|| format!("node {node} is out of bounds"))?; + with_neighbours_of_node(world, node, |neighbours| { if neighbours.is_empty() { Ok(()) } else { @@ -107,11 +121,8 @@ fn then_node_has_no_neighbours(world: &AdjacencyWorld, node: usize) -> Result<() #[then("the neighbours of node {node} are sorted")] fn then_neighbours_of_node_are_sorted(world: &AdjacencyWorld, node: usize) -> Result<(), String> { - with_report(world, |report| { - let neighbours = report - .neighbours_of(node) - .ok_or_else(|| format!("node {node} is out of bounds"))?; - let is_sorted = neighbours.windows(2).all(|pair| pair[0].0 <= pair[1].0); + with_neighbours_of_node(world, node, |neighbours| { + let is_sorted = neighbours.is_sorted_by_key(|neighbour| neighbour.0); if is_sorted { Ok(()) } else { @@ -124,21 +135,13 @@ fn then_neighbours_of_node_are_sorted(world: &AdjacencyWorld, node: usize) -> Re // `tests/features/decomposition_adjacency.feature`. #[scenario(path = "tests/features/decomposition_adjacency.feature", index = 0)] -fn scenario_valid_edges_produce_symmetric_neighbour_lists(world: AdjacencyWorld) { - let _ = world; -} +fn scenario_valid_edges_produce_symmetric_neighbour_lists(world: AdjacencyWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_adjacency.feature", index = 1)] -fn scenario_malformed_edge_input_rejected_canonical_order(world: AdjacencyWorld) { - let _ = world; -} +fn scenario_malformed_edge_input_rejected_canonical_order(world: AdjacencyWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_adjacency.feature", index = 2)] -fn scenario_isolated_nodes_have_empty_neighbour_lists(world: AdjacencyWorld) { - let _ = world; -} +fn scenario_isolated_nodes_have_empty_neighbour_lists(world: AdjacencyWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_adjacency.feature", index = 3)] -fn scenario_multiple_neighbours_appear_in_sorted_order(world: AdjacencyWorld) { - let _ = world; -} +fn scenario_multiple_neighbours_appear_in_sorted_order(world: AdjacencyWorld) { let _ = world; } diff --git a/common/tests/decomposition_advice_behaviour.rs b/common/tests/decomposition_advice_behaviour.rs index 42966ac6..e67e2592 100644 --- a/common/tests/decomposition_advice_behaviour.rs +++ b/common/tests/decomposition_advice_behaviour.rs @@ -1,21 +1,24 @@ //! Behaviour-driven coverage for decomposition advice analysis. +use std::{cell::RefCell, collections::BTreeMap}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::collections::BTreeMap; use whitaker_common::decomposition_advice::{ - DecompositionContext, DecompositionSuggestion, MethodProfileBuilder, SubjectKind, - SuggestedExtractionKind, suggest_decomposition, + DecompositionContext, + DecompositionSuggestion, + MethodProfileBuilder, + SubjectKind, + SuggestedExtractionKind, + suggest_decomposition, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Clone)] struct CsvList(Vec); impl CsvList { - fn into_vec(self) -> Vec { - self.0 - } + fn into_vec(self) -> Vec { self.0 } } impl std::str::FromStr for CsvList { @@ -28,7 +31,7 @@ impl std::str::FromStr for CsvList { .filter(|v| !v.is_empty()) .map(ToOwned::to_owned) .collect(); - Ok(CsvList(items)) + Ok(Self(items)) } } @@ -41,12 +44,11 @@ struct DecompositionWorld { suggestions: RefCell>>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> DecompositionWorld { - DecompositionWorld::default() -} +fn world() -> DecompositionWorld { DecompositionWorld::default() } -fn create_method_builder(world: &DecompositionWorld, method_name: &str) { +fn create_method_builder(world: &DecompositionWorld, method_name: &str) -> usize { let mut next_method_id = world.next_method_id.borrow_mut(); let method_id = *next_method_id; *next_method_id += 1; @@ -61,6 +63,17 @@ fn create_method_builder(world: &DecompositionWorld, method_name: &str) { .entry(method_name.to_owned()) .or_default() .push(method_id); + method_id +} + +/// Returns the identifier of the most recently created builder for +/// `method_name`, if any. +fn lookup_method_id(world: &DecompositionWorld, method_name: &str) -> Option { + world + .method_ids_by_name + .borrow() + .get(method_name) + .and_then(|ids| ids.last().copied()) } fn with_method_builder( @@ -68,27 +81,8 @@ fn with_method_builder( method_name: &str, update: impl FnOnce(&mut MethodProfileBuilder), ) { - let method_id = world - .method_ids_by_name - .borrow() - .get(method_name) - .and_then(|ids| ids.last().copied()); - - let method_id = match method_id { - Some(method_id) => method_id, - None => { - create_method_builder(world, method_name); - let method_id = world - .method_ids_by_name - .borrow() - .get(method_name) - .and_then(|ids| ids.last().copied()); - let Some(method_id) = method_id else { - panic!("method id must exist after creation"); - }; - method_id - } - }; + let method_id = lookup_method_id(world, method_name) + .unwrap_or_else(|| create_method_builder(world, method_name)); let mut methods = world.methods.borrow_mut(); let Some(builder) = methods.get_mut(&method_id) else { @@ -101,8 +95,8 @@ fn with_suggestions( world: &DecompositionWorld, assert_fn: impl FnOnce(&[DecompositionSuggestion]) -> Result<(), String>, ) -> Result<(), String> { - let suggestions = world.suggestions.borrow(); - let Some(suggestions) = suggestions.as_ref() else { + let suggestions_ref = world.suggestions.borrow(); + let Some(suggestions) = suggestions_ref.as_ref() else { return Err(String::from( "suggestions must be generated before running assertions", )); @@ -153,19 +147,12 @@ fn duplicate_method_names_use_distinct_builders() { } #[given("decomposition analysis for a {kind} named {name}")] -fn given_context( - world: &DecompositionWorld, - kind: SubjectKind, - name: String, -) -> Result<(), String> { +fn given_context(world: &DecompositionWorld, kind: SubjectKind, name: String) { *world.context.borrow_mut() = Some(DecompositionContext::new(name, kind)); - Ok(()) } #[given("a method named {name}")] -fn given_method(world: &DecompositionWorld, name: String) { - create_method_builder(world, &name); -} +fn given_method(world: &DecompositionWorld, name: String) { create_method_builder(world, &name); } #[given("method {name} accesses fields {fields}")] fn given_fields(world: &DecompositionWorld, name: String, fields: CsvList) { @@ -275,8 +262,8 @@ fn then_matching_suggestion( .map(|s| format!("{}:{}:{:?}", s.label(), s.extraction_kind(), s.methods())) .collect::>(); Err(format!( - "missing {kind} suggestion labelled {label} containing methods {:?}; actual suggestions: {:?}", - expected_methods, actual + "missing {kind} suggestion labelled {label} containing methods \ + {expected_methods:?}; actual suggestions: {actual:?}" )) } }) @@ -312,26 +299,16 @@ fn then_suggestion_has_rationale( // `tests/features/decomposition_advice.feature` file. #[scenario(path = "tests/features/decomposition_advice.feature", index = 0)] -fn scenario_type_method_groups(world: DecompositionWorld) { - let _ = world; -} +fn scenario_type_method_groups(world: DecompositionWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_advice.feature", index = 1)] -fn scenario_trait_sub_traits(world: DecompositionWorld) { - let _ = world; -} +fn scenario_trait_sub_traits(world: DecompositionWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_advice.feature", index = 2)] -fn scenario_no_suggestions(world: DecompositionWorld) { - let _ = world; -} +fn scenario_no_suggestions(world: DecompositionWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_advice.feature", index = 3)] -fn scenario_singleton_noise(world: DecompositionWorld) { - let _ = world; -} +fn scenario_singleton_noise(world: DecompositionWorld) { let _ = world; } #[scenario(path = "tests/features/decomposition_advice.feature", index = 4)] -fn scenario_local_type_groups(world: DecompositionWorld) { - let _ = world; -} +fn scenario_local_type_groups(world: DecompositionWorld) { let _ = world; } diff --git a/common/tests/decomposition_diagnostic_notes_behaviour.rs b/common/tests/decomposition_diagnostic_notes_behaviour.rs index 966acd2d..c94fce25 100644 --- a/common/tests/decomposition_diagnostic_notes_behaviour.rs +++ b/common/tests/decomposition_diagnostic_notes_behaviour.rs @@ -1,33 +1,33 @@ //! Behaviour-driven coverage for decomposition diagnostic-note rendering. +use std::{cell::RefCell, collections::BTreeMap}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::collections::BTreeMap; -use whitaker_common::decomposition_advice::{ - DecompositionContext, MethodProfileBuilder, SubjectKind, format_diagnostic_note, - suggest_decomposition, -}; -use whitaker_common::test_support::decomposition::{ - parser_serde_fs_fixture, transport_trait_fixture, +use whitaker_common::{ + decomposition_advice::{ + DecompositionContext, + MethodProfileBuilder, + SubjectKind, + format_diagnostic_note, + suggest_decomposition, + }, + test_support::decomposition::{parser_serde_fs_fixture, transport_trait_fixture}, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Clone)] struct CsvList(Vec); impl CsvList { - fn into_vec(self) -> Vec { - self.0 - } + fn into_vec(self) -> Vec { self.0 } } #[derive(Debug, Clone)] struct QuotedString(String); impl QuotedString { - fn into_inner(self) -> String { - self.0 - } + fn into_inner(self) -> String { self.0 } } impl std::str::FromStr for CsvList { @@ -47,9 +47,7 @@ impl std::str::FromStr for CsvList { impl std::str::FromStr for QuotedString { type Err = std::convert::Infallible; - fn from_str(s: &str) -> Result { - Ok(Self(s.trim_matches('"').to_owned())) - } + fn from_str(s: &str) -> Result { Ok(Self(s.trim_matches('"').to_owned())) } } #[derive(Debug, Default)] @@ -62,12 +60,11 @@ struct DiagnosticNoteWorld { rendered_note: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> DiagnosticNoteWorld { - DiagnosticNoteWorld::default() -} +fn world() -> DiagnosticNoteWorld { DiagnosticNoteWorld::default() } -fn create_method_builder(world: &DiagnosticNoteWorld, method_name: &str) { +fn create_method_builder(world: &DiagnosticNoteWorld, method_name: &str) -> usize { let mut next_method_id = world.next_method_id.borrow_mut(); let method_id = *next_method_id; *next_method_id += 1; @@ -82,37 +79,33 @@ fn create_method_builder(world: &DiagnosticNoteWorld, method_name: &str) { .entry(method_name.to_owned()) .or_default() .push(method_id); + method_id +} + +/// Returns the identifier of the most recently created builder for +/// `method_name`, if any. +fn lookup_method_id(world: &DiagnosticNoteWorld, method_name: &str) -> Option { + world + .method_ids_by_name + .borrow() + .get(method_name) + .and_then(|ids| ids.last().copied()) } fn with_method_builder( world: &DiagnosticNoteWorld, method_name: &str, update: impl FnOnce(&mut MethodProfileBuilder), -) { - let method_id = world - .method_ids_by_name - .borrow() - .get(method_name) - .and_then(|ids| ids.last().copied()); - - let method_id = match method_id { - Some(method_id) => method_id, - None => { - create_method_builder(world, method_name); - world - .method_ids_by_name - .borrow() - .get(method_name) - .and_then(|ids| ids.last().copied()) - .unwrap_or_else(|| panic!("method id must exist after creation")) - } - }; +) -> Result<(), String> { + let method_id = lookup_method_id(world, method_name) + .unwrap_or_else(|| create_method_builder(world, method_name)); let mut methods = world.methods.borrow_mut(); let builder = methods .get_mut(&method_id) - .unwrap_or_else(|| panic!("method id {method_id} must exist while applying updates")); + .ok_or_else(|| format!("method id {method_id} must exist while applying updates"))?; update(builder); + Ok(()) } fn with_rendered_note( @@ -166,9 +159,7 @@ fn given_context(world: &DiagnosticNoteWorld, kind: SubjectKind, name: String) { } #[given("a method named {name}")] -fn given_method(world: &DiagnosticNoteWorld, name: String) { - create_method_builder(world, &name); -} +fn given_method(world: &DiagnosticNoteWorld, name: String) { create_method_builder(world, &name); } #[given("the parser, serde, and filesystem methods are tracked")] fn given_parser_serde_fs_fixture(world: &DiagnosticNoteWorld) { @@ -181,23 +172,27 @@ fn given_transport_fixture(world: &DiagnosticNoteWorld) { } #[given("method {name} accesses fields {fields}")] -fn given_fields(world: &DiagnosticNoteWorld, name: String, fields: CsvList) { +fn given_fields(world: &DiagnosticNoteWorld, name: String, fields: CsvList) -> Result<(), String> { let parsed_fields = fields.into_vec(); with_method_builder(world, &name, |builder| { for field in &parsed_fields { builder.record_accessed_field(field.as_str()); } - }); + }) } #[given("method {name} uses external domains {domains}")] -fn given_external_domains(world: &DiagnosticNoteWorld, name: String, domains: CsvList) { +fn given_external_domains( + world: &DiagnosticNoteWorld, + name: String, + domains: CsvList, +) -> Result<(), String> { let parsed_domains = domains.into_vec(); with_method_builder(world, &name, |builder| { for domain in &parsed_domains { builder.record_external_domain(domain.as_str()); } - }); + }) } #[when("the decomposition diagnostic note is rendered")] @@ -228,24 +223,25 @@ fn then_note_is_present(world: &DiagnosticNoteWorld) -> Result<(), String> { #[then("there is no note")] fn then_there_is_no_note(world: &DiagnosticNoteWorld) -> Result<(), String> { - with_rendered_note(world, |rendered_note| match rendered_note { - Some(note) => Err(format!("expected no note but found:\n{note}")), - None => Ok(()), + with_rendered_note(world, |rendered_note| { + rendered_note.as_ref().map_or(Ok(()), |note| { + Err(format!("expected no note but found:\n{note}")) + }) }) } #[then("the note contains line {line}")] fn then_note_contains_line(world: &DiagnosticNoteWorld, line: QuotedString) -> Result<(), String> { - let line = line.into_inner(); + let expected_line = line.into_inner(); with_rendered_note(world, |rendered_note| { let note = rendered_note .as_ref() .ok_or_else(|| String::from("expected a rendered note but found none"))?; - if note.lines().any(|candidate| candidate == line) { + if note.lines().any(|candidate| candidate == expected_line) { Ok(()) } else { Err(format!( - "expected note to contain line `{line}` but found:\n{note}" + "expected note to contain line `{expected_line}` but found:\n{note}" )) } }) @@ -256,14 +252,14 @@ fn then_note_does_not_contain( world: &DiagnosticNoteWorld, fragment: QuotedString, ) -> Result<(), String> { - let fragment = fragment.into_inner(); + let unexpected_fragment = fragment.into_inner(); with_rendered_note(world, |rendered_note| { let note = rendered_note .as_ref() .ok_or_else(|| String::from("expected a rendered note but found none"))?; - if note.contains(&fragment) { + if note.contains(&unexpected_fragment) { Err(format!( - "expected note not to contain `{fragment}` but found:\n{note}" + "expected note not to contain `{unexpected_fragment}` but found:\n{note}" )) } else { Ok(()) @@ -277,38 +273,28 @@ fn then_note_does_not_contain( path = "tests/features/decomposition_diagnostic_notes.feature", index = 0 )] -fn scenario_type_note_renders_three_areas(world: DiagnosticNoteWorld) { - let _ = world; -} +fn scenario_type_note_renders_three_areas(world: DiagnosticNoteWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_diagnostic_notes.feature", index = 1 )] -fn scenario_trait_note_renders_sub_traits(world: DiagnosticNoteWorld) { - let _ = world; -} +fn scenario_trait_note_renders_sub_traits(world: DiagnosticNoteWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_diagnostic_notes.feature", index = 2 )] -fn scenario_no_suggestions_yield_no_note(world: DiagnosticNoteWorld) { - let _ = world; -} +fn scenario_no_suggestions_yield_no_note(world: DiagnosticNoteWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_diagnostic_notes.feature", index = 3 )] -fn scenario_large_subjects_cap_rendered_areas(world: DiagnosticNoteWorld) { - let _ = world; -} +fn scenario_large_subjects_cap_rendered_areas(world: DiagnosticNoteWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_diagnostic_notes.feature", index = 4 )] -fn scenario_large_communities_cap_method_names(world: DiagnosticNoteWorld) { - let _ = world; -} +fn scenario_large_communities_cap_method_names(world: DiagnosticNoteWorld) { let _ = world; } diff --git a/common/tests/decomposition_label_propagation_behaviour.rs b/common/tests/decomposition_label_propagation_behaviour.rs index 6a1ee8a3..f6974451 100644 --- a/common/tests/decomposition_label_propagation_behaviour.rs +++ b/common/tests/decomposition_label_propagation_behaviour.rs @@ -1,20 +1,22 @@ //! Behaviour-driven coverage for decomposition label propagation. +use std::{cell::RefCell, str::FromStr}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::str::FromStr; use whitaker_common::test_support::decomposition::{ - AdjacencyError, EdgeInput, LabelPropagationReport, label_propagation_report, + AdjacencyError, + EdgeInput, + LabelPropagationReport, + label_propagation_report, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Clone, Debug)] struct CsvList(Vec); impl CsvList { - fn into_vec(self) -> Vec { - self.0 - } + fn into_vec(self) -> Vec { self.0 } } impl FromStr for CsvList { @@ -36,9 +38,7 @@ impl FromStr for CsvList { struct CsvLabels(Vec); impl CsvLabels { - fn as_slice(&self) -> &[usize] { - &self.0 - } + fn as_slice(&self) -> &[usize] { &self.0 } } impl FromStr for CsvLabels { @@ -67,10 +67,9 @@ struct LabelPropagationWorld { result: RefCell>>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> LabelPropagationWorld { - LabelPropagationWorld::default() -} +fn world() -> LabelPropagationWorld { LabelPropagationWorld::default() } #[given("methods named {method_names} are tracked")] fn given_methods(world: &LabelPropagationWorld, method_names: CsvList) { @@ -199,9 +198,7 @@ fn scenario_disconnected_pairs_settle_to_shared_labels(world: LabelPropagationWo path = "tests/features/decomposition_label_propagation.feature", index = 1 )] -fn scenario_isolated_nodes_keep_their_own_labels(world: LabelPropagationWorld) { - let _ = world; -} +fn scenario_isolated_nodes_keep_their_own_labels(world: LabelPropagationWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_label_propagation.feature", @@ -215,14 +212,10 @@ fn scenario_zero_iteration_bound_keeps_initial_labels(world: LabelPropagationWor path = "tests/features/decomposition_label_propagation.feature", index = 3 )] -fn scenario_equal_scores_break_ties_lexically(world: LabelPropagationWorld) { - let _ = world; -} +fn scenario_equal_scores_break_ties_lexically(world: LabelPropagationWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_label_propagation.feature", index = 4 )] -fn scenario_invalid_edge_input_is_rejected(world: LabelPropagationWorld) { - let _ = world; -} +fn scenario_invalid_edge_input_is_rejected(world: LabelPropagationWorld) { let _ = world; } diff --git a/common/tests/decomposition_vector_algebra_behaviour.rs b/common/tests/decomposition_vector_algebra_behaviour.rs index fafbc1c9..03a01900 100644 --- a/common/tests/decomposition_vector_algebra_behaviour.rs +++ b/common/tests/decomposition_vector_algebra_behaviour.rs @@ -1,14 +1,14 @@ //! Behaviour-driven coverage for decomposition vector algebra helpers. +use std::{cell::RefCell, collections::BTreeMap, str::FromStr}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::collections::BTreeMap; -use std::str::FromStr; -use whitaker_common::MethodProfileBuilder; -use whitaker_common::test_support::decomposition::{ - MethodVectorAlgebraReport, method_vector_algebra, +use whitaker_common::{ + MethodProfileBuilder, + test_support::decomposition::{MethodVectorAlgebraReport, method_vector_algebra}, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Clone, Copy, Debug)] enum MethodSide { @@ -17,7 +17,7 @@ enum MethodSide { } impl MethodSide { - fn key(self) -> &'static str { + const fn key(self) -> &'static str { match self { Self::Left => "left", Self::Right => "right", @@ -41,9 +41,7 @@ impl FromStr for MethodSide { struct CsvList(Vec); impl CsvList { - fn into_vec(self) -> Vec { - self.0 - } + fn into_vec(self) -> Vec { self.0 } } impl FromStr for CsvList { @@ -66,10 +64,9 @@ struct VectorAlgebraWorld { report: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> VectorAlgebraWorld { - VectorAlgebraWorld::default() -} +fn world() -> VectorAlgebraWorld { VectorAlgebraWorld::default() } fn ensure_method_builder(world: &VectorAlgebraWorld, side: MethodSide, method_name: &str) { world.methods.borrow_mut().insert( @@ -222,17 +219,13 @@ fn then_dot_product_is_zero(world: &VectorAlgebraWorld) -> Result<(), String> { path = "tests/features/decomposition_vector_algebra.feature", index = 0 )] -fn scenario_shared_field_preserves_commutativity(world: VectorAlgebraWorld) { - let _ = world; -} +fn scenario_shared_field_preserves_commutativity(world: VectorAlgebraWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_vector_algebra.feature", index = 1 )] -fn scenario_empty_method_has_non_negative_norm(world: VectorAlgebraWorld) { - let _ = world; -} +fn scenario_empty_method_has_non_negative_norm(world: VectorAlgebraWorld) { let _ = world; } #[scenario( path = "tests/features/decomposition_vector_algebra.feature", diff --git a/common/tests/features/brain_trait_metrics.feature b/common/tests/features/brain_trait_metrics.feature index c2f45a58..7ce72bdf 100644 --- a/common/tests/features/brain_trait_metrics.feature +++ b/common/tests/features/brain_trait_metrics.feature @@ -52,7 +52,7 @@ Feature: Brain trait metric collection Given a trait named Transformer And a required method parse And a required method validate - And a default method normalise with CC 7 + And a default method normalize with CC 7 And an associated const VERSION When trait metrics are built Then total trait items is 4 diff --git a/common/tests/features/cognitive_complexity.feature b/common/tests/features/cognitive_complexity.feature index 9fcd51de..488c1508 100644 --- a/common/tests/features/cognitive_complexity.feature +++ b/common/tests/features/cognitive_complexity.feature @@ -5,14 +5,14 @@ Feature: Cognitive complexity with macro-expansion filtering Scenario: Empty function has zero complexity Given a new complexity builder - When the complexity is finalised + When the complexity is finalized Then the complexity score is 0 Scenario: Single if adds one structural increment Given a new complexity builder And a structural increment not from expansion And a nesting increment not from expansion - When the complexity is finalised + When the complexity is finalized Then the complexity score is 1 Scenario: Nested if adds nesting-depth penalty @@ -23,13 +23,13 @@ Feature: Cognitive complexity with macro-expansion filtering And a structural increment not from expansion And a nesting increment not from expansion And nesting is popped - When the complexity is finalised + When the complexity is finalized Then the complexity score is 3 Scenario: Macro-expanded structural increment is excluded Given a new complexity builder And a structural increment from expansion - When the complexity is finalised + When the complexity is finalized Then the complexity score is 0 Scenario: Macro-expanded nesting does not inflate depth @@ -38,7 +38,7 @@ Feature: Cognitive complexity with macro-expansion filtering And a structural increment not from expansion And a nesting increment not from expansion And nesting is popped - When the complexity is finalised + When the complexity is finalized Then the complexity score is 1 Scenario: Boolean operators add fundamental increments @@ -46,7 +46,7 @@ Feature: Cognitive complexity with macro-expansion filtering And a structural increment not from expansion And a fundamental increment not from expansion And a fundamental increment not from expansion - When the complexity is finalised + When the complexity is finalized Then the complexity score is 3 Scenario: Mixed real and expansion increments @@ -57,11 +57,11 @@ Feature: Cognitive complexity with macro-expansion filtering And a structural increment not from expansion And a nesting increment not from expansion And nesting is popped - When the complexity is finalised + When the complexity is finalized Then the complexity score is 3 Scenario: Fundamental increment from expansion is excluded Given a new complexity builder And a fundamental increment from expansion - When the complexity is finalised + When the complexity is finalized Then the complexity score is 0 diff --git a/common/tests/features/context_detection.feature b/common/tests/features/context_detection.feature index 137ff860..5431d9a6 100644 --- a/common/tests/features/context_detection.feature +++ b/common/tests/features/context_detection.feature @@ -1,26 +1,26 @@ Feature: Context detection - Scenario: Recognise rstest decorated functions + Scenario: Recognize rstest decorated functions Given a function annotated with rstest When I check whether the function is test-like - Then the function is recognised as test-like + Then the function is recognized as test-like And its context is marked as test-like - Scenario: Recognise tokio::test decorated functions + Scenario: Recognize tokio::test decorated functions Given a function annotated with tokio::test When I check whether the function is test-like - Then the function is recognised as test-like + Then the function is recognized as test-like And its context is marked as test-like Scenario: Ignore plain functions Given a function without test attributes When I check whether the function is test-like - Then the function is recognised as not test-like + Then the function is recognized as not test-like And its context is not marked as test-like - Scenario: Recognise configured custom test attribute - Given the lint recognises custom::test as a test attribute + Scenario: Recognize configured custom test attribute + Given the lint recognizes custom::test as a test attribute And a function annotated with the custom test attribute custom::test When I check whether the function is test-like - Then the function is recognised as test-like + Then the function is recognized as test-like And its context is marked as test-like diff --git a/common/tests/features/decomposition_advice.feature b/common/tests/features/decomposition_advice.feature index 34907b48..6a14a4fe 100644 --- a/common/tests/features/decomposition_advice.feature +++ b/common/tests/features/decomposition_advice.feature @@ -2,7 +2,7 @@ Feature: Decomposition advice analysis Community detection groups related methods into reusable decomposition suggestions for brain type and brain trait diagnostics. - Scenario: Type methods split into parsing, serialisation, and filesystem groups + Scenario: Type methods split into parsing, serialization, and filesystem groups Given decomposition analysis for a type named Foo And a method named parse_tokens And method parse_tokens accesses fields grammar,tokens diff --git a/common/tests/features/rstest_detection.feature b/common/tests/features/rstest_detection.feature index 2fde144d..b690acb6 100644 --- a/common/tests/features/rstest_detection.feature +++ b/common/tests/features/rstest_detection.feature @@ -3,12 +3,12 @@ Feature: Strict rstest detection Scenario: Detect an rstest test from a direct attribute Given a function annotated with rstest When I check whether the function is an rstest test - Then the function is recognised as an rstest test + Then the function is recognized as an rstest test Scenario: Detect an rstest fixture from a direct attribute Given a function annotated with rstest::fixture When I check whether the function is an rstest fixture - Then the function is recognised as an rstest fixture + Then the function is recognized as an rstest fixture Scenario: Classify a plain identifier parameter as fixture-local Given a parameter named db @@ -30,18 +30,18 @@ Feature: Strict rstest detection Scenario: Ignore expansion traces while fallback is disabled Given the expansion trace contains rstest When I check whether the function is an rstest test - Then the function is recognised as not being an rstest test + Then the function is recognized as not being an rstest test Scenario: Use expansion traces when fallback is enabled Given the expansion trace contains rstest And expansion fallback is enabled When I check whether the function is an rstest test - Then the function is recognised as an rstest test + Then the function is recognized as an rstest test Scenario: Detect rstest test with multiple attributes Given a function annotated with rstest and allow When I check whether the function is an rstest test - Then the function is recognised as an rstest test + Then the function is recognized as an rstest test Scenario: Classify custom provider parameters Given a parameter annotated with a custom provider attribute @@ -53,4 +53,4 @@ Feature: Strict rstest detection Given the expansion trace contains outer_macro and rstest And expansion fallback is enabled When I check whether the function is an rstest test - Then the function is recognised as an rstest test + Then the function is recognized as an rstest test diff --git a/common/tests/i18n_behaviour.rs b/common/tests/i18n_behaviour.rs index 3ea2c62f..1e4bdf60 100644 --- a/common/tests/i18n_behaviour.rs +++ b/common/tests/i18n_behaviour.rs @@ -4,12 +4,12 @@ //! missing message handling to ensure lint crates can rely on predictable //! diagnostics across locales. +use std::{borrow::Cow, cell::RefCell, collections::HashMap}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::borrow::Cow; -use std::cell::RefCell; -use std::collections::HashMap; use whitaker_common::i18n::{Arguments, FluentValue, I18nError, Localizer, branch_phrase}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[path = "support/i18n_helpers.rs"] mod i18n_helpers; @@ -23,9 +23,7 @@ struct I18nFixture { } impl I18nFixture { - fn set_locale(&self, locale: Option) { - *self.locale.borrow_mut() = locale; - } + fn set_locale(&self, locale: Option) { *self.locale.borrow_mut() = locale; } fn ensure_localizer(&self) -> Localizer { let locale = self.locale.borrow().clone(); @@ -39,12 +37,11 @@ impl I18nFixture { *self.outcome.borrow_mut() = Some(result); } - fn result(&self) -> Result { - self.outcome - .borrow() - .as_ref() - .cloned() - .unwrap_or_else(|| panic!("lookup should have been performed")) + /// Returns the stored lookup outcome, or `None` when no lookup has + /// been performed yet. Callers assert on the absence themselves so + /// this accessor stays panic-free. + fn result(&self) -> Option> { + self.outcome.borrow().as_ref().cloned() } } @@ -52,30 +49,25 @@ fn lint_count_from_key(key: &str) -> Option<(String, u32)> { let suffix = " with lint count "; let (base, count) = key.rsplit_once(suffix)?; let value = count.trim().parse().ok()?; - Some((base.to_string(), value)) + Some((base.to_owned(), value)) } fn branch_count_from_key(key: &str) -> Option<(String, u32)> { let suffix = " with branches "; let (base, count) = key.rsplit_once(suffix)?; let value = count.trim().parse().ok()?; - Some((base.to_string(), value)) + Some((base.to_owned(), value)) } +#[allow_fixture_expansion_lints] #[fixture] -fn fixture() -> I18nFixture { - I18nFixture::default() -} +fn fixture() -> I18nFixture { I18nFixture::default() } #[given("no locale preference")] -fn given_no_locale(fixture: &I18nFixture) { - fixture.set_locale(None); -} +fn given_no_locale(fixture: &I18nFixture) { fixture.set_locale(None); } #[given("the locale preference {locale}")] -fn given_locale(fixture: &I18nFixture, locale: String) { - fixture.set_locale(Some(locale)); -} +fn given_locale(fixture: &I18nFixture, locale: String) { fixture.set_locale(Some(locale)); } #[when("I request the message for {key}")] fn when_message(fixture: &I18nFixture, key: String) { @@ -93,7 +85,7 @@ fn when_attribute(fixture: &I18nFixture, attribute: String, key: String) { let mut args = default_arguments(); args.insert( Cow::Borrowed("branches"), - FluentValue::from(branches as i64), + FluentValue::from(i64::from(branches)), ); let phrase = branch_phrase(localizer.locale(), branches as usize); args.insert( @@ -107,7 +99,10 @@ fn when_attribute(fixture: &I18nFixture, attribute: String, key: String) { if let Some((base_key, lint_count)) = lint_count_from_key(&key) { let mut args: Arguments<'static> = HashMap::new(); - args.insert(Cow::Borrowed("lint"), FluentValue::from(lint_count as i64)); + args.insert( + Cow::Borrowed("lint"), + FluentValue::from(i64::from(lint_count)), + ); let result = localizer.attribute_with_args(&base_key, &attribute, &args); fixture.store_message(result); return; @@ -121,115 +116,101 @@ fn when_attribute(fixture: &I18nFixture, attribute: String, key: String) { #[then("the resolved locale is {expected}")] fn then_locale(fixture: &I18nFixture, expected: String) { let localizer = fixture.ensure_localizer(); - assert_eq!(localizer.locale(), expected); + assert_eq!( + localizer.locale(), + expected, + "expected resolved locale `{expected}`" + ); } #[then("the loader reports fallback usage")] fn then_fallback_used(fixture: &I18nFixture) { let localizer = fixture.ensure_localizer(); - assert!(localizer.used_fallback()); + assert!( + localizer.used_fallback(), + "expected the loader to report fallback usage" + ); } #[then("the message contains {snippet}")] -fn then_contains(fixture: &I18nFixture, snippet: String) { +fn then_contains(fixture: &I18nFixture, snippet: String) -> Result<(), String> { let message = fixture .result() - .unwrap_or_else(|error| panic!("message should resolve: {error}")); - let message = strip_isolation_marks(&message); - let snippet = strip_isolation_marks(&snippet); - assert!( - message.contains(snippet.as_ref()), - "expected `{message}` to contain `{snippet}`", - ); + .ok_or_else(|| String::from("lookup should have been performed"))? + .map_err(|error| format!("message should resolve: {error}"))?; + let cleaned_message = strip_isolation_marks(&message); + let cleaned_snippet = strip_isolation_marks(&snippet); + if cleaned_message.contains(cleaned_snippet.as_ref()) { + Ok(()) + } else { + Err(format!( + "expected `{cleaned_message}` to contain `{cleaned_snippet}`" + )) + } } #[then("localization fails with a missing message error")] fn then_missing(fixture: &I18nFixture) { match fixture.result() { - Err(I18nError::MissingMessage { .. }) => {} - other => panic!("unexpected result: {other:?}", other = other), + Some(Err(I18nError::MissingMessage { .. })) => {} + other => panic!("unexpected result: {other:?}"), } } #[scenario(path = "tests/features/i18n_loader.feature", index = 0)] -fn scenario_falls_back(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_falls_back(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 1)] -fn scenario_secondary_locale(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_secondary_locale(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 2)] -fn scenario_gaelic_plural(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_gaelic_plural(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 3)] -fn scenario_welsh_lint_count_zero(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_zero(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 4)] -fn scenario_welsh_lint_count_large(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_large(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 5)] -fn scenario_welsh_lint_count_one(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_one(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 6)] -fn scenario_welsh_lint_count_two(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_two(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 7)] -fn scenario_welsh_lint_count_three(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_three(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 8)] -fn scenario_welsh_lint_count_six(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_six(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 9)] -fn scenario_welsh_lint_count_eleven(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_lint_count_eleven(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 10)] -fn scenario_attribute_falls_back(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_attribute_falls_back(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 11)] -fn scenario_missing_message(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_missing_message(fixture: I18nFixture) { let _ = fixture; } #[scenario(path = "tests/features/i18n_loader.feature", index = 12)] -fn scenario_welsh_conditional_note_lenition(fixture: I18nFixture) { - let _ = fixture; -} +fn scenario_welsh_conditional_note_lenition(fixture: I18nFixture) { let _ = fixture; } #[cfg(test)] mod tests { //! Validates lint count key parsing so attribute helpers feed deterministic //! arguments into i18n scenarios. - use super::lint_count_from_key; use rstest::rstest; + use super::lint_count_from_key; + #[rstest] - #[case("foo with lint count 42", Some(("foo".to_string(), 42)))] + #[case("foo with lint count 42", Some(("foo".to_owned(), 42)))] #[case("foo with lint 42", None)] #[case("foo with lint count ", None)] #[case("foo with lint count abc", None)] #[case("", None)] - #[case(" with lint count 10", Some(("".to_string(), 10)))] + #[case(" with lint count 10", Some((String::new(), 10)))] fn lint_count_from_key_parsing(#[case] input: &str, #[case] expected: Option<(String, u32)>) { assert_eq!(lint_count_from_key(input), expected); } diff --git a/common/tests/i18n_packaging.rs b/common/tests/i18n_packaging.rs index 3456d534..7c9ee7eb 100644 --- a/common/tests/i18n_packaging.rs +++ b/common/tests/i18n_packaging.rs @@ -6,43 +6,56 @@ #[cfg(unix)] mod unix { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - use whitaker_common::i18n::packaged_fallback_locale_path; + //! Unix-only packaging checks that shell out to `cargo package` and `tar`. + //! + //! These helpers stage a temporary target directory, build the package + //! tarball, and list its contents so the test can assert that the + //! fallback Fluent bundle ships with the crate. + + use std::{ + error::Error, + fs, + io, + path::{Path, PathBuf}, + process::Command, + }; use tempfile::{Builder, TempDir}; + use whitaker_common::i18n::packaged_fallback_locale_path; + + type TestResult = Result>; #[test] - fn fluent_bundles_are_included_in_the_package_tarball() { - let target_dir = package_target_dir(); - let crate_path = package_crate_path(target_dir.path()); - let tar_listing = package_tar_listing(&crate_path); + fn fluent_bundles_are_included_in_the_package_tarball() -> TestResult { + let target_dir = package_target_dir()?; + let crate_path = package_crate_path(target_dir.path())?; + let tar_listing = package_tar_listing(&crate_path)?; let expected_entry = packaged_fallback_locale_path() .to_string_lossy() .replace('\\', "/"); - assert!( - tar_listing.lines().any(|line| line == expected_entry), - "expected packaged tarball to include the fallback Fluent bundle, but it did not" - ); + if tar_listing.lines().any(|line| line == expected_entry) { + Ok(()) + } else { + Err(format!( + "expected packaged tarball to include the fallback Fluent bundle \ + `{expected_entry}`, but it did not" + ) + .into()) + } } - fn package_target_dir() -> TempDir { + fn package_target_dir() -> io::Result { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let target_root = manifest_dir.join("target"); - fs::create_dir_all(&target_root) - .unwrap_or_else(|error| panic!("target directory should be creatable: {error}")); + fs::create_dir_all(&target_root)?; Builder::new() .prefix("whitaker-common-package-") .tempdir_in(&target_root) - .unwrap_or_else(|error| { - panic!("temporary package directory should be creatable: {error}") - }) } - fn package_crate_path(target_dir: &Path) -> PathBuf { + fn package_crate_path(target_dir: &Path) -> TestResult { let status = Command::new("cargo") .current_dir(env!("CARGO_MANIFEST_DIR")) .env("CARGO_TARGET_DIR", target_dir) @@ -53,10 +66,11 @@ mod unix { "--allow-dirty", "--no-verify", ]) - .status() - .unwrap_or_else(|error| panic!("cargo package should run: {error}")); + .status()?; - assert!(status.success(), "cargo package should succeed"); + if !status.success() { + return Err(format!("cargo package should succeed, but exited with {status}").into()); + } let expected_name = format!( "{}-{}.crate", @@ -64,36 +78,30 @@ mod unix { env!("CARGO_PKG_VERSION") ); let package_dir = target_dir.join("package"); - fs::read_dir(&package_dir) - .unwrap_or_else(|error| panic!("package directory should be readable: {error}")) - .map(|entry| { - entry - .unwrap_or_else(|error| { - panic!("package directory entry should be readable: {error}") - }) - .path() - }) - .find(|path| { - path.file_name() - .is_some_and(|name| name == expected_name.as_str()) - }) - .unwrap_or_else(|| panic!("cargo package should produce {expected_name}")) + for entry_result in fs::read_dir(&package_dir)? { + let path = entry_result?.path(); + if path + .file_name() + .is_some_and(|name| name == expected_name.as_str()) + { + return Ok(path); + } + } + + Err(format!("cargo package should produce {expected_name}").into()) } - fn package_tar_listing(crate_path: &Path) -> String { - let output = Command::new("tar") - .arg("-tf") - .arg(crate_path) - .output() - .unwrap_or_else(|error| panic!("tar should list package contents: {error}")); + fn package_tar_listing(crate_path: &Path) -> TestResult { + let output = Command::new("tar").arg("-tf").arg(crate_path).output()?; - assert!( - output.status.success(), - "tar should succeed when listing the packaged crate: {}", - String::from_utf8_lossy(&output.stderr) - ); + if !output.status.success() { + return Err(format!( + "tar should succeed when listing the packaged crate: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } - String::from_utf8(output.stdout) - .unwrap_or_else(|error| panic!("tar listing should be valid UTF-8: {error}")) + String::from_utf8(output.stdout).map_err(Into::into) } } diff --git a/common/tests/i18n_quality/ftl_smoke_behaviour.rs b/common/tests/i18n_quality/ftl_smoke_behaviour.rs index 67554b63..3cf1c7ac 100644 --- a/common/tests/i18n_quality/ftl_smoke_behaviour.rs +++ b/common/tests/i18n_quality/ftl_smoke_behaviour.rs @@ -114,7 +114,7 @@ impl ParsingWorld { .borrow() .as_ref() .cloned() - .expect("Fluent source should be initialised"); + .expect("Fluent source should be initialized"); let result = match FluentResource::try_new(source) { Ok(resource) => bundle_duplicate_result(resource), Err((resource, errors)) => { diff --git a/common/tests/i18n_quality/suite.rs b/common/tests/i18n_quality/suite.rs index d15604af..096ff50b 100644 --- a/common/tests/i18n_quality/suite.rs +++ b/common/tests/i18n_quality/suite.rs @@ -133,7 +133,7 @@ fn validate_entry_placeables( validate_attribute_placeables(&context, message_id, en_entry, locale_entry); } -fn validate_pluralisation_coverage(locale: &str, max_branches: i64) { +fn validate_pluralization_coverage(locale: &str, max_branches: i64) { let localizer = Localizer::new(Some(locale)); let mut args = HashMap::new(); @@ -196,7 +196,7 @@ fn ftl_bundles_parse_successfully() { } #[test] -fn localised_help_attributes_are_complete() { +fn localized_help_attributes_are_complete() { for (locale, en_path, locale_path) in file_pairs() { let locale_code = LocaleCode::from(locale.as_str()); let en_entries = parse_ftl(&en_path); @@ -231,8 +231,8 @@ fn localised_help_attributes_are_complete() { #[case("en-GB", 12)] #[case("cy", 12)] #[case("gd", 25)] -fn pluralisation_covers_sample_range(#[case] locale: &str, #[case] max_branches: i64) { - validate_pluralisation_coverage(locale, max_branches); +fn pluralization_covers_sample_range(#[case] locale: &str, #[case] max_branches: i64) { + validate_pluralization_coverage(locale, max_branches); } #[rstest] diff --git a/common/tests/lcom4_behaviour.rs b/common/tests/lcom4_behaviour.rs index 367d7b80..60997819 100644 --- a/common/tests/lcom4_behaviour.rs +++ b/common/tests/lcom4_behaviour.rs @@ -1,10 +1,14 @@ //! Behaviour-driven coverage for LCOM4 cohesion analysis. +use std::{ + cell::{Cell, RefCell}, + collections::BTreeSet, +}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; -use std::collections::BTreeSet; use whitaker_common::lcom4::{MethodInfo, cohesion_components}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct LcomWorld { @@ -13,24 +17,19 @@ struct LcomWorld { } impl LcomWorld { - fn push_method(&self, method: MethodInfo) { - self.methods.borrow_mut().push(method); - } + fn push_method(&self, method: MethodInfo) { self.methods.borrow_mut().push(method); } fn compute(&self) { let methods = self.methods.borrow(); self.result.set(Some(cohesion_components(&methods))); } - fn result(&self) -> Option { - self.result.get() - } + const fn result(&self) -> Option { self.result.get() } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> LcomWorld { - LcomWorld::default() -} +fn world() -> LcomWorld { LcomWorld::default() } /// Parses a comma-separated list of field names into a `BTreeSet`. /// @@ -68,9 +67,7 @@ fn given_method_no_fields_calling(world: &LcomWorld, name: String, callee: Strin } #[when("I compute LCOM4")] -fn when_compute(world: &LcomWorld) { - world.compute(); -} +fn when_compute(world: &LcomWorld) { world.compute(); } #[then("the component count is {count}")] fn then_component_count(world: &LcomWorld, count: usize) { @@ -82,52 +79,40 @@ fn then_component_count(world: &LcomWorld, count: usize) { // scenarios in the feature file requires updating the indices here. #[scenario(path = "tests/features/lcom4.feature", index = 0)] -fn scenario_single_method(world: LcomWorld) { - let _ = world; -} +fn scenario_single_method(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 1)] -fn scenario_shared_field(world: LcomWorld) { - let _ = world; -} +fn scenario_shared_field(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 2)] -fn scenario_direct_call(world: LcomWorld) { - let _ = world; -} +fn scenario_direct_call(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 3)] -fn scenario_disjoint_methods(world: LcomWorld) { - let _ = world; -} +fn scenario_disjoint_methods(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 4)] -fn scenario_transitive_sharing(world: LcomWorld) { - let _ = world; -} +fn scenario_transitive_sharing(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 5)] -fn scenario_empty_type(world: LcomWorld) { - let _ = world; -} +fn scenario_empty_type(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 6)] -fn scenario_isolated_methods(world: LcomWorld) { - let _ = world; -} +fn scenario_isolated_methods(world: LcomWorld) { let _ = world; } #[scenario(path = "tests/features/lcom4.feature", index = 7)] -fn scenario_self_call(world: LcomWorld) { - let _ = world; -} +fn scenario_self_call(world: LcomWorld) { let _ = world; } // --- Unit tests for parse_field_set --- #[cfg(test)] mod parse_field_set_tests { - use super::parse_field_set; + //! Unit tests for `parse_field_set`, the comma-separated field parser used + //! by the LCOM4 behaviour steps. + use std::collections::BTreeSet; + use super::parse_field_set; + #[test] fn basic_comma_separated() { let result = parse_field_set("a, b"); diff --git a/common/tests/localizer_helpers.rs b/common/tests/localizer_helpers.rs index 5fc32240..c7d45827 100644 --- a/common/tests/localizer_helpers.rs +++ b/common/tests/localizer_helpers.rs @@ -3,18 +3,31 @@ //! Scenarios validate locale resolution and fallback handling so lints can //! depend on deterministic localization outcomes. +use std::{ + borrow::Cow, + cell::RefCell, + sync::{Mutex, MutexGuard, PoisonError}, +}; + use logtest::Logger; use rstest::{fixture, rstest}; use rstest_bdd_macros::{given, scenario, then, when}; -use std::borrow::Cow; -use std::cell::RefCell; -use std::sync::{Mutex, MutexGuard}; -use whitaker_common::i18n::testing::RecordingEmitter; -use whitaker_common::i18n::{ - Arguments, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, MessageResolution, - get_localizer_for_lint, noop_reporter, safe_resolve_message_set, +use whitaker_common::{ + i18n::{ + Arguments, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + get_localizer_for_lint, + noop_reporter, + safe_resolve_message_set, + testing::RecordingEmitter, + }, + test_support::with_locale, }; -use whitaker_common::test_support::LocaleOverride; +use whitaker_test_macros::allow_fixture_expansion_lints; static ENVIRONMENT_LOCK: Mutex<()> = Mutex::new(()); @@ -33,15 +46,17 @@ struct HelperWorld { fallback: RefCell>, result: RefCell>, emitter: RecordingEmitter, - environment_override: RefCell>, + environment_locale: RefCell>, _guard: MutexGuard<'static, ()>, } impl HelperWorld { fn new() -> Self { + // The guard protects only process-wide environment mutation; a + // poisoned lock carries no invalid state, so recovering is safe. let guard = ENVIRONMENT_LOCK .lock() - .unwrap_or_else(|error| panic!("environment lock poisoned: {error}")); + .unwrap_or_else(PoisonError::into_inner); Self { configuration: RefCell::new(None), @@ -51,19 +66,13 @@ impl HelperWorld { fallback: RefCell::new(None), result: RefCell::new(None), emitter: RecordingEmitter::default(), - environment_override: RefCell::new(None), + environment_locale: RefCell::new(None), _guard: guard, } } fn set_environment(&self, value: Option) { - let mut guard = self.environment_override.borrow_mut(); - guard.take(); - let override_guard = match value { - Some(locale) => LocaleOverride::set(locale.as_str()), - None => LocaleOverride::clear(), - }; - *guard = Some(override_guard); + *self.environment_locale.borrow_mut() = value; } fn set_configuration(&self, locale: Option) { @@ -72,26 +81,34 @@ impl HelperWorld { fn request_localizer(&self, lint: &str) { let config = self.configuration.borrow(); - let localizer = get_localizer_for_lint(lint, config.as_deref()); + let environment = self.environment_locale.borrow(); + let localizer = with_locale(environment.as_deref(), || { + get_localizer_for_lint(lint, config.as_deref()) + }); self.localizer.borrow_mut().replace(localizer); } - fn ensure_localizer(&self) -> Localizer { + fn ensure_localizer(&self) -> Result { self.localizer .borrow() .as_ref() .cloned() - .unwrap_or_else(|| panic!("localizer should be initialised")) + .ok_or_else(|| String::from("localizer should be initialized")) } - fn assert_locale(&self, expected: &str) { - let localizer = self.ensure_localizer(); - assert_eq!(localizer.locale(), expected); + fn assert_locale(&self, expected: &str) -> Result<(), String> { + let localizer = self.ensure_localizer()?; + let actual = localizer.locale(); + if actual == expected { + Ok(()) + } else { + Err(format!( + "expected resolved locale `{expected}`, but found `{actual}`" + )) + } } - fn set_message_key(&self, key: String) { - self.message_key.borrow_mut().replace(key); - } + fn set_message_key(&self, key: String) { self.message_key.borrow_mut().replace(key); } fn set_fallback_messages(&self) { let fallback = DiagnosticMessageSet::new( @@ -115,31 +132,27 @@ impl HelperWorld { .clone() } - fn ensure_arguments(&self) -> Arguments<'static> { - self.arguments.borrow().clone() - } + fn ensure_arguments(&self) -> Arguments<'static> { self.arguments.borrow().clone() } - fn clear_arguments(&self) { - *self.arguments.borrow_mut() = Arguments::default(); - } + fn clear_arguments(&self) { *self.arguments.borrow_mut() = Arguments::default(); } fn prepare_doc_arguments(&self) { let mut args: Arguments<'static> = Arguments::default(); args.insert(Cow::Borrowed("subject"), FluentValue::from("functions")); args.insert( Cow::Borrowed("attribute"), - FluentValue::from("#[inline]".to_string()), + FluentValue::from("#[inline]".to_owned()), ); *self.arguments.borrow_mut() = args; } - fn resolve_messages(&self) { - let localizer = self.ensure_localizer(); + fn resolve_messages(&self) -> Result<(), String> { + let localizer = self.ensure_localizer()?; let key = self .message_key .borrow() .clone() - .unwrap_or_else(|| panic!("a message key should be configured")); + .ok_or_else(|| String::from("a message key should be configured"))?; let args = self.arguments.borrow().clone(); let fallback = self.ensure_fallback(); @@ -158,44 +171,38 @@ impl HelperWorld { ); self.result.borrow_mut().replace(messages); + Ok(()) } - fn resolved_messages(&self) -> DiagnosticMessageSet { + fn resolved_messages(&self) -> Result { self.result .borrow() .as_ref() .cloned() - .unwrap_or_else(|| panic!("diagnostic messages should be resolved")) + .ok_or_else(|| String::from("diagnostic messages should be resolved")) } - fn recorded_messages(&self) -> Vec { - self.emitter.recorded_messages() - } + fn recorded_messages(&self) -> Vec { self.emitter.recorded_messages() } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> HelperWorld { - HelperWorld::new() -} +fn world() -> HelperWorld { HelperWorld::new() } #[given("DYLINT_LOCALE is not set")] -fn given_env_cleared(world: &HelperWorld) { - world.set_environment(None); -} +fn given_env_cleared(world: &HelperWorld) { world.set_environment(None); } #[given("DYLINT_LOCALE is {locale}")] fn given_env(world: &HelperWorld, locale: String) { - world.set_environment(Some(unquote(&locale).to_string())); + world.set_environment(Some(unquote(&locale).to_owned())); } #[given("no configuration locale is provided")] -fn given_no_config(world: &HelperWorld) { - world.set_configuration(None); -} +fn given_no_config(world: &HelperWorld) { world.set_configuration(None); } #[given("the configuration locale is {locale}")] fn given_config(world: &HelperWorld, locale: String) { - world.set_configuration(Some(unquote(&locale).to_string())); + world.set_configuration(Some(unquote(&locale).to_owned())); } #[when("I request the localizer for {lint}")] @@ -205,60 +212,72 @@ fn when_request_localizer(world: &HelperWorld, lint: String) { } #[then("the resolved locale is {locale}")] -fn then_locale(world: &HelperWorld, locale: String) { - world.assert_locale(unquote(&locale)); +fn then_locale(world: &HelperWorld, locale: String) -> Result<(), String> { + world.assert_locale(unquote(&locale)) } #[given("fallback messages are defined")] -fn given_fallback(world: &HelperWorld) { - world.set_fallback_messages(); -} +fn given_fallback(world: &HelperWorld) { world.set_fallback_messages(); } #[given("a missing message key {key} is requested")] fn given_missing_key(world: &HelperWorld, key: String) { - world.set_message_key(unquote(&key).to_string()); + world.set_message_key(unquote(&key).to_owned()); } #[given("a message key {key} is requested")] fn given_message_key(world: &HelperWorld, key: String) { - world.set_message_key(unquote(&key).to_string()); + world.set_message_key(unquote(&key).to_owned()); } #[given("I prepare arguments for the doc attribute diagnostic")] -fn given_doc_arguments(world: &HelperWorld) { - world.prepare_doc_arguments(); -} +fn given_doc_arguments(world: &HelperWorld) { world.prepare_doc_arguments(); } #[given("I do not prepare arguments for the doc attribute diagnostic")] -fn given_no_doc_arguments(world: &HelperWorld) { - world.clear_arguments(); -} +fn given_no_doc_arguments(world: &HelperWorld) { world.clear_arguments(); } #[when("I resolve the diagnostic message set")] -fn when_resolve_messages(world: &HelperWorld) { - world.resolve_messages(); -} +fn when_resolve_messages(world: &HelperWorld) -> Result<(), String> { world.resolve_messages() } #[then("the fallback primary message contains {snippet}")] -fn then_fallback_primary(world: &HelperWorld, snippet: String) { - let messages = world.resolved_messages(); - let snippet = unquote(&snippet); - assert!(messages.primary().contains(snippet)); +fn then_fallback_primary(world: &HelperWorld, snippet: String) -> Result<(), String> { + let messages = world.resolved_messages()?; + let expected_snippet = unquote(&snippet); + if messages.primary().contains(expected_snippet) { + Ok(()) + } else { + Err(format!( + "expected fallback primary message to contain `{expected_snippet}`" + )) + } } #[then("a delayed bug is recorded mentioning {snippet}")] fn then_bug_recorded(world: &HelperWorld, snippet: String) { let messages = world.recorded_messages(); - let snippet = unquote(&snippet); - assert!(!messages.is_empty()); - assert!(messages.iter().any(|message| message.contains(snippet))); + let expected_snippet = unquote(&snippet); + assert!( + !messages.is_empty(), + "expected at least one delayed bug to be recorded" + ); + assert!( + messages + .iter() + .any(|message| message.contains(expected_snippet)), + "expected a recorded bug mentioning `{expected_snippet}`" + ); } #[then("the resolved primary message contains {snippet}")] -fn then_primary_message(world: &HelperWorld, snippet: String) { - let messages = world.resolved_messages(); - let snippet = unquote(&snippet); - assert!(messages.primary().contains(snippet)); +fn then_primary_message(world: &HelperWorld, snippet: String) -> Result<(), String> { + let messages = world.resolved_messages()?; + let expected_snippet = unquote(&snippet); + if messages.primary().contains(expected_snippet) { + Ok(()) + } else { + Err(format!( + "expected resolved primary message to contain `{expected_snippet}`" + )) + } } #[then("no delayed bug is recorded")] @@ -267,29 +286,19 @@ fn then_no_bug(world: &HelperWorld) { } #[scenario("tests/features/localizer_helpers.feature", index = 0)] -fn scenario_fallback_to_default(world: HelperWorld) { - let _ = world; -} +fn scenario_fallback_to_default(world: HelperWorld) { let _ = world; } #[scenario("tests/features/localizer_helpers.feature", index = 1)] -fn scenario_environment_locale(world: HelperWorld) { - let _ = world; -} +fn scenario_environment_locale(world: HelperWorld) { let _ = world; } #[scenario("tests/features/localizer_helpers.feature", index = 2)] -fn scenario_localization_fallback(world: HelperWorld) { - let _ = world; -} +fn scenario_localization_fallback(world: HelperWorld) { let _ = world; } #[scenario("tests/features/localizer_helpers.feature", index = 3)] -fn scenario_localization_success(world: HelperWorld) { - let _ = world; -} +fn scenario_localization_success(world: HelperWorld) { let _ = world; } #[scenario("tests/features/localizer_helpers.feature", index = 4)] -fn scenario_interpolation_failure(world: HelperWorld) { - let _ = world; -} +fn scenario_interpolation_failure(world: HelperWorld) { let _ = world; } #[test] fn invalid_locale_warns_and_falls_back() { @@ -298,13 +307,13 @@ fn invalid_locale_warns_and_falls_back() { world.set_environment(Some(String::from("xx-XX"))); world.set_configuration(None); world.request_localizer("function_attrs_follow_docs"); - world.assert_locale("en-GB"); + world.assert_locale("en-GB").expect("locale should resolve"); let mut warned = false; while let Some(record) = logger.pop() { if record .args() - .to_string() + .to_owned() .contains("unsupported DYLINT_LOCALE `xx-XX`") { warned = true; @@ -324,8 +333,8 @@ fn repeated_failures_record_all_bugs() { world.set_fallback_messages(); world.set_message_key(String::from("missing-key")); - world.resolve_messages(); - world.resolve_messages(); + world.resolve_messages().expect("messages should resolve"); + world.resolve_messages().expect("messages should resolve"); let recorded = world.recorded_messages(); assert_eq!(recorded.len(), 2); @@ -343,7 +352,9 @@ fn missing_key_with_noop_reporter_uses_fallback(world: HelperWorld) { world.request_localizer("no_expect_outside_tests"); world.set_fallback_messages(); - let localizer = world.ensure_localizer(); + let localizer = world + .ensure_localizer() + .expect("localizer should initialize"); let args = world.ensure_arguments(); let fallback = world.ensure_fallback(); let resolution = MessageResolution { diff --git a/common/tests/method_extraction_behaviour.rs b/common/tests/method_extraction_behaviour.rs index a84e86d9..c7717f11 100644 --- a/common/tests/method_extraction_behaviour.rs +++ b/common/tests/method_extraction_behaviour.rs @@ -1,9 +1,11 @@ //! Behaviour-driven coverage for method metadata extraction. +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; use whitaker_common::lcom4::{MethodInfo, MethodInfoBuilder}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct ExtractionWorld { @@ -47,19 +49,14 @@ impl ExtractionWorld { self.with_result(|info| info.called_methods().contains(method)) } - fn fields_empty(&self) -> bool { - self.with_result(|info| info.accessed_fields().is_empty()) - } + fn fields_empty(&self) -> bool { self.with_result(|info| info.accessed_fields().is_empty()) } - fn calls_empty(&self) -> bool { - self.with_result(|info| info.called_methods().is_empty()) - } + fn calls_empty(&self) -> bool { self.with_result(|info| info.called_methods().is_empty()) } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> ExtractionWorld { - ExtractionWorld::default() -} +fn world() -> ExtractionWorld { ExtractionWorld::default() } // --- Given steps --- @@ -91,9 +88,7 @@ fn given_call_expanded(world: &ExtractionWorld, method: String) { // --- When steps --- #[when("the method info is built")] -fn when_build(world: &ExtractionWorld) { - world.build(); -} +fn when_build(world: &ExtractionWorld) { world.build(); } // --- Then steps --- @@ -145,36 +140,22 @@ fn then_calls_empty(world: &ExtractionWorld) { // here. #[scenario(path = "tests/features/method_extraction.feature", index = 0)] -fn scenario_field_access_recorded(world: ExtractionWorld) { - let _ = world; -} +fn scenario_field_access_recorded(world: ExtractionWorld) { let _ = world; } #[scenario(path = "tests/features/method_extraction.feature", index = 1)] -fn scenario_method_call_recorded(world: ExtractionWorld) { - let _ = world; -} +fn scenario_method_call_recorded(world: ExtractionWorld) { let _ = world; } #[scenario(path = "tests/features/method_extraction.feature", index = 2)] -fn scenario_macro_field_filtered(world: ExtractionWorld) { - let _ = world; -} +fn scenario_macro_field_filtered(world: ExtractionWorld) { let _ = world; } #[scenario(path = "tests/features/method_extraction.feature", index = 3)] -fn scenario_macro_call_filtered(world: ExtractionWorld) { - let _ = world; -} +fn scenario_macro_call_filtered(world: ExtractionWorld) { let _ = world; } #[scenario(path = "tests/features/method_extraction.feature", index = 4)] -fn scenario_all_expansion_empty(world: ExtractionWorld) { - let _ = world; -} +fn scenario_all_expansion_empty(world: ExtractionWorld) { let _ = world; } #[scenario(path = "tests/features/method_extraction.feature", index = 5)] -fn scenario_empty_builder(world: ExtractionWorld) { - let _ = world; -} +fn scenario_empty_builder(world: ExtractionWorld) { let _ = world; } #[scenario(path = "tests/features/method_extraction.feature", index = 6)] -fn scenario_multiple_accumulate(world: ExtractionWorld) { - let _ = world; -} +fn scenario_multiple_accumulate(world: ExtractionWorld) { let _ = world; } diff --git a/common/tests/rstest_detection_behaviour.rs b/common/tests/rstest_detection_behaviour.rs index 0ec2d955..ac83dcad 100644 --- a/common/tests/rstest_detection_behaviour.rs +++ b/common/tests/rstest_detection_behaviour.rs @@ -1,14 +1,24 @@ //! Behaviour-driven tests for strict `rstest` detection helpers. +use std::{cell::RefCell, collections::BTreeSet}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::collections::BTreeSet; -use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -use whitaker_common::rstest::{ - ExpansionTrace, ParameterBinding, RstestDetectionOptions, RstestParameter, RstestParameterKind, - classify_rstest_parameter, fixture_local_names, is_rstest_fixture_with, is_rstest_test_with, +use whitaker_common::{ + attributes::{Attribute, AttributeKind, AttributePath}, + rstest::{ + ExpansionTrace, + ParameterBinding, + RstestDetectionOptions, + RstestParameter, + RstestParameterKind, + classify_rstest_parameter, + fixture_local_names, + is_rstest_fixture_with, + is_rstest_test_with, + }, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Clone, Debug, Default)] struct DetectionWorld { @@ -100,20 +110,15 @@ impl DetectionWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> DetectionWorld { - DetectionWorld::default() -} +fn world() -> DetectionWorld { DetectionWorld::default() } #[given("a function annotated with rstest")] -fn given_rstest_function(world: &DetectionWorld) { - world.push_attribute("rstest"); -} +fn given_rstest_function(world: &DetectionWorld) { world.push_attribute("rstest"); } #[given("a function annotated with rstest::fixture")] -fn given_rstest_fixture(world: &DetectionWorld) { - world.push_attribute("rstest::fixture"); -} +fn given_rstest_fixture(world: &DetectionWorld) { world.push_attribute("rstest::fixture"); } #[given("a parameter named db")] fn given_fixture_local_parameter(world: &DetectionWorld) { @@ -123,7 +128,7 @@ fn given_fixture_local_parameter(world: &DetectionWorld) { #[given("a parameter named case_input annotated with case")] fn given_provider_parameter(world: &DetectionWorld) { world.set_parameter(RstestParameter::new( - ParameterBinding::Ident("case_input".to_string()), + ParameterBinding::Ident("case_input".to_owned()), vec![Attribute::new( AttributePath::from("case"), AttributeKind::Outer, @@ -137,14 +142,10 @@ fn given_unsupported_parameter(world: &DetectionWorld) { } #[given("the expansion trace contains rstest")] -fn given_trace(world: &DetectionWorld) { - world.set_trace("rstest"); -} +fn given_trace(world: &DetectionWorld) { world.set_trace("rstest"); } #[given("expansion fallback is enabled")] -fn given_fallback_enabled(world: &DetectionWorld) { - world.enable_trace_fallback(); -} +fn given_fallback_enabled(world: &DetectionWorld) { world.enable_trace_fallback(); } #[given("a function annotated with rstest and allow")] fn given_rstest_and_allow(world: &DetectionWorld) { @@ -155,7 +156,7 @@ fn given_rstest_and_allow(world: &DetectionWorld) { #[given("a parameter annotated with a custom provider attribute")] fn given_custom_provider_parameter(world: &DetectionWorld) { world.set_parameter(RstestParameter::new( - ParameterBinding::Ident("custom_value".to_string()), + ParameterBinding::Ident("custom_value".to_owned()), vec![Attribute::new( AttributePath::from("custom::provider"), AttributeKind::Outer, @@ -174,14 +175,10 @@ fn given_multi_frame_trace(world: &DetectionWorld) { } #[when("I check whether the function is an rstest test")] -fn when_check_test(world: &DetectionWorld) { - world.evaluate_test(); -} +fn when_check_test(world: &DetectionWorld) { world.evaluate_test(); } #[when("I check whether the function is an rstest fixture")] -fn when_check_fixture(world: &DetectionWorld) { - world.evaluate_fixture(); -} +fn when_check_fixture(world: &DetectionWorld) { world.evaluate_fixture(); } #[when("I classify the parameter")] fn when_classify_parameter(world: &DetectionWorld) -> Result<(), String> { @@ -189,21 +186,19 @@ fn when_classify_parameter(world: &DetectionWorld) -> Result<(), String> { } #[when("I evaluate fixture-local names")] -fn when_fixture_names_evaluated(world: &DetectionWorld) { - world.evaluate_fixture_names(); -} +fn when_fixture_names_evaluated(world: &DetectionWorld) { world.evaluate_fixture_names(); } -#[then("the function is recognised as an rstest test")] +#[then("the function is recognized as an rstest test")] fn then_test_positive(world: &DetectionWorld) { assert_eq!(*world.test_result.borrow(), Some(true)); } -#[then("the function is recognised as not being an rstest test")] +#[then("the function is recognized as not being an rstest test")] fn then_test_negative(world: &DetectionWorld) { assert_eq!(*world.test_result.borrow(), Some(false)); } -#[then("the function is recognised as an rstest fixture")] +#[then("the function is recognized as an rstest fixture")] fn then_fixture_positive(world: &DetectionWorld) { assert_eq!(*world.fixture_result.borrow(), Some(true)); } @@ -213,7 +208,7 @@ fn then_fixture_local(world: &DetectionWorld) { assert_eq!( *world.parameter_kind.borrow(), Some(RstestParameterKind::FixtureLocal { - name: "db".to_string() + name: "db".to_owned() }) ); } @@ -238,7 +233,7 @@ fn then_unsupported(world: &DetectionWorld) { fn then_fixture_names(world: &DetectionWorld) { assert_eq!( *world.fixture_names.borrow(), - Some(BTreeSet::from(["db".to_string()])) + Some(BTreeSet::from(["db".to_owned()])) ); } diff --git a/common/tests/rstest_fingerprint_behaviour.rs b/common/tests/rstest_fingerprint_behaviour.rs index 058fa87b..82e5a087 100644 --- a/common/tests/rstest_fingerprint_behaviour.rs +++ b/common/tests/rstest_fingerprint_behaviour.rs @@ -1,12 +1,20 @@ //! Behaviour-driven tests for shared `rstest` fingerprint models. +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; use whitaker_common::rstest::{ - ArgAtom, ArgFingerprint, CalleeShape, ExprShape, LocalSlot, ParagraphFingerprint, - ParagraphNormalizer, StmtShape, + ArgAtom, + ArgFingerprint, + CalleeShape, + ExprShape, + LocalSlot, + ParagraphFingerprint, + ParagraphNormalizer, + StmtShape, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Default)] struct FingerprintWorld { @@ -48,20 +56,15 @@ impl FingerprintWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> FingerprintWorld { - FingerprintWorld::default() -} +fn world() -> FingerprintWorld { FingerprintWorld::default() } #[given("helper-call arguments for fixture db and literal 42")] -fn given_helper_args(world: &FingerprintWorld) { - world.set_first_args(helper_args()); -} +fn given_helper_args(world: &FingerprintWorld) { world.set_first_args(helper_args()); } #[given("matching helper-call arguments for fixture db and literal 42")] -fn given_matching_helper_args(world: &FingerprintWorld) { - world.set_second_args(helper_args()); -} +fn given_matching_helper_args(world: &FingerprintWorld) { world.set_second_args(helper_args()); } #[given("helper-call arguments containing an unsupported argument")] fn given_unsupported_args(world: &FingerprintWorld) { @@ -92,24 +95,16 @@ fn given_two_arg_paragraph(world: &FingerprintWorld) { } #[when("I compare the argument fingerprints")] -fn when_compare_args(world: &FingerprintWorld) { - world.compare_args(); -} +fn when_compare_args(world: &FingerprintWorld) { world.compare_args(); } #[when("I compare the paragraph fingerprints")] -fn when_compare_paragraphs(world: &FingerprintWorld) { - world.compare_paragraphs(); -} +fn when_compare_paragraphs(world: &FingerprintWorld) { world.compare_paragraphs(); } #[when("I inspect the argument fingerprint")] -fn when_inspect_args(world: &FingerprintWorld) { - let _ = world; -} +fn when_inspect_args(world: &FingerprintWorld) { let _ = world; } #[when("I inspect the paragraph fingerprint")] -fn when_inspect_paragraph(world: &FingerprintWorld) { - let _ = world; -} +fn when_inspect_paragraph(world: &FingerprintWorld) { let _ = world; } #[then("the argument fingerprints match")] fn then_args_match(world: &FingerprintWorld) { @@ -172,26 +167,16 @@ fn setup_paragraph(first: &str, second: &str, constructor_argc: usize) -> Paragr } #[scenario(path = "tests/features/rstest_fingerprint.feature", index = 0)] -fn scenario_equivalent_arguments_match(world: FingerprintWorld) { - let _ = world; -} +fn scenario_equivalent_arguments_match(world: FingerprintWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_fingerprint.feature", index = 1)] -fn scenario_renamed_paragraphs_match(world: FingerprintWorld) { - let _ = world; -} +fn scenario_renamed_paragraphs_match(world: FingerprintWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_fingerprint.feature", index = 2)] -fn scenario_unsupported_arguments_remain_explicit(world: FingerprintWorld) { - let _ = world; -} +fn scenario_unsupported_arguments_remain_explicit(world: FingerprintWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_fingerprint.feature", index = 3)] -fn scenario_structural_paragraphs_diverge(world: FingerprintWorld) { - let _ = world; -} +fn scenario_structural_paragraphs_diverge(world: FingerprintWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_fingerprint.feature", index = 4)] -fn scenario_first_appearance_order_controls_slots(world: FingerprintWorld) { - let _ = world; -} +fn scenario_first_appearance_order_controls_slots(world: FingerprintWorld) { let _ = world; } diff --git a/common/tests/rstest_span_recovery_behaviour.rs b/common/tests/rstest_span_recovery_behaviour.rs index 4733ef5e..1e7900d9 100644 --- a/common/tests/rstest_span_recovery_behaviour.rs +++ b/common/tests/rstest_span_recovery_behaviour.rs @@ -1,10 +1,14 @@ //! Behaviour-driven tests for shared `rstest` span recovery helpers. +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use whitaker_common::rstest::{SpanRecoveryFrame, UserEditableSpan, recover_user_editable_span}; -use whitaker_common::span::{SourceLocation, SourceSpan}; +use whitaker_common::{ + rstest::{SpanRecoveryFrame, UserEditableSpan, recover_user_editable_span}, + span::{SourceLocation, SourceSpan}, +}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Default)] struct SpanRecoveryWorld { @@ -27,30 +31,21 @@ impl SpanRecoveryWorld { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> SpanRecoveryWorld { - SpanRecoveryWorld::default() -} +fn world() -> SpanRecoveryWorld { SpanRecoveryWorld::default() } #[given("a direct user-editable span at line {line}")] -fn given_direct_span(world: &SpanRecoveryWorld, line: usize) { - world.push_frame(line, false); -} +fn given_direct_span(world: &SpanRecoveryWorld, line: usize) { world.push_frame(line, false); } #[given("a macro frame at line {line}")] -fn given_macro_frame(world: &SpanRecoveryWorld, line: usize) { - world.push_frame(line, true); -} +fn given_macro_frame(world: &SpanRecoveryWorld, line: usize) { world.push_frame(line, true); } #[given("a user-editable frame at line {line}")] -fn given_user_frame(world: &SpanRecoveryWorld, line: usize) { - world.push_frame(line, false); -} +fn given_user_frame(world: &SpanRecoveryWorld, line: usize) { world.push_frame(line, false); } #[when("I recover the user-editable span")] -fn when_recover(world: &SpanRecoveryWorld) { - world.evaluate(); -} +fn when_recover(world: &SpanRecoveryWorld) { world.evaluate(); } fn source_span(line: usize) -> SourceSpan { match SourceSpan::new(SourceLocation::new(line, 1), SourceLocation::new(line, 8)) { @@ -85,21 +80,13 @@ fn then_macro_only(world: &SpanRecoveryWorld) { } #[scenario(path = "tests/features/rstest_span_recovery.feature", index = 0)] -fn scenario_direct_span_is_kept(world: SpanRecoveryWorld) { - let _ = world; -} +fn scenario_direct_span_is_kept(world: SpanRecoveryWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_span_recovery.feature", index = 1)] -fn scenario_nested_macro_chain_recovers(world: SpanRecoveryWorld) { - let _ = world; -} +fn scenario_nested_macro_chain_recovers(world: SpanRecoveryWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_span_recovery.feature", index = 2)] -fn scenario_macro_only_is_skipped(world: SpanRecoveryWorld) { - let _ = world; -} +fn scenario_macro_only_is_skipped(world: SpanRecoveryWorld) { let _ = world; } #[scenario(path = "tests/features/rstest_span_recovery.feature", index = 3)] -fn scenario_first_user_frame_wins(world: SpanRecoveryWorld) { - let _ = world; -} +fn scenario_first_user_frame_wins(world: SpanRecoveryWorld) { let _ = world; } diff --git a/common/tests/support/i18n_helpers.rs b/common/tests/support/i18n_helpers.rs index e307c729..bdbf58ed 100644 --- a/common/tests/support/i18n_helpers.rs +++ b/common/tests/support/i18n_helpers.rs @@ -3,6 +3,7 @@ //! that keep suites aligned and readable. use std::borrow::Cow; + use whitaker_common::i18n::{Arguments, FluentValue}; const UNICODE_ISOLATION_MARKS: [char; 2] = ['\u{2068}', '\u{2069}']; @@ -14,7 +15,7 @@ const UNICODE_ISOLATION_MARKS: [char; 2] = ['\u{2068}', '\u{2069}']; /// let cleaned = strip_isolation_marks("\u{2068}42\u{2069}"); /// assert_eq!(cleaned, "42"); /// ``` -pub fn strip_isolation_marks<'a>(text: &'a str) -> Cow<'a, str> { +pub fn strip_isolation_marks(text: &str) -> Cow<'_, str> { if text .chars() .any(|character| UNICODE_ISOLATION_MARKS.contains(&character)) @@ -67,7 +68,7 @@ pub fn default_arguments() -> Arguments<'static> { /// assert!(should_skip_line(" attribute = value")); /// assert!(!should_skip_line("identifier = value")); /// ``` -pub fn should_skip_line(line: &str) -> bool { +pub const fn should_skip_line(line: &str) -> bool { matches!(line.as_bytes().first(), Some(b' ' | b'\t')) } @@ -96,11 +97,13 @@ pub fn extract_identifier(line: &str) -> Option { if id.is_empty() { return None; } - Some(id.to_string()) + Some(id.to_owned()) } #[cfg(test)] mod tests { + //! Tests for the i18n behaviour-test helpers that parse Fluent fixtures. + use super::*; #[test] @@ -115,7 +118,7 @@ mod tests { fn extract_identifier_handles_basic_messages() { assert_eq!( extract_identifier("message-id = Value"), - Some("message-id".to_string()) + Some("message-id".to_owned()) ); } @@ -130,7 +133,7 @@ mod tests { fn extract_identifier_handles_multiple_equals() { assert_eq!( extract_identifier("message = part = extra"), - Some("message".to_string()) + Some("message".to_owned()) ); } diff --git a/crates/bumpy_road_function/Cargo.toml b/crates/bumpy_road_function/Cargo.toml index 1b1e1e3d..11ae9e98 100644 --- a/crates/bumpy_road_function/Cargo.toml +++ b/crates/bumpy_road_function/Cargo.toml @@ -42,6 +42,7 @@ serde = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } @@ -51,3 +52,6 @@ whitaker = { workspace = true } whitaker-common = { workspace = true } camino = { workspace = true } tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/bumpy_road_function/src/analysis.rs b/crates/bumpy_road_function/src/analysis.rs index 72aa96d0..953e09af 100644 --- a/crates/bumpy_road_function/src/analysis.rs +++ b/crates/bumpy_road_function/src/analysis.rs @@ -69,7 +69,7 @@ impl Default for Settings { /// # Examples /// /// ``` -/// use bumpy_road_function::analysis::{normalise_settings, Settings, Weights}; +/// use bumpy_road_function::analysis::{Settings, Weights, normalize_settings}; /// /// let settings = Settings { /// threshold: -1.0, @@ -82,14 +82,14 @@ impl Default for Settings { /// ..Settings::default() /// }; /// -/// let normalised = normalise_settings(settings); -/// assert_eq!(normalised.threshold, Settings::default().threshold); -/// assert_eq!(normalised.window, Settings::default().window); -/// assert_eq!(normalised.weights, Settings::default().weights); +/// let normalized = normalize_settings(settings); +/// assert_eq!(normalized.threshold, Settings::default().threshold); +/// assert_eq!(normalized.window, Settings::default().window); +/// assert_eq!(normalized.weights, Settings::default().weights); /// ``` #[must_use] -pub fn normalise_settings(settings: Settings) -> Settings { - fn normalise_weight(candidate: f64, fallback: f64) -> f64 { +pub fn normalize_settings(settings: Settings) -> Settings { + fn normalize_weight(candidate: f64, fallback: f64) -> f64 { if candidate.is_finite() && candidate >= 0.0 { candidate } else { @@ -97,9 +97,7 @@ pub fn normalise_settings(settings: Settings) -> Settings { } } - fn is_valid_window(window: usize) -> bool { - window != 0 && (window & 1) == 1 - } + const fn is_valid_window(window: usize) -> bool { window != 0 && (window & 1) == 1 } let defaults = Settings::default(); let threshold = if settings.threshold.is_finite() && settings.threshold >= 0.0 { @@ -116,9 +114,9 @@ pub fn normalise_settings(settings: Settings) -> Settings { let min_bump_lines = settings.min_bump_lines.max(1); let weights = Weights { - depth: normalise_weight(settings.weights.depth, defaults.weights.depth), - predicate: normalise_weight(settings.weights.predicate, defaults.weights.predicate), - flow: normalise_weight(settings.weights.flow, defaults.weights.flow), + depth: normalize_weight(settings.weights.depth, defaults.weights.depth), + predicate: normalize_weight(settings.weights.predicate, defaults.weights.predicate), + flow: normalize_weight(settings.weights.flow, defaults.weights.flow), }; Settings { @@ -141,21 +139,15 @@ pub struct BumpInterval { impl BumpInterval { /// First index covered by the bump (inclusive). #[must_use] - pub const fn start_index(self) -> usize { - self.start_index - } + pub const fn start_index(self) -> usize { self.start_index } /// Last index covered by the bump (inclusive). #[must_use] - pub const fn end_index(self) -> usize { - self.end_index - } + pub const fn end_index(self) -> usize { self.end_index } /// Number of samples spanned by the bump. #[must_use] - pub const fn len(self) -> usize { - self.end_index - self.start_index + 1 - } + pub const fn len(self) -> usize { self.end_index - self.start_index + 1 } /// Returns `true` when the interval contains no samples. /// @@ -163,15 +155,11 @@ impl BumpInterval { /// retained defensively so callers can validate values originating from /// other sources. #[must_use] - pub const fn is_empty(self) -> bool { - self.start_index > self.end_index - } + pub const fn is_empty(self) -> bool { self.start_index > self.end_index } /// Area above the threshold used for ranking bumps. #[must_use] - pub const fn area_above_threshold(self) -> f64 { - self.area_above_threshold - } + pub const fn area_above_threshold(self) -> f64 { self.area_above_threshold } } /// Mutable state maintained during bump detection. @@ -194,9 +182,7 @@ impl BumpDetectionContext { } } - fn into_intervals(self) -> Vec { - self.intervals - } + fn into_intervals(self) -> Vec { self.intervals } } /// Detects bump intervals in the smoothed signal. @@ -238,6 +224,11 @@ pub fn detect_bumps(smoothed: &[f64], threshold: f64, min_bump_lines: usize) -> context.into_intervals() } +#[expect( + clippy::float_arithmetic, + reason = "bump areas accumulate smoothed complexity samples, which are inherently \ + floating-point" +)] fn process_sample_value(value: f64, index: usize, context: &mut BumpDetectionContext) { if value >= context.threshold { if context.current_start.is_none() { @@ -259,7 +250,7 @@ fn process_sample_value(value: f64, index: usize, context: &mut BumpDetectionCon context.area = 0.0; } -fn finalize_bump( +const fn finalize_bump( start: usize, end: usize, area: f64, diff --git a/crates/bumpy_road_function/src/driver/config.rs b/crates/bumpy_road_function/src/driver/config.rs index 4381ab7a..27644931 100644 --- a/crates/bumpy_road_function/src/driver/config.rs +++ b/crates/bumpy_road_function/src/driver/config.rs @@ -1,13 +1,13 @@ //! Configuration parsing and loading for the bumpy road lint. //! //! The lint reads optional configuration from `dylint.toml`, applies defaults, -//! and relies on `analysis::normalise_settings` to clamp invalid values. +//! and relies on `analysis::normalize_settings` to clamp invalid values. -use crate::analysis::{Settings, Weights}; use log::debug; use serde::Deserialize; use super::LINT_NAME; +use crate::analysis::{Settings, Weights}; #[derive(Clone, Copy, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -52,7 +52,7 @@ impl Default for Config { } impl Config { - pub(super) fn into_settings(self) -> Settings { + pub(super) const fn into_settings(self) -> Settings { Settings { threshold: self.threshold, window: self.window, diff --git a/crates/bumpy_road_function/src/driver/diagnostic.rs b/crates/bumpy_road_function/src/driver/diagnostic.rs index 467e4326..64a277b8 100644 --- a/crates/bumpy_road_function/src/driver/diagnostic.rs +++ b/crates/bumpy_road_function/src/driver/diagnostic.rs @@ -3,19 +3,22 @@ //! The lint warns when it detects two or more separated bump intervals in the //! smoothed signal and highlights the two most severe bumps. -use std::borrow::Cow; -use std::ops::RangeInclusive; +use std::{borrow::Cow, ops::RangeInclusive}; -use crate::analysis::{BumpInterval, Settings, top_two_bumps}; use fluent_templates::fluent_bundle::FluentValue; use rustc_lint::{LateContext, LintContext}; use rustc_span::{BytePos, Span}; -use whitaker_common::i18n::DiagnosticMessageSet; use whitaker_common::{ - Arguments, Localizer, MessageResolution, noop_reporter, safe_resolve_message_set, + Arguments, + Localizer, + MessageResolution, + i18n::DiagnosticMessageSet, + noop_reporter, + safe_resolve_message_set, }; use super::{BUMPY_ROAD_FUNCTION, LINT_NAME, MESSAGE_KEY}; +use crate::analysis::{BumpInterval, Settings, top_two_bumps}; /// Payload describing a lint diagnostic to emit. pub(super) struct DiagnosticInput<'a> { @@ -36,7 +39,7 @@ pub(super) fn emit_diagnostic( args.insert(Cow::Borrowed("name"), FluentValue::from(input.name)); args.insert( Cow::Borrowed("count"), - FluentValue::from(input.bumps.len() as i64), + FluentValue::from(i64::try_from(input.bumps.len()).unwrap_or(i64::MAX)), ); args.insert( Cow::Borrowed("threshold"), @@ -59,19 +62,22 @@ pub(super) fn emit_diagnostic( BUMPY_ROAD_FUNCTION, input.primary_span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); - lint.span_note(input.primary_span, messages.note().to_string()); + lint.primary_message(messages.primary().to_owned()); + lint.span_note(input.primary_span, messages.note().to_owned()); for (ordinal, interval) in highlighted.iter().enumerate() { let Some(span) = bump_spans.get(ordinal).copied().flatten() else { continue; }; - let label = - resolve_bump_label(localizer, (ordinal + 1) as i64, interval.len() as i64); + let label = resolve_bump_label( + localizer, + i64::try_from(ordinal + 1).unwrap_or(i64::MAX), + i64::try_from(interval.len()).unwrap_or(i64::MAX), + ); lint.span_label(span, label); } - lint.help(messages.help().to_string()); + lint.help(messages.help().to_owned()); }), ); } @@ -125,7 +131,12 @@ struct LineSpanMapper { } impl LineSpanMapper { - fn new(base_span: Span, snippet_len: usize, base_line: usize, line_starts: Vec) -> Self { + const fn new( + base_span: Span, + snippet_len: usize, + base_line: usize, + line_starts: Vec, + ) -> Self { Self { base_span, snippet_len, @@ -150,10 +161,10 @@ impl LineSpanMapper { .unwrap_or(self.snippet_len); let base = self.base_span.shrink_to_lo(); - // `BytePos` is `u32`-backed; the snippet length is expected to fit in - // 4 GiB for any reasonable Rust source file. - let lo = base.lo() + BytePos(start_offset as u32); - let mut hi = base.lo() + BytePos(end_offset as u32); + // `BytePos` is `u32`-backed; offsets beyond 4 GiB cannot be represented + // as spans, so such (pathological) snippets yield no span. + let lo = base.lo() + BytePos(u32::try_from(start_offset).ok()?); + let mut hi = base.lo() + BytePos(u32::try_from(end_offset).ok()?); if hi <= lo { hi = lo + BytePos(1); } diff --git a/crates/bumpy_road_function/src/driver.rs b/crates/bumpy_road_function/src/driver/mod.rs similarity index 68% rename from crates/bumpy_road_function/src/driver.rs rename to crates/bumpy_road_function/src/driver/mod.rs index 41ab1c79..af679b4c 100644 --- a/crates/bumpy_road_function/src/driver.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -5,17 +5,19 @@ //! more separated bumps above a configurable threshold. The warning highlights //! the two largest bump intervals with labelled spans. -use crate::analysis::{Settings, detect_bumps, normalise_settings}; use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; -use rustc_span::Ident; -use rustc_span::Span; -use rustc_span::symbol::Symbol; +use rustc_span::{Ident, Span, symbol::Symbol}; use whitaker::SharedConfig; -use whitaker_common::complexity_signal::{rasterize_signal, smooth_moving_average}; -use whitaker_common::i18n::MessageKey; -use whitaker_common::{Localizer, get_localizer_for_lint}; +use whitaker_common::{ + Localizer, + complexity_signal::{rasterize_signal, smooth_moving_average}, + get_localizer_for_lint, + i18n::MessageKey, +}; + +use crate::analysis::{Settings, detect_bumps, normalize_settings}; const LINT_NAME: &str = "bumpy_road_function"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); @@ -24,17 +26,38 @@ mod config; mod diagnostic; mod segment_builder; -use self::config::load_configuration; -use self::diagnostic::{DiagnosticInput, emit_diagnostic}; -use self::segment_builder::{SegmentBuilder, span_line_range}; - -dylint_linting::impl_late_lint! { - pub BUMPY_ROAD_FUNCTION, - Warn, - "functions should avoid multiple separated clusters of complex conditional logic", - BumpyRoadFunction::default() +use self::{ + config::load_configuration, + diagnostic::{DiagnosticInput, emit_diagnostic}, + segment_builder::{SegmentBuilder, span_line_range}, +}; + +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::BumpyRoadFunction; + + dylint_linting::impl_late_lint! { + /// Lint flagging functions with multiple separated clusters of complex + /// conditional logic ("bumpy road" complexity profiles). + pub BUMPY_ROAD_FUNCTION, + Warn, + "functions should avoid multiple separated clusters of complex conditional logic", + BumpyRoadFunction::default() + } } +pub use declaration::BUMPY_ROAD_FUNCTION; + /// Lint pass that caches configuration and localization for a crate. pub struct BumpyRoadFunction { settings: Settings, @@ -52,7 +75,7 @@ impl Default for BumpyRoadFunction { impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { - self.settings = normalise_settings(load_configuration().into_settings()); + self.settings = normalize_settings(load_configuration().into_settings()); let shared_config = SharedConfig::load(); self.localizer = get_localizer_for_lint(LINT_NAME, shared_config.locale()); } @@ -62,7 +85,7 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, item.span, target); + self.analyse_if_not_expanded(cx, item.span, &target); } fn check_impl_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::ImplItem<'tcx>) { @@ -70,7 +93,7 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, item.span, target); + self.analyse_if_not_expanded(cx, item.span, &target); } fn check_trait_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::TraitItem<'tcx>) { @@ -78,7 +101,7 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, item.span, target); + self.analyse_if_not_expanded(cx, item.span, &target); } fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { @@ -90,12 +113,12 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, expr.span, target); + self.analyse_if_not_expanded(cx, expr.span, &target); } } impl BumpyRoadFunction { - fn analyse_if_not_expanded(&self, cx: &LateContext<'_>, span: Span, target: AnalysisTarget) { + fn analyse_if_not_expanded(&self, cx: &LateContext<'_>, span: Span, target: &AnalysisTarget) { if span.from_expansion() { return; } @@ -104,7 +127,7 @@ impl BumpyRoadFunction { } } -fn extract_item_target(item: &hir::Item<'_>) -> Option { +const fn extract_item_target(item: &hir::Item<'_>) -> Option { let hir::ItemKind::Fn { ident, body, .. } = item.kind else { return None; }; @@ -112,7 +135,7 @@ fn extract_item_target(item: &hir::Item<'_>) -> Option { Some(make_ident_target(ident, body)) } -fn extract_impl_item_target(item: &hir::ImplItem<'_>) -> Option { +const fn extract_impl_item_target(item: &hir::ImplItem<'_>) -> Option { if let hir::ImplItemKind::Fn(_, body_id) = item.kind { return Some(make_analysis_target( item.ident.name, @@ -124,7 +147,7 @@ fn extract_impl_item_target(item: &hir::ImplItem<'_>) -> Option None } -fn extract_trait_item_target(item: &hir::TraitItem<'_>) -> Option { +const fn extract_trait_item_target(item: &hir::TraitItem<'_>) -> Option { let hir::TraitItemKind::Fn(_, trait_fn) = item.kind else { return None; }; @@ -152,7 +175,11 @@ fn extract_expr_target(expr: &hir::Expr<'_>) -> Option { )) } -fn make_analysis_target(name: Symbol, primary_span: Span, body_id: hir::BodyId) -> AnalysisTarget { +const fn make_analysis_target( + name: Symbol, + primary_span: Span, + body_id: hir::BodyId, +) -> AnalysisTarget { AnalysisTarget { name, primary_span, @@ -160,7 +187,7 @@ fn make_analysis_target(name: Symbol, primary_span: Span, body_id: hir::BodyId) } } -fn make_ident_target(ident: Ident, body_id: hir::BodyId) -> AnalysisTarget { +const fn make_ident_target(ident: Ident, body_id: hir::BodyId) -> AnalysisTarget { make_analysis_target(ident.name, ident.span, body_id) } @@ -172,7 +199,7 @@ struct AnalysisTarget { fn analyse_body( cx: &LateContext<'_>, - target: AnalysisTarget, + target: &AnalysisTarget, settings: &Settings, localizer: &Localizer, ) { @@ -196,14 +223,14 @@ fn analyse_body( Err(error) => { cx.tcx.sess.dcx().span_delayed_bug( body_span, - format!("bumpy-road signal rasterisation failed: {error}"), + format!("bumpy-road signal rasterization failed: {error}"), ); return; } }; let smoothed = match smooth_moving_average(&signal, settings.window) { - Ok(signal) => signal, + Ok(smoothed) => smoothed, Err(error) => { cx.tcx.sess.dcx().span_delayed_bug( body_span, diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index 27992e20..04686885 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -5,14 +5,14 @@ use std::ops::RangeInclusive; -use crate::analysis::Settings; use rustc_hir as hir; use rustc_hir::{BinOpKind, ExprKind, LoopSource, UnOp}; use rustc_lint::LateContext; -use rustc_span::source_map::SourceMap; -use rustc_span::{DesugaringKind, Span}; +use rustc_span::{DesugaringKind, Span, source_map::SourceMap}; use whitaker_common::complexity_signal::LineSegment; +use crate::analysis::Settings; + pub(super) struct SegmentBuilder<'a, 'tcx> { cx: &'a LateContext<'tcx>, settings: &'a Settings, @@ -21,7 +21,7 @@ pub(super) struct SegmentBuilder<'a, 'tcx> { } impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { - pub(super) fn new( + pub(super) const fn new( cx: &'a LateContext<'tcx>, settings: &'a Settings, function_lines: RangeInclusive, @@ -134,12 +134,16 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { self.push_segment(span, self.settings.weights.flow); } + #[expect( + clippy::float_arithmetic, + reason = "predicate weights are floating-point tuning parameters scaled by branch counts" + )] fn push_predicate_segment(&mut self, expr: &'tcx hir::Expr<'tcx>) { if matches!(expr.kind, ExprKind::Let(..)) { return; } - let branches = count_branches(expr) as f64; + let branches = f64::from(u32::try_from(count_branches(expr)).unwrap_or(u32::MAX)); let value = branches * self.settings.weights.predicate; self.push_segment(expr.span, value); } @@ -160,7 +164,9 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { self.cx.tcx.sess.dcx().span_delayed_bug( span, format!( - "bumpy-road segment lines lie outside function range (segment={segment_start}..={segment_end}, function={function_start}..={function_end})", + "bumpy-road segment lines lie outside function range \ + (segment={segment_start}..={segment_end}, \ + function={function_start}..={function_end})", segment_start = lines.start(), segment_end = lines.end(), function_start = self.function_lines.start(), @@ -185,14 +191,10 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { } } -impl<'a, 'tcx> rustc_hir::intravisit::Visitor<'tcx> for SegmentBuilder<'a, 'tcx> { - fn visit_expr(&mut self, expr: &'tcx hir::Expr<'tcx>) { - Self::visit_expr(self, expr); - } +impl<'tcx> rustc_hir::intravisit::Visitor<'tcx> for SegmentBuilder<'_, 'tcx> { + fn visit_expr(&mut self, expr: &'tcx hir::Expr<'tcx>) { Self::visit_expr(self, expr); } - fn visit_block(&mut self, block: &'tcx hir::Block<'tcx>) { - Self::visit_block(self, block); - } + fn visit_block(&mut self, block: &'tcx hir::Block<'tcx>) { Self::visit_block(self, block); } } fn extract_while_components<'hir>( @@ -211,12 +213,8 @@ fn count_branches(expr: &hir::Expr<'_>) -> usize { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { count_branches(lhs) + count_branches(rhs) } - ExprKind::Unary(UnOp::Not, inner) => count_branches(inner), - ExprKind::DropTemps(inner) => count_branches(inner), - ExprKind::Block(block, _) => match block.expr { - Some(inner) => count_branches(inner), - None => 1, - }, + ExprKind::Unary(UnOp::Not, inner) | ExprKind::DropTemps(inner) => count_branches(inner), + ExprKind::Block(block, _) => block.expr.map_or(1, count_branches), ExprKind::If(cond, ..) => count_branches(cond), _ => 1, } @@ -229,8 +227,9 @@ pub(super) fn span_line_range(source_map: &SourceMap, span: Span) -> Option = bumps + .iter() + .map(|bump| (bump.start_index(), bump.end_index())) + .collect(); + assert_eq!(ranges, vec![(1, 2), (4, 5)]); } #[rstest] @@ -90,9 +97,11 @@ fn top_two_bumps_prefers_area_then_length() { let bumps = detect_bumps(&smoothed, 3.0, 2); let top = top_two_bumps(bumps); - assert_eq!(top.len(), 2); - assert_eq!((top[0].start_index(), top[0].end_index()), (6, 8)); - assert_eq!((top[1].start_index(), top[1].end_index()), (1, 2)); + let ranges: Vec<(usize, usize)> = top + .iter() + .map(|bump| (bump.start_index(), bump.end_index())) + .collect(); + assert_eq!(ranges, vec![(6, 8), (1, 2)]); } #[derive(Default)] @@ -102,13 +111,12 @@ struct World { min_bump_lines: RefCell, bumps: RefCell>, settings: RefCell, - normalised: RefCell>, + normalized: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> World { - World::default() -} +fn world() -> World { World::default() } #[given("a smoothed signal with two bumps")] fn given_signal_two_bumps(world: &World) { @@ -118,19 +126,13 @@ fn given_signal_two_bumps(world: &World) { } #[given("a smoothed signal with one bump")] -fn given_signal_one_bump(world: &World) { - world.signal.replace(vec![0.0, 3.0, 3.0, 0.0]); -} +fn given_signal_one_bump(world: &World) { world.signal.replace(vec![0.0, 3.0, 3.0, 0.0]); } #[given("a smoothed signal with a short spike")] -fn given_signal_short_spike(world: &World) { - world.signal.replace(vec![0.0, 4.0, 0.0]); -} +fn given_signal_short_spike(world: &World) { world.signal.replace(vec![0.0, 4.0, 0.0]); } #[given("the threshold is {threshold:f64}")] -fn given_threshold(world: &World, threshold: f64) { - world.threshold.replace(threshold); -} +fn given_threshold(world: &World, threshold: f64) { world.threshold.replace(threshold); } #[given("the minimum bump length is {min_lines}")] fn given_min_bump_lines(world: &World, min_lines: usize) { @@ -152,43 +154,46 @@ fn then_bump_count(world: &World, count: usize) { } #[given("default settings")] -fn given_default_settings(world: &World) { - world.settings.replace(Settings::default()); -} +fn given_default_settings(world: &World) { world.settings.replace(Settings::default()); } #[when("the smoothing window is set to {window}")] -fn when_set_window(world: &World, window: usize) { - world.settings.borrow_mut().window = window; -} +fn when_set_window(world: &World, window: usize) { world.settings.borrow_mut().window = window; } #[when("the threshold is set to {threshold:f64}")] fn when_set_threshold(world: &World, threshold: f64) { world.settings.borrow_mut().threshold = threshold; } -#[when("I normalise the settings")] -fn when_normalise(world: &World) { +#[when("I normalize the settings")] +fn when_normalize(world: &World) { let settings = *world.settings.borrow(); - let normalised = normalise_settings(settings); - world.normalised.replace(Some(normalised)); + let normalized = normalize_settings(settings); + world.normalized.replace(Some(normalized)); } #[then("the window becomes {window}")] fn then_window(world: &World, window: usize) { let settings = world - .normalised + .normalized .borrow() - .expect("settings should be normalised"); + .expect("settings should be normalized"); assert_eq!(settings.window, window); } #[then("the threshold becomes {threshold:f64}")] fn then_threshold(world: &World, threshold: f64) { let settings = world - .normalised + .normalized .borrow() - .expect("settings should be normalised"); - assert_eq!(settings.threshold, threshold); + .expect("settings should be normalized"); + // Compare bit patterns: the assertion is that the configured value + // round-tripped through parsing unchanged, which is exact equality of + // representation rather than numeric proximity. + assert_eq!( + settings.threshold.to_bits(), + threshold.to_bits(), + "the normalized threshold should match the configured value exactly", + ); } #[scenario(path = "tests/features/bumpy_road.feature", index = 0)] @@ -243,8 +248,11 @@ fn ui_dylint_toml_threshold_matches_default() { .and_then(toml::Value::as_float) .expect("ui/dylint.toml should contain [bumpy_road_function].threshold"); + // Bit-pattern comparison: this pins the fixture to the constant exactly, + // so any drift in either is caught rather than tolerated. assert_eq!( - threshold, DEFAULT_THRESHOLD, + threshold.to_bits(), + DEFAULT_THRESHOLD.to_bits(), concat!( "ui/dylint.toml threshold must equal DEFAULT_THRESHOLD; ", "update the config file or the constant to keep them in sync" diff --git a/crates/bumpy_road_function/tests/features/bumpy_road.feature b/crates/bumpy_road_function/tests/features/bumpy_road.feature index bd32d17d..65f27c97 100644 --- a/crates/bumpy_road_function/tests/features/bumpy_road.feature +++ b/crates/bumpy_road_function/tests/features/bumpy_road.feature @@ -24,12 +24,12 @@ Feature: Detect bumpy road intervals Scenario: Even smoothing windows fall back to defaults Given default settings When the smoothing window is set to 2 - And I normalise the settings + And I normalize the settings Then the window becomes 3 Scenario: Negative thresholds fall back to defaults Given default settings When the threshold is set to -1.0 - And I normalise the settings + And I normalize the settings Then the threshold becomes 2.5 diff --git a/crates/bumpy_road_function/tests/ui.rs b/crates/bumpy_road_function/tests/ui.rs index 7693b624..02256b5b 100644 --- a/crates/bumpy_road_function/tests/ui.rs +++ b/crates/bumpy_road_function/tests/ui.rs @@ -7,23 +7,24 @@ #[cfg(feature = "dylint-driver")] extern crate rustc_driver; +use std::path::Path; + use camino::Utf8Path; use dylint_testing::ui::Test; -use std::path::Path; use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test_runner}; #[test] fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |crate_name, dir| { - run_fixtures(crate_name, dir) - }) - .unwrap_or_else(|error| { - panic!( - "UI tests should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error} }}" - ) - }); + whitaker::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/clippy_utils/Cargo.toml b/crates/clippy_utils/Cargo.toml index 8c2f68b8..01243cdd 100644 --- a/crates/clippy_utils/Cargo.toml +++ b/crates/clippy_utils/Cargo.toml @@ -10,3 +10,6 @@ publish = false rustc_hir = { workspace = true } rustc_lint = { workspace = true } rustc_span = { workspace = true } + +[lints] +workspace = true diff --git a/crates/clippy_utils/src/lib.rs b/crates/clippy_utils/src/lib.rs index 34d4c929..20094825 100644 --- a/crates/clippy_utils/src/lib.rs +++ b/crates/clippy_utils/src/lib.rs @@ -7,6 +7,8 @@ #![feature(rustc_private)] +/// Macro-related helpers mirroring the subset of Clippy's `macros` module +/// used by Whitaker lints. pub mod macros { use rustc_hir as hir; use rustc_lint::LateContext; @@ -37,7 +39,7 @@ pub mod macros { return false; }; - let def_id = cx + let resolved = cx .typeck_results() .type_dependent_def_id(callee.hir_id) .or_else(|| match callee.kind { @@ -45,7 +47,7 @@ pub mod macros { _ => None, }); - let Some(def_id) = def_id else { + let Some(def_id) = resolved else { return false; }; diff --git a/crates/conditional_max_n_branches/Cargo.toml b/crates/conditional_max_n_branches/Cargo.toml index bcbc70f9..aef24cd7 100644 --- a/crates/conditional_max_n_branches/Cargo.toml +++ b/crates/conditional_max_n_branches/Cargo.toml @@ -42,6 +42,7 @@ serde = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } whitaker-common = { workspace = true } whitaker = { workspace = true } camino = { workspace = true } @@ -52,3 +53,6 @@ rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } serial_test = "4.0.1" + +[lints] +workspace = true diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index e1419908..ba7d2738 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -15,10 +15,16 @@ use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{DesugaringKind, Span}; use serde::Deserialize; use whitaker::SharedConfig; -use whitaker_common::i18n::{DiagnosticMessageSet, MessageKey}; use whitaker_common::{ - Arguments, FALLBACK_LOCALE, Localizer, MessageResolution, branch_phrase, - get_localizer_for_lint, noop_reporter, safe_resolve_message_set, + Arguments, + FALLBACK_LOCALE, + Localizer, + MessageResolution, + branch_phrase, + get_localizer_for_lint, + i18n::{DiagnosticMessageSet, MessageKey}, + noop_reporter, + safe_resolve_message_set, }; const LINT_NAME: &str = "conditional_max_n_branches"; @@ -32,9 +38,7 @@ struct Config { } impl Config { - const fn default_max_branches() -> usize { - 2 - } + const fn default_max_branches() -> usize { 2 } } impl Default for Config { @@ -60,13 +64,32 @@ impl Default for ConditionalMaxNBranches { } } -dylint_linting::impl_late_lint! { - pub CONDITIONAL_MAX_N_BRANCHES, - Warn, - "complex conditionals should be decomposed when they exceed the configured branch limit", - ConditionalMaxNBranches::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::ConditionalMaxNBranches; + + dylint_linting::impl_late_lint! { + /// Lint flagging conditionals whose predicate exceeds the configured + /// number of short-circuit branches. + pub CONDITIONAL_MAX_N_BRANCHES, + Warn, + "complex conditionals should be decomposed when they exceed the configured branch limit", + ConditionalMaxNBranches::default() + } } +pub use declaration::CONDITIONAL_MAX_N_BRANCHES; + impl<'tcx> LateLintPass<'tcx> for ConditionalMaxNBranches { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { self.max_branches = load_configuration().max_branches.max(1); @@ -187,12 +210,8 @@ fn count_branches(expr: &hir::Expr<'_>) -> usize { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { count_branches(lhs) + count_branches(rhs) } - ExprKind::Unary(UnOp::Not, inner) => count_branches(inner), - ExprKind::DropTemps(inner) => count_branches(inner), - ExprKind::Block(block, _) => match block.expr { - Some(inner) => count_branches(inner), - None => 1, - }, + ExprKind::Unary(UnOp::Not, inner) | ExprKind::DropTemps(inner) => count_branches(inner), + ExprKind::Block(block, _) => block.expr.map_or(1, count_branches), ExprKind::If(cond, ..) => count_branches(cond), _ => 1, } @@ -211,9 +230,12 @@ fn emit_diagnostic( ); args.insert( Cow::Borrowed("branches"), - FluentValue::from(metadata.branches as i64), + FluentValue::from(i64::try_from(metadata.branches).unwrap_or(i64::MAX)), + ); + args.insert( + Cow::Borrowed("limit"), + FluentValue::from(i64::try_from(limit).unwrap_or(i64::MAX)), ); - args.insert(Cow::Borrowed("limit"), FluentValue::from(limit as i64)); let branch_phrase_text = branch_phrase(localizer.locale(), metadata.branches); args.insert( Cow::Borrowed("branch_phrase"), @@ -234,9 +256,9 @@ fn emit_diagnostic( fallback_messages(metadata.kind, metadata.branches, limit) }); - let primary = normalise_isolation_marks(messages.primary()); - let note = normalise_isolation_marks(messages.note()); - let help = normalise_isolation_marks(messages.help()); + let primary = normalize_isolation_marks(messages.primary()); + let note = normalize_isolation_marks(messages.note()); + let help = normalize_isolation_marks(messages.help()); cx.emit_span_lint( CONDITIONAL_MAX_N_BRANCHES, @@ -249,7 +271,7 @@ fn emit_diagnostic( ); } -fn normalise_isolation_marks(text: &str) -> String { +fn normalize_isolation_marks(text: &str) -> String { if text .chars() .any(|character| matches!(character, '\u{2068}' | '\u{2069}' | '\u{FFFD}')) @@ -261,7 +283,7 @@ fn normalise_isolation_marks(text: &str) -> String { }) .collect() } else { - text.to_string() + text.to_owned() } } @@ -287,9 +309,10 @@ fn fallback_messages(kind: ConditionKind, branches: usize, limit: usize) -> Diag #[cfg(test)] mod tests { - use super::*; use rstest::rstest; + use super::*; + #[rstest] #[case(1, 2, ConditionDisposition::WithinLimit)] #[case(2, 2, ConditionDisposition::WithinLimit)] diff --git a/crates/conditional_max_n_branches/src/lib.rs b/crates/conditional_max_n_branches/src/lib.rs index 6f94e601..6abe4970 100644 --- a/crates/conditional_max_n_branches/src/lib.rs +++ b/crates/conditional_max_n_branches/src/lib.rs @@ -4,8 +4,13 @@ #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{CONDITIONAL_MAX_N_BRANCHES, ConditionalMaxNBranches}; #[cfg(not(feature = "dylint-driver"))] mod stub { 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 5afc924c..c05dc47a 100644 --- a/crates/conditional_max_n_branches/src/lib_ui_tests.rs +++ b/crates/conditional_max_n_branches/src/lib_ui_tests.rs @@ -2,23 +2,24 @@ //! `conditional_max_n_branches` lint. These tests ensure curated fixtures execute without //! diffs and provide coverage for the fixture discovery helpers. +use std::path::Path; + use camino::Utf8Path; use dylint_testing::ui::Test; -use std::path::Path; use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test_runner}; #[test] fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |crate_name, dir| { - run_fixtures(crate_name, dir) - }) - .unwrap_or_else(|error| { - panic!( - "UI tests should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error} }}" - ) - }); + whitaker::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/src/tests/behaviour.rs b/crates/conditional_max_n_branches/src/tests/behaviour.rs index 48dddf3e..b3715fb4 100644 --- a/crates/conditional_max_n_branches/src/tests/behaviour.rs +++ b/crates/conditional_max_n_branches/src/tests/behaviour.rs @@ -1,9 +1,11 @@ //! Behaviour-driven coverage for predicate branch evaluation logic. -use super::{ConditionDisposition, evaluate_condition}; +use std::cell::Cell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::Cell; + +use super::{ConditionDisposition, evaluate_condition}; #[derive(Default)] struct PredicateWorld { @@ -13,13 +15,9 @@ struct PredicateWorld { } impl PredicateWorld { - fn set_limit(&self, value: usize) { - self.limit.set(value); - } + fn set_limit(&self, value: usize) { self.limit.set(value); } - fn set_branches(&self, value: usize) { - self.branches.set(value); - } + fn set_branches(&self, value: usize) { self.branches.set(value); } fn evaluate(&self) { let outcome = evaluate_condition(self.branches.get(), self.limit.get()); @@ -33,25 +31,18 @@ impl PredicateWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> PredicateWorld { - PredicateWorld::default() -} +fn world() -> PredicateWorld { PredicateWorld::default() } #[given("the branch limit is {limit}")] -fn given_limit(world: &PredicateWorld, limit: usize) { - world.set_limit(limit); -} +fn given_limit(world: &PredicateWorld, limit: usize) { world.set_limit(limit); } #[given("the predicate declares {branches} branches")] -fn given_branches(world: &PredicateWorld, branches: usize) { - world.set_branches(branches); -} +fn given_branches(world: &PredicateWorld, branches: usize) { world.set_branches(branches); } #[when("I evaluate the predicate complexity")] -fn when_evaluate(world: &PredicateWorld) { - world.evaluate(); -} +fn when_evaluate(world: &PredicateWorld) { world.evaluate(); } #[then("the predicate is accepted")] fn then_accepted(world: &PredicateWorld) { @@ -64,21 +55,13 @@ fn then_rejected(world: &PredicateWorld) { } #[scenario(path = "tests/features/conditional_branches.feature", index = 0)] -fn scenario_within_limit(world: PredicateWorld) { - let _ = world; -} +fn scenario_within_limit(world: PredicateWorld) { let _ = world; } #[scenario(path = "tests/features/conditional_branches.feature", index = 1)] -fn scenario_at_limit(world: PredicateWorld) { - let _ = world; -} +fn scenario_at_limit(world: PredicateWorld) { let _ = world; } #[scenario(path = "tests/features/conditional_branches.feature", index = 2)] -fn scenario_exceeds_limit(world: PredicateWorld) { - let _ = world; -} +fn scenario_exceeds_limit(world: PredicateWorld) { let _ = world; } #[scenario(path = "tests/features/conditional_branches.feature", index = 3)] -fn scenario_custom_limit(world: PredicateWorld) { - let _ = world; -} +fn scenario_custom_limit(world: PredicateWorld) { let _ = world; } diff --git a/crates/function_attrs_follow_docs/Cargo.toml b/crates/function_attrs_follow_docs/Cargo.toml index bb4d06c8..94b5a09a 100644 --- a/crates/function_attrs_follow_docs/Cargo.toml +++ b/crates/function_attrs_follow_docs/Cargo.toml @@ -41,8 +41,12 @@ log = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } serial_test = "4.0.1" + +[lints] +workspace = true diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index 1235e3d4..45285d23 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -4,17 +4,25 @@ //! free functions, inherent methods, and trait methods. Keeping doc comments at //! the front mirrors idiomatic Rust style and prevents them from being obscured //! by implementation details such as `#[inline]` or `#[allow]` attributes. -use rustc_ast::AttrStyle; -use rustc_ast::attr::AttributeExt; +use std::borrow::Cow; + +use rustc_ast::{AttrStyle, attr::AttributeExt}; use rustc_hir as hir; use rustc_hir::attrs::AttributeKind; use rustc_lint::{DiagDecorator, LateContext, LateLintPass, LintContext}; use rustc_span::Span; -use std::borrow::Cow; use whitaker::{SharedConfig, recover_user_editable_hir_span}; use whitaker_common::i18n::{ - Arguments, BundleLookup, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, - MessageResolution, get_localizer_for_lint, noop_reporter, safe_resolve_message_set, + Arguments, + BundleLookup, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + get_localizer_for_lint, + noop_reporter, + safe_resolve_message_set, }; #[cfg(test)] use whitaker_common::i18n::{I18nError, resolve_message_set}; @@ -32,13 +40,31 @@ impl Default for FunctionAttrsFollowDocs { } } -dylint_linting::impl_late_lint! { - pub FUNCTION_ATTRS_FOLLOW_DOCS, - Warn, - "doc comments on functions must precede other outer attributes", - FunctionAttrsFollowDocs::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::FunctionAttrsFollowDocs; + + dylint_linting::impl_late_lint! { + /// Warns when outer attributes on a function precede its doc comments. + pub FUNCTION_ATTRS_FOLLOW_DOCS, + Warn, + "doc comments on functions must precede other outer attributes", + FunctionAttrsFollowDocs::default() + } } +pub use declaration::FUNCTION_ATTRS_FOLLOW_DOCS; + impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { let shared_config = SharedConfig::load(); @@ -50,7 +76,7 @@ impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { if let hir::ItemKind::Fn { .. } = item.kind { self.check_item_attributes( cx, - ItemInfo::new(item.hir_id(), item.span, FunctionKind::Function), + &ItemInfo::new(item.hir_id(), item.span, FunctionKind::Function), ); } } @@ -59,7 +85,7 @@ impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { if let hir::ImplItemKind::Fn(..) = item.kind { self.check_item_attributes( cx, - ItemInfo::new(item.hir_id(), item.span, FunctionKind::Method), + &ItemInfo::new(item.hir_id(), item.span, FunctionKind::Method), ); } } @@ -68,7 +94,7 @@ impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { if let hir::TraitItemKind::Fn(..) = item.kind { self.check_item_attributes( cx, - ItemInfo::new(item.hir_id(), item.span, FunctionKind::TraitMethod), + &ItemInfo::new(item.hir_id(), item.span, FunctionKind::TraitMethod), ); } } @@ -82,15 +108,15 @@ struct ItemInfo { } impl ItemInfo { - fn new(hir_id: hir::HirId, span: Span, kind: FunctionKind) -> Self { + const fn new(hir_id: hir::HirId, span: Span, kind: FunctionKind) -> Self { Self { hir_id, span, kind } } } impl<'tcx> FunctionAttrsFollowDocs { - fn check_item_attributes(&self, cx: &LateContext<'tcx>, item: ItemInfo) { + fn check_item_attributes(&self, cx: &LateContext<'tcx>, item: &ItemInfo) { let attrs = cx.tcx.hir_attrs(item.hir_id); - check_function_attributes(FunctionAttributeCheck { + check_function_attributes(&FunctionAttributeCheck { cx, attrs, item_span: item.span, @@ -195,23 +221,15 @@ impl AttrInfo { (span.lo(), span.hi()) } - fn user_editable_span(&self) -> Option { - self.user_editable_span - } + const fn user_editable_span(&self) -> Option { self.user_editable_span } } impl OrderedAttribute for AttrInfo { - fn is_outer(&self) -> bool { - self.is_outer - } + fn is_outer(&self) -> bool { self.is_outer } - fn is_doc(&self) -> bool { - self.is_doc - } + fn is_doc(&self) -> bool { self.is_doc } - fn span(&self) -> Span { - self.span - } + fn span(&self) -> Span { self.span } } /// Context for checking function attributes. @@ -223,7 +241,7 @@ struct FunctionAttributeCheck<'tcx, 'a> { localizer: &'a Localizer, } -fn check_function_attributes(check: FunctionAttributeCheck<'_, '_>) { +fn check_function_attributes(check: &FunctionAttributeCheck<'_, '_>) { let item_user_editable_span = recover_user_editable_hir_span(check.item_span); let mut infos: Vec = check .attrs @@ -238,14 +256,15 @@ fn check_function_attributes(check: FunctionAttributeCheck<'_, '_>) { ) }); // Attribute macros can reorder attributes in HIR; rely on source order instead. - infos.sort_by_key(|info| info.source_order_key()); + infos.sort_by_key(AttrInfo::source_order_key); let Some((doc_index, offending_index)) = detect_misordered_doc(infos.as_slice()) else { return; }; - let doc = &infos[doc_index]; - let offending = &infos[offending_index]; + let (Some(doc), Some(offending)) = (infos.get(doc_index), infos.get(offending_index)) else { + return; + }; let diagnostic_context = DiagnosticContext { doc_span: doc.span(), offending_span: offending.span(), @@ -265,7 +284,7 @@ fn attribute_within_item( item_span: Option, raw_item_span: Span, ) -> bool { - let Some(attribute_span) = attribute_span else { + let Some(editable_attribute_span) = attribute_span else { return false; }; @@ -273,14 +292,15 @@ fn attribute_within_item( return true; } - let item_span = item_span.unwrap_or(raw_item_span); + let bounding_item_span = item_span.unwrap_or(raw_item_span); // Modern nightlies exclude attributes from the item span, so outer // attributes sit immediately before it. Accept spans contained in the // item (older behaviour and inner attributes) or preceding it (outer // attributes on current nightlies). - let contained = attribute_span.lo() >= item_span.lo() && attribute_span.hi() <= item_span.hi(); - let precedes = attribute_span.hi() <= item_span.lo(); + let contained = editable_attribute_span.lo() >= bounding_item_span.lo() + && editable_attribute_span.hi() <= bounding_item_span.hi(); + let precedes = editable_attribute_span.hi() <= bounding_item_span.lo(); contained || precedes } @@ -312,9 +332,9 @@ fn emit_diagnostic(cx: &LateContext<'_>, context: DiagnosticContext, localizer: let kind = context.kind; move || fallback_messages(kind, attribute.as_str()) }); - let primary = messages.primary().to_string(); - let note = messages.note().to_string(); - let help = messages.help().to_string(); + let primary = messages.primary().to_owned(); + let note = messages.note().to_owned(); + let help = messages.help().to_owned(); cx.emit_span_lint( FUNCTION_ATTRS_FOLLOW_DOCS, @@ -332,7 +352,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("function_attrs_follow_ type FunctionAttrsMessages = DiagnosticMessageSet; #[cfg(test)] -fn localised_messages( +fn localized_messages( lookup: &impl BundleLookup, kind: FunctionKind, attribute: &str, @@ -341,7 +361,7 @@ fn localised_messages( args.insert(Cow::Borrowed("subject"), FluentValue::from(kind.subject())); args.insert( Cow::Borrowed("attribute"), - FluentValue::from(attribute.to_string()), + FluentValue::from(attribute.to_owned()), ); resolve_message_set(lookup, MESSAGE_KEY, &args) @@ -352,17 +372,17 @@ fn fallback_messages(kind: FunctionKind, attribute: &str) -> FunctionAttrsMessag "Doc comments on {} must precede other outer attributes.", kind.subject() ); - let note = format!("The outer attribute {attribute} appears before the doc comment.",); - let help = format!("Move the doc comment so it appears before {attribute} on the item.",); + let note = format!("The outer attribute {attribute} appears before the doc comment."); + let help = format!("Move the doc comment so it appears before {attribute} on the item."); FunctionAttrsMessages::new(primary, note, help) } fn attribute_label(cx: &LateContext<'_>, span: Span, localizer: &Localizer) -> String { - match cx.sess().source_map().span_to_snippet(span) { - Ok(snippet) => snippet.trim().to_string(), - Err(_) => attribute_fallback(localizer), - } + cx.sess().source_map().span_to_snippet(span).map_or_else( + |_| attribute_fallback(localizer), + |snippet| snippet.trim().to_owned(), + ) } fn attribute_fallback(lookup: &impl BundleLookup) -> String { @@ -370,7 +390,7 @@ fn attribute_fallback(lookup: &impl BundleLookup) -> String { lookup .message(MessageKey::new("common-attribute-fallback"), &args) - .unwrap_or_else(|_| "the preceding attribute".to_string()) + .unwrap_or_else(|_| "the preceding attribute".to_owned()) } /// Recover the source span of a parsed attribute kind. @@ -385,7 +405,7 @@ fn attribute_fallback(lookup: &impl BundleLookup) -> String { /// deliberately not recovered until the ordering check needs them. Only /// variants whose shape is identical on the currently supported nightlies /// are matched; further kinds can be added as the pin advances. -fn parsed_attribute_span(kind: &AttributeKind) -> Option { +const fn parsed_attribute_span(kind: &AttributeKind) -> Option { match kind { AttributeKind::DocComment { span, .. } | AttributeKind::Ignore { span, .. } diff --git a/crates/function_attrs_follow_docs/src/lib.rs b/crates/function_attrs_follow_docs/src/lib.rs index 23f48679..ca62ecc6 100644 --- a/crates/function_attrs_follow_docs/src/lib.rs +++ b/crates/function_attrs_follow_docs/src/lib.rs @@ -12,11 +12,22 @@ #![cfg_attr(feature = "dylint-driver", feature(rustc_private))] +// `rustc_hir` attribute structures store feature lists in `ThinVec`, which is a +// `rustc_private` crate rather than a Cargo dependency. The unit tests need to +// name the type when constructing attribute fixtures. +#[cfg(all(test, feature = "dylint-driver"))] +extern crate thin_vec; + #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{FUNCTION_ATTRS_FOLLOW_DOCS, FunctionAttrsFollowDocs}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/function_attrs_follow_docs/src/tests/localization.rs b/crates/function_attrs_follow_docs/src/tests/localization.rs index da1dcda9..a643d296 100644 --- a/crates/function_attrs_follow_docs/src/tests/localization.rs +++ b/crates/function_attrs_follow_docs/src/tests/localization.rs @@ -3,15 +3,20 @@ //! Exercises locale selection, attribute fallback, and missing-message paths via //! `rstest-bdd` scenarios and a custom failing lookup to validate fallbacks. -use super::{ - FunctionAttrsMessages, FunctionKind, Localizer, MESSAGE_KEY, attribute_fallback, - localised_messages, -}; +use std::cell::{Cell, Ref, RefCell}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, Ref, RefCell}; -use whitaker_common::i18n::I18nError; -use whitaker_common::i18n::testing::FailingLookup; +use whitaker_common::i18n::{I18nError, testing::FailingLookup}; + +use super::{ + FunctionAttrsMessages, + FunctionKind, + Localizer, + MESSAGE_KEY, + attribute_fallback, + localized_messages, +}; #[derive(Default)] struct LocalizationWorld { @@ -53,15 +58,12 @@ impl LocalizationWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> LocalizationWorld { - LocalizationWorld::default() -} +fn world() -> LocalizationWorld { LocalizationWorld::default() } #[given("the locale {locale} is selected")] -fn given_locale(world: &LocalizationWorld, locale: String) { - world.use_localizer(&locale); -} +fn given_locale(world: &LocalizationWorld, locale: String) { world.use_localizer(&locale); } #[given("the subject kind is {kind}")] fn given_subject(world: &LocalizationWorld, kind: String) { @@ -79,16 +81,12 @@ fn given_attribute(world: &LocalizationWorld, label: String) { } #[given("the attribute snippet cannot be retrieved")] -fn given_attribute_fallback(world: &LocalizationWorld) { - world.use_attribute_fallback.set(true); -} +fn given_attribute_fallback(world: &LocalizationWorld) { world.use_attribute_fallback.set(true); } #[given("localization fails")] -fn given_failure(world: &LocalizationWorld) { - world.failing.set(true); -} +fn given_failure(world: &LocalizationWorld) { world.failing.set(true); } -#[when("I localise the diagnostic")] +#[when("I localize the diagnostic")] fn when_localize(world: &LocalizationWorld) { let kind = *world.subject.borrow(); let failing = world.failing.get(); @@ -117,9 +115,9 @@ fn resolve_localization( ) -> Result { if failing { let lookup = failing_lookup(); - localised_messages(&lookup, kind, attribute) + localized_messages(&lookup, kind, attribute) } else { - world.with_localizer(|localizer| localised_messages(localizer, kind, attribute)) + world.with_localizer(|localizer| localized_messages(localizer, kind, attribute)) } } @@ -147,30 +145,18 @@ fn then_failure(world: &LocalizationWorld, key: String) { } #[scenario(path = "tests/features/function_attrs_localization.feature", index = 0)] -fn scenario_fallback(world: LocalizationWorld) { - let _ = world; -} +fn scenario_fallback(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/function_attrs_localization.feature", index = 1)] -fn scenario_welsh(world: LocalizationWorld) { - let _ = world; -} +fn scenario_welsh(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/function_attrs_localization.feature", index = 2)] -fn scenario_attribute_fallback(world: LocalizationWorld) { - let _ = world; -} +fn scenario_attribute_fallback(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/function_attrs_localization.feature", index = 3)] -fn scenario_unknown_locale(world: LocalizationWorld) { - let _ = world; -} +fn scenario_unknown_locale(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/function_attrs_localization.feature", index = 4)] -fn scenario_failure(world: LocalizationWorld) { - let _ = world; -} +fn scenario_failure(world: LocalizationWorld) { let _ = world; } -fn failing_lookup() -> FailingLookup { - FailingLookup::new(MESSAGE_KEY.as_ref()) -} +fn failing_lookup() -> FailingLookup { FailingLookup::new(MESSAGE_KEY.as_ref()) } diff --git a/crates/function_attrs_follow_docs/src/tests/order_detection.rs b/crates/function_attrs_follow_docs/src/tests/order_detection.rs index 891ee2a6..5fb7145f 100644 --- a/crates/function_attrs_follow_docs/src/tests/order_detection.rs +++ b/crates/function_attrs_follow_docs/src/tests/order_detection.rs @@ -3,30 +3,29 @@ //! These scenarios exercise `detect_misordered_doc` to ensure doc comments //! continue to precede other outer attributes across common layouts. -use super::{ - AttrInfo, OrderedAttribute, attribute_within_item, detect_misordered_doc, parsed_attribute_span, -}; -use rstest::fixture; -use rstest::rstest; +use std::cell::RefCell; + +use rstest::{fixture, rstest}; use rstest_bdd_macros::{given, scenario, then, when}; -use rustc_hir::attrs::AttributeKind as HirAttributeKind; -use rustc_hir::attrs::{InlineAttr, OptimizeAttr}; +use rustc_hir::attrs::{AttributeKind as HirAttributeKind, InlineAttr, OptimizeAttr}; use rustc_span::{BytePos, DUMMY_SP, Span}; -use std::cell::RefCell; +use thin_vec::ThinVec; use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; +use super::{ + AttrInfo, + OrderedAttribute, + attribute_within_item, + detect_misordered_doc, + parsed_attribute_span, +}; + impl OrderedAttribute for Attribute { - fn is_outer(&self) -> bool { - self.is_outer() - } + fn is_outer(&self) -> bool { self.is_outer() } - fn is_doc(&self) -> bool { - self.is_doc() - } + fn is_doc(&self) -> bool { self.is_doc() } - fn span(&self) -> Span { - DUMMY_SP - } + fn span(&self) -> Span { DUMMY_SP } } #[derive(Default)] @@ -46,15 +45,13 @@ impl AttributeWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> AttributeWorld { - AttributeWorld::default() -} +fn world() -> AttributeWorld { AttributeWorld::default() } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn result() -> Option<(usize, usize)> { - None -} +fn result() -> Option<(usize, usize)> { None } #[given("a doc comment before other attributes")] fn doc_precedes(world: &AttributeWorld) { @@ -82,9 +79,7 @@ fn doc_after_inner(world: &AttributeWorld) { } #[when("I evaluate the attribute order")] -fn evaluate(world: &AttributeWorld) -> Option<(usize, usize)> { - world.result() -} +fn evaluate(world: &AttributeWorld) -> Option<(usize, usize)> { world.result() } #[then("the order is accepted")] fn order_ok(result: &Option<(usize, usize)>) { @@ -96,9 +91,7 @@ fn order_rejected(result: &Option<(usize, usize)>) { assert!(result.is_some()); } -fn test_span(lo: u32, hi: u32) -> Span { - Span::with_root_ctxt(BytePos(lo), BytePos(hi)) -} +fn test_span(lo: u32, hi: u32) -> Span { Span::with_root_ctxt(BytePos(lo), BytePos(hi)) } #[rstest] fn recovered_user_span_drives_source_ordering() { @@ -239,7 +232,7 @@ fn parsed_attribute_span_recovers_whitelisted_kinds() { ( "target_feature", HirAttributeKind::TargetFeature { - features: Default::default(), + features: ThinVec::default(), attr_span: span, was_forced: false, }, diff --git a/crates/function_attrs_follow_docs/src/tests/ui.rs b/crates/function_attrs_follow_docs/src/tests/ui.rs index 4d131fff..b0c39131 100644 --- a/crates/function_attrs_follow_docs/src/tests/ui.rs +++ b/crates/function_attrs_follow_docs/src/tests/ui.rs @@ -6,21 +6,18 @@ //! fixtures. use serial_test::serial; -use whitaker_common::test_support::LocaleOverride; +use whitaker_common::test_support::with_locale; #[test] #[serial] -fn ui() { - run_ui_with_locale("ui", None); -} +fn ui() { run_ui_with_locale("ui", None); } #[test] #[serial] -fn ui_runs_in_welsh_locale() { - run_ui_with_locale("ui-cy", Some("cy")); -} +fn ui_runs_in_welsh_locale() { run_ui_with_locale("ui-cy", Some("cy")); } fn run_ui_with_locale(directory: &str, locale: Option<&str>) { - let _guard = locale.map(LocaleOverride::set); - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + with_locale(locale, || { + whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + }); } diff --git a/crates/function_attrs_follow_docs/tests/features/function_attrs_localization.feature b/crates/function_attrs_follow_docs/tests/features/function_attrs_localization.feature index b30dd7a8..044fa9be 100644 --- a/crates/function_attrs_follow_docs/tests/features/function_attrs_localization.feature +++ b/crates/function_attrs_follow_docs/tests/features/function_attrs_localization.feature @@ -1,9 +1,9 @@ -Feature: Localised diagnostics for function attribute ordering +Feature: Localized diagnostics for function attribute ordering Scenario: English fallback locale Given the locale "en-GB" is selected And the subject kind is "function" And the attribute label is "#[inline]" - When I localise the diagnostic + When I localize the diagnostic Then the primary message contains "Doc comments" And the note mentions "#[inline]" And the help mentions "#[inline]" @@ -12,7 +12,7 @@ Feature: Localised diagnostics for function attribute ordering Given the locale "cy" is selected And the subject kind is "method" And the attribute label is "#[allow(clippy::bool_comparison)]" - When I localise the diagnostic + When I localize the diagnostic Then the primary message contains "sylwadau doc" And the note mentions "#[allow(clippy::bool_comparison)]" @@ -20,7 +20,7 @@ Feature: Localised diagnostics for function attribute ordering Given the locale "en-GB" is selected And the subject kind is "trait method" And the attribute snippet cannot be retrieved - When I localise the diagnostic + When I localize the diagnostic Then the note mentions "the preceding attribute" And the help mentions "the preceding attribute" @@ -28,12 +28,12 @@ Feature: Localised diagnostics for function attribute ordering Given the locale "zz" is selected And the subject kind is "trait method" And the attribute label is "#[allow(dead_code)]" - When I localise the diagnostic + When I localize the diagnostic Then the primary message contains "Doc comments" Scenario: Localization failure reports missing message Given localization fails And the subject kind is "function" And the attribute label is "#[cfg(test)]" - When I localise the diagnostic + When I localize the diagnostic Then localization fails for "function_attrs_follow_docs" diff --git a/crates/module_max_lines/Cargo.toml b/crates/module_max_lines/Cargo.toml index 7001aca9..9a3c2b45 100644 --- a/crates/module_max_lines/Cargo.toml +++ b/crates/module_max_lines/Cargo.toml @@ -34,6 +34,7 @@ whitaker = { workspace = true, features = ["dylint-driver"], optional = true } fluent-templates = { workspace = true, optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } camino = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } @@ -41,3 +42,6 @@ rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } tempfile = "3.14.0" glob = "0.3" + +[lints] +workspace = true diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index c67dd852..6b150deb 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -7,13 +7,17 @@ use log::debug; use rustc_hir as hir; use rustc_lint::{LateContext, LateLintPass, LintContext}; -use rustc_span::Span; -use rustc_span::source_map::SourceMap; -use rustc_span::symbol::Ident; +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, Localizer, MessageKey, MessageResolution, - get_localizer_for_lint, noop_reporter, safe_resolve_message_set, + Arguments, + DiagnosticMessageSet, + Localizer, + MessageKey, + MessageResolution, + get_localizer_for_lint, + noop_reporter, + safe_resolve_message_set, }; const LINT_NAME: &str = "module_max_lines"; @@ -26,13 +30,31 @@ enum ModuleDisposition { ExceedsLimit, } -dylint_linting::impl_late_lint! { - pub MODULE_MAX_LINES, - Warn, - "modules should stay within the configured maximum line count", - ModuleMaxLines::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::ModuleMaxLines; + + dylint_linting::impl_late_lint! { + /// Warns when a module exceeds the configured maximum line count. + pub MODULE_MAX_LINES, + Warn, + "modules should stay within the configured maximum line count", + ModuleMaxLines::default() + } } +pub use declaration::MODULE_MAX_LINES; + /// Lint pass that tracks configuration and localization state while checking modules. pub struct ModuleMaxLines { max_lines: usize, @@ -56,9 +78,8 @@ impl<'tcx> LateLintPass<'tcx> for ModuleMaxLines { } fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::Item<'tcx>) { - let (ident, module) = match item.kind { - hir::ItemKind::Mod(ident, module) => (ident, module), - _ => return, + let hir::ItemKind::Mod(ident, module) = item.kind else { + return; }; let span = module_body_span(cx, item, module); @@ -93,7 +114,7 @@ impl<'tcx> LateLintPass<'tcx> for ModuleMaxLines { } } -fn evaluate_module(lines: usize, limit: usize, from_macro: bool) -> ModuleDisposition { +const fn evaluate_module(lines: usize, limit: usize, from_macro: bool) -> ModuleDisposition { if from_macro { ModuleDisposition::Ignore } else if lines > limit { @@ -110,8 +131,7 @@ fn load_configuration() -> usize { Err(error) => { debug!( target: LINT_NAME, - "failed to parse `{}` configuration: {error}; using defaults", - LINT_NAME + "failed to parse `{LINT_NAME}` configuration: {error}; using defaults" ); ModuleMaxLinesConfig::default().max_lines } @@ -126,7 +146,7 @@ fn count_lines(source_map: &SourceMap, span: Span) -> Option { let contiguous = info .lines .windows(2) - .all(|pair| pair[1].line_index == pair[0].line_index + 1); + .all(|pair| matches!(pair, [previous, next] if next.line_index == previous.line_index + 1)); if !contiguous { debug!( target: LINT_NAME, @@ -147,14 +167,19 @@ struct ModuleDiagnosticInfo { } fn emit_diagnostic(cx: &LateContext<'_>, info: &ModuleDiagnosticInfo, localizer: &Localizer) { - use fluent_templates::fluent_bundle::FluentValue; use std::borrow::Cow; + use fluent_templates::fluent_bundle::FluentValue; + let mut args: Arguments<'_> = Arguments::default(); let module_name = info.ident.name.as_str(); args.insert(Cow::Borrowed("module"), FluentValue::from(module_name)); - args.insert(Cow::Borrowed("lines"), FluentValue::from(info.lines as i64)); - args.insert(Cow::Borrowed("limit"), FluentValue::from(info.limit as i64)); + // Fluent arguments are `i64`; module sizes never approach the bound, so + // saturating keeps the diagnostic honest without a fallible path. + let line_count = i64::try_from(info.lines).unwrap_or(i64::MAX); + let line_limit = i64::try_from(info.limit).unwrap_or(i64::MAX); + args.insert(Cow::Borrowed("lines"), FluentValue::from(line_count)); + args.insert(Cow::Borrowed("limit"), FluentValue::from(line_limit)); let resolution = MessageResolution { lint_name: LINT_NAME, @@ -169,12 +194,12 @@ fn emit_diagnostic(cx: &LateContext<'_>, info: &ModuleDiagnosticInfo, localizer: MODULE_MAX_LINES, info.ident.span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); + lint.primary_message(messages.primary().to_owned()); lint.span_note( module_header_span(info.item_span, info.ident.span), - messages.note().to_string(), + messages.note().to_owned(), ); - lint.help(messages.help().to_string()); + lint.help(messages.help().to_owned()); }), ); } @@ -189,9 +214,10 @@ fn fallback_messages(module: &str, lines: usize, limit: usize) -> DiagnosticMess #[cfg(test)] mod tests { - use super::*; use rstest::rstest; + use super::*; + #[rstest] #[case(4, 5, false, ModuleDisposition::WithinLimit)] #[case(6, 5, false, ModuleDisposition::ExceedsLimit)] @@ -209,10 +235,12 @@ mod tests { #[cfg(test)] mod behaviour { - use super::{ModuleDisposition, evaluate_module}; + use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; - use std::cell::RefCell; + + use super::{ModuleDisposition, evaluate_module}; #[derive(Default)] struct ModuleWorld { @@ -223,17 +251,11 @@ mod behaviour { } impl ModuleWorld { - fn set_lines(&self, value: usize) { - *self.lines.borrow_mut() = value; - } + fn set_lines(&self, value: usize) { *self.lines.borrow_mut() = value; } - fn set_limit(&self, value: usize) { - *self.limit.borrow_mut() = value; - } + fn set_limit(&self, value: usize) { *self.limit.borrow_mut() = value; } - fn mark_macro(&self) { - *self.from_macro.borrow_mut() = true; - } + fn mark_macro(&self) { *self.from_macro.borrow_mut() = true; } fn evaluate(&self) { let lines = *self.lines.borrow(); @@ -250,30 +272,21 @@ mod behaviour { } } + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] - fn world() -> ModuleWorld { - ModuleWorld::default() - } + fn world() -> ModuleWorld { ModuleWorld::default() } #[given("the maximum module length is {limit}")] - fn given_limit(world: &ModuleWorld, limit: usize) { - world.set_limit(limit); - } + fn given_limit(world: &ModuleWorld, limit: usize) { world.set_limit(limit); } #[given("a module spans {lines} lines")] - fn given_lines(world: &ModuleWorld, lines: usize) { - world.set_lines(lines); - } + fn given_lines(world: &ModuleWorld, lines: usize) { world.set_lines(lines); } #[given("the module originates from a macro expansion")] - fn given_macro(world: &ModuleWorld) { - world.mark_macro(); - } + fn given_macro(world: &ModuleWorld) { world.mark_macro(); } #[when("I evaluate the module length")] - fn when_evaluate(world: &ModuleWorld) { - world.evaluate(); - } + fn when_evaluate(world: &ModuleWorld) { world.evaluate(); } #[then("the module is accepted")] fn then_accepted(world: &ModuleWorld) { @@ -291,24 +304,16 @@ mod behaviour { } #[scenario(path = "tests/features/module_length.feature", index = 0)] - fn scenario_within_limit(world: ModuleWorld) { - let _ = world; - } + fn scenario_within_limit(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_length.feature", index = 1)] - fn scenario_exceeds_limit(world: ModuleWorld) { - let _ = world; - } + fn scenario_exceeds_limit(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_length.feature", index = 2)] - fn scenario_exact_limit(world: ModuleWorld) { - let _ = world; - } + fn scenario_exact_limit(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_length.feature", index = 3)] - fn scenario_macro(world: ModuleWorld) { - let _ = world; - } + fn scenario_macro(world: ModuleWorld) { let _ = world; } } #[cfg(test)] diff --git a/crates/module_max_lines/src/lib.rs b/crates/module_max_lines/src/lib.rs index 3fbf5a1a..c83e2af7 100644 --- a/crates/module_max_lines/src/lib.rs +++ b/crates/module_max_lines/src/lib.rs @@ -1,10 +1,20 @@ +//! Dylint lint that flags modules exceeding the configured line budget. +//! +//! The lint drives contributors toward smaller, reviewable modules; the +//! `dylint-driver` feature gates the rustc-facing implementation so the crate +//! also builds as an ordinary library. #![cfg_attr(feature = "dylint-driver", feature(rustc_private))] #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{MODULE_MAX_LINES, ModuleMaxLines}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/module_max_lines/src/lib_ui_tests.rs b/crates/module_max_lines/src/lib_ui_tests.rs index 3ac39206..62e4989e 100644 --- a/crates/module_max_lines/src/lib_ui_tests.rs +++ b/crates/module_max_lines/src/lib_ui_tests.rs @@ -2,23 +2,24 @@ //! `module_max_lines` lint. These tests ensure curated fixtures execute without //! diffs and provide coverage for the shared fixture-discovery helpers. +use std::path::Path; + use camino::Utf8Path; use dylint_testing::ui::Test; -use std::path::Path; use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test_runner}; #[test] fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |crate_name, dir| { - run_fixtures(crate_name, dir) - }) - .unwrap_or_else(|error| { - panic!( - "UI tests should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error} }}" - ) - }); + whitaker::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 284e79b4..b8c11ea9 100644 --- a/crates/module_must_have_inner_docs/Cargo.toml +++ b/crates/module_must_have_inner_docs/Cargo.toml @@ -34,8 +34,12 @@ whitaker = { workspace = true, features = ["dylint-driver"], optional = true } newt-hype = "0.2" [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } serial_test = "4.0.1" + +[lints] +workspace = true diff --git a/crates/module_must_have_inner_docs/src/driver/inner_attr.rs b/crates/module_must_have_inner_docs/src/driver/inner_attr.rs index d73b62d6..b24afccf 100644 --- a/crates/module_must_have_inner_docs/src/driver/inner_attr.rs +++ b/crates/module_must_have_inner_docs/src/driver/inner_attr.rs @@ -4,9 +4,8 @@ //! tied to the main lint flow, such as detecting case-mismatched `doc` //! identifiers and filtering `cfg_attr` wrappers that never supply docs. -use crate::{AttributeBody, ParseInput}; - use super::parser; +use crate::{AttributeBody, ParseInput}; fn segment_has_case_incorrect_doc(segment: &str) -> bool { let Some((ident, tail)) = parser::take_ident(ParseInput::from(segment)) else { @@ -38,7 +37,8 @@ fn has_case_incorrect_doc_in_meta_list(list: &str) -> bool { } } - segment_has_case_incorrect_doc(&list[state.start..]) + list.get(state.start..) + .is_some_and(segment_has_case_incorrect_doc) } // Extracted to reduce nested complexity in `has_case_incorrect_doc_in_meta_list`. @@ -47,7 +47,10 @@ fn handle_character(ch: char, state: &mut ParserState, list: &str, idx: usize) - '(' => state.depth += 1, ')' => state.depth = state.depth.saturating_sub(1), ',' if state.depth == 0 => { - if segment_has_case_incorrect_doc(&list[state.start..idx]) { + if list + .get(state.start..idx) + .is_some_and(segment_has_case_incorrect_doc) + { return true; } state.start = idx + 1; @@ -89,8 +92,12 @@ fn cfg_attr_has_case_incorrect_doc(rest: ParseInput<'_>) -> bool { return false; }; - let args = &content[..close_idx]; - let attr_section = &args[attr_section_start + 1..]; + let Some(args) = content.get(..close_idx) else { + return false; + }; + let Some(attr_section) = args.get(attr_section_start + 1..) else { + return false; + }; has_case_incorrect_doc_in_meta_list(attr_section) } @@ -103,7 +110,7 @@ fn inner_attribute_body(rest: ParseInput<'_>) -> Option> { // Missing `]` is tolerated here: downstream identifier parsing will // gracefully reject malformed content by failing to match expected patterns. let attr_end = body.find(']').unwrap_or(body.len()); - Some(AttributeBody::from(&body[..attr_end])) + body.get(..attr_end).map(AttributeBody::from) } /// Detects inner attributes like `#![DOC = ...]` or `#![cfg_attr(..., Doc = ...)]` diff --git a/crates/module_must_have_inner_docs/src/driver.rs b/crates/module_must_have_inner_docs/src/driver/mod.rs similarity index 75% rename from crates/module_must_have_inner_docs/src/driver.rs rename to crates/module_must_have_inner_docs/src/driver/mod.rs index 2bba0898..6b786836 100644 --- a/crates/module_must_have_inner_docs/src/driver.rs +++ b/crates/module_must_have_inner_docs/src/driver/mod.rs @@ -9,24 +9,45 @@ use std::borrow::Cow; use log::debug; -use newt_hype::base_newtype; use rustc_hir as hir; use rustc_lint::{LateContext, LateLintPass, LintContext}; #[cfg(test)] use rustc_span::DUMMY_SP; -use rustc_span::source_map::SourceMap; -use rustc_span::symbol::Ident; -use rustc_span::{BytePos, Span}; +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, FluentValue, Localizer, MessageKey, MessageResolution, - get_localizer_for_lint, noop_reporter, safe_resolve_message_set, + Arguments, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + get_localizer_for_lint, + noop_reporter, + safe_resolve_message_set, }; mod inner_attr; mod parser; -base_newtype!(StrWrapper); +/// Shared string newtype backing the parser's snippet aliases. +/// +/// `newt_hype::base_newtype!` emits both `Copy` and an explicit `Clone` impl. +/// The impl is generated inside the external macro, so it has no source +/// location that could be changed to a derive; isolating the invocation keeps +/// the expectation scoped to exactly that generated impl. +mod str_wrapper { + #![expect( + clippy::expl_impl_clone_on_copy, + reason = "newt_hype::base_newtype! emits an explicit Clone impl on a Copy type" + )] + + use newt_hype::base_newtype; + + base_newtype!(StrWrapper); +} + +pub use str_wrapper::StrWrapper; pub type SourceSnippet<'a> = StrWrapper<&'a str>; pub type AttributeBody<'a> = StrWrapper<&'a str>; @@ -43,21 +64,38 @@ impl<'a> ParseInput<'a> { /// let input = ParseInput::from("example"); /// assert_eq!(input.as_str(), "example"); /// ``` - pub fn as_str(&self) -> &'a str { - **self - } + #[must_use] + pub fn as_str(&self) -> &'a str { **self } } const LINT_NAME: &str = "module_must_have_inner_docs"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); -dylint_linting::impl_late_lint! { - pub MODULE_MUST_HAVE_INNER_DOCS, - Warn, - "modules must begin with an inner doc comment", - ModuleMustHaveInnerDocs::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::ModuleMustHaveInnerDocs; + + dylint_linting::impl_late_lint! { + /// Warns when a module body does not open with an inner doc comment. + pub MODULE_MUST_HAVE_INNER_DOCS, + Warn, + "modules must begin with an inner doc comment", + ModuleMustHaveInnerDocs::default() + } } +pub use declaration::MODULE_MUST_HAVE_INNER_DOCS; + /// Lint pass enforcing leading inner doc comments on modules. pub struct ModuleMustHaveInnerDocs { localizer: Localizer, @@ -78,9 +116,8 @@ impl<'tcx> LateLintPass<'tcx> for ModuleMustHaveInnerDocs { } fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::Item<'tcx>) { - let (ident, module) = match item.kind { - hir::ItemKind::Mod(ident, module) => (ident, module), - _ => return, + let hir::ItemKind::Mod(ident, module) = item.kind else { + return; }; if item.span.from_expansion() { @@ -191,17 +228,17 @@ fn has_inner_doc(rest: ParseInput<'_>) -> bool { let snippet = rest.as_str(); let mut line_start = 0; - while line_start < snippet.len() { - let line_end = snippet[line_start..] - .find('\n') - .map(|idx| line_start + idx) - .unwrap_or(snippet.len()); - let line = &snippet[line_start..line_end]; + // `split_inclusive` keeps each terminator attached, so accumulating the + // yielded lengths reproduces the byte offset of every line start. + for line_with_terminator in snippet.split_inclusive('\n') { + let line = line_with_terminator + .strip_suffix('\n') + .unwrap_or(line_with_terminator); if check_line_for_inner_doc(snippet, line, line_start) { return true; } - line_start = line_end.saturating_add(1); + line_start = line_start.saturating_add(line_with_terminator.len()); } false @@ -223,10 +260,13 @@ fn check_line_for_inner_doc(snippet: &str, line: &str, line_start: usize) -> boo search_start = offset.saturating_add(2); } - while let Some(local_idx) = line[search_start..].find("#!") { + while let Some(local_idx) = line.get(search_start..).and_then(|tail| tail.find("#!")) { let absolute_idx = search_start + local_idx; - let offset = line_start + absolute_idx; - if parser::is_doc_comment(ParseInput::from(&snippet[offset..])) { + let snippet_offset = line_start + absolute_idx; + let Some(tail) = snippet.get(snippet_offset..) else { + break; + }; + if parser::is_doc_comment(ParseInput::from(tail)) { return true; } search_start = absolute_idx + 2; @@ -282,8 +322,13 @@ fn primary_span_for_disposition( fn first_token_span(module_body: Span, offset: usize, len: usize) -> Span { let base = module_body.shrink_to_lo(); - let start = base.lo() + BytePos(offset as u32); - let hi = start + BytePos(len.max(1) as u32); + // Source files never exceed `u32::MAX` bytes, so a failed conversion means + // the caller supplied a nonsensical offset; fall back to the module start. + let (Ok(byte_offset), Ok(byte_len)) = (u32::try_from(offset), u32::try_from(len.max(1))) else { + return base; + }; + let start = base.lo() + BytePos(byte_offset); + let hi = start + BytePos(byte_len); base.with_lo(start).with_hi(hi) } @@ -305,9 +350,9 @@ fn emit_diagnostic(cx: &LateContext<'_>, context: &ModuleDiagnosticContext, loca MODULE_MUST_HAVE_INNER_DOCS, context.primary_span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); - lint.span_note(context.header_span, messages.note().to_string()); - lint.help(messages.help().to_string()); + lint.primary_message(messages.primary().to_owned()); + lint.span_note(context.header_span, messages.note().to_owned()); + lint.help(messages.help().to_owned()); }), ); } @@ -326,17 +371,17 @@ fn fallback_messages(module: ModuleName<'_>) -> ModuleDocMessages { } #[cfg(test)] -#[path = "tests/behaviour.rs"] +#[path = "../tests/behaviour.rs"] mod behaviour; #[cfg(test)] -#[path = "tests/ui.rs"] +#[path = "../tests/ui.rs"] mod ui; #[cfg(test)] -#[path = "tests/classifier.rs"] +#[path = "../tests/classifier.rs"] mod classifier; #[cfg(test)] -#[path = "tests/span_to_snippet.rs"] +#[path = "../tests/span_to_snippet.rs"] mod span_to_snippet; diff --git a/crates/module_must_have_inner_docs/src/driver/parser.rs b/crates/module_must_have_inner_docs/src/driver/parser.rs index 1f36914c..a0215ba7 100644 --- a/crates/module_must_have_inner_docs/src/driver/parser.rs +++ b/crates/module_must_have_inner_docs/src/driver/parser.rs @@ -6,8 +6,8 @@ //! `#![doc = \"...\"]` style attributes) while ignoring commas inside nested //! parentheses when dissecting meta lists. Key helpers: //! - `skip_leading_whitespace`: advances a text cursor past Unicode whitespace. -//! - `is_doc_comment`: recognizes leading doc comments or doc attributes, -//! including those wrapped in `cfg_attr`. +//! - `is_doc_comment`: recognizes leading doc comments or doc attributes, including those wrapped +//! in `cfg_attr`. //! //! These utilities underpin the lint that determines whether a module has the //! required leading inner docs. @@ -44,7 +44,7 @@ use crate::{AttributeBody, MetaList, ParseInput}; /// assert_eq!(offset, 5); // 2 bytes + 3 bytes. /// assert_eq!(rest.as_str(), "hello"); /// ``` -pub(super) fn skip_leading_whitespace<'a>(snippet: ParseInput<'a>) -> (usize, ParseInput<'a>) { +pub(super) fn skip_leading_whitespace(snippet: ParseInput<'_>) -> (usize, ParseInput<'_>) { let snippet_str = snippet.as_str(); let trimmed = snippet_str.trim_start_matches(char::is_whitespace); let byte_offset = snippet_str.len().saturating_sub(trimmed.len()); @@ -78,8 +78,9 @@ pub(super) fn is_doc_comment(rest: ParseInput<'_>) -> bool { let (_, tail) = skip_leading_whitespace(ParseInput::from(after_bang)); if let Some(body) = tail.strip_prefix('[') { let attr_end = body.find(']').unwrap_or(body.len()); - let attr_body = AttributeBody::from(&body[..attr_end]); - return is_doc_attr(attr_body); + return body + .get(..attr_end) + .is_some_and(|attr_body| is_doc_attr(AttributeBody::from(attr_body))); } } false @@ -87,9 +88,7 @@ pub(super) fn is_doc_comment(rest: ParseInput<'_>) -> bool { // Returns true for direct `doc` attributes and for `cfg_attr` wrappers that // contain a `doc` entry. -fn is_doc_attr(attr_body: AttributeBody<'_>) -> bool { - is_doc_ident(ParseInput::from(*attr_body)) -} +fn is_doc_attr(attr_body: AttributeBody<'_>) -> bool { is_doc_ident(ParseInput::from(*attr_body)) } /// Extracts the leading identifier from the input, skipping any leading /// whitespace. @@ -104,22 +103,24 @@ fn is_doc_attr(attr_body: AttributeBody<'_>) -> bool { /// # use module_must_have_inner_docs::ParseInput; /// # use module_must_have_inner_docs::parser::take_ident; /// let input = ParseInput::from(" foo_bar(baz)"); -/// let Some((ident, rest)) = take_ident(input) else { panic!() }; +/// let Some((ident, rest)) = take_ident(input) else { +/// panic!() +/// }; /// assert_eq!(*ident, "foo_bar"); /// assert_eq!(rest.as_str(), "(baz)"); /// /// assert!(take_ident(ParseInput::from(" 123")).is_none()); /// ``` -pub(super) fn take_ident<'a>(input: ParseInput<'a>) -> Option<(ParseInput<'a>, ParseInput<'a>)> { +pub(super) fn take_ident(input: ParseInput<'_>) -> Option<(ParseInput<'_>, ParseInput<'_>)> { let (_, trimmed) = skip_leading_whitespace(input); let trimmed_str = trimmed.as_str(); let mut iter = trimmed_str.char_indices(); - let (start, ch) = iter.next()?; - if !is_ident_start(ch) { + let (start, first_ch) = iter.next()?; + if !is_ident_start(first_ch) { return None; } - let mut end = start + ch.len_utf8(); + let mut end = start + first_ch.len_utf8(); for (idx, ch) in iter { if is_ident_continue(ch) { end = idx + ch.len_utf8(); @@ -128,8 +129,8 @@ pub(super) fn take_ident<'a>(input: ParseInput<'a>) -> Option<(ParseInput<'a>, P } } - let ident = ParseInput::from(&trimmed_str[..end]); - Some((ident, ParseInput::from(&trimmed_str[end..]))) + let (ident, remainder) = trimmed_str.split_at_checked(end)?; + Some((ParseInput::from(ident), ParseInput::from(remainder))) } // Detects documentation by matching a `doc` ident directly or inside `cfg_attr`. @@ -149,25 +150,25 @@ fn is_doc_ident(input: ParseInput<'_>) -> bool { false } -fn is_ident_start(ch: char) -> bool { +const fn is_ident_start(ch: char) -> bool { // Ident parsing is intentionally ASCII-only; we only need to recognize // built-in attribute names such as `doc` and `cfg_attr`. ch == '_' || ch.is_ascii_alphabetic() } -fn is_ident_continue(ch: char) -> bool { - ch == '_' || ch.is_ascii_alphanumeric() -} +const fn is_ident_continue(ch: char) -> bool { ch == '_' || ch.is_ascii_alphanumeric() } pub(super) fn cfg_attr_has_doc(tail: ParseInput<'_>) -> bool { - let (_, tail) = skip_leading_whitespace(tail); - let Some(args) = tail.strip_prefix('(') else { + let (_, trimmed) = skip_leading_whitespace(tail); + let Some(args) = trimmed.strip_prefix('(') else { return false; }; let Some(close_idx) = args.rfind(')') else { return false; }; - let meta_list = &args[..close_idx]; + let Some(meta_list) = args.get(..close_idx) else { + return false; + }; has_doc_in_meta_list_after_first(MetaList::from(meta_list)) } @@ -179,7 +180,7 @@ struct ParserStateAfterFirst { } impl ParserStateAfterFirst { - fn new() -> Self { + const fn new() -> Self { Self { depth: 0, start: 0, @@ -198,7 +199,7 @@ fn has_doc_in_meta_list_after_first(list: MetaList<'_>) -> bool { } } - state.seen_comma && segment_is_doc(&list_str[state.start..]) + state.seen_comma && list_str.get(state.start..).is_some_and(segment_is_doc) } fn process_char_for_doc_after_first( @@ -217,7 +218,7 @@ fn process_char_for_doc_after_first( false } ',' if state.depth == 0 => { - if state.seen_comma && segment_is_doc(&list_str[state.start..idx]) { + if state.seen_comma && list_str.get(state.start..idx).is_some_and(segment_is_doc) { return true; } state.seen_comma = true; @@ -228,17 +229,16 @@ fn process_char_for_doc_after_first( } } -fn segment_is_doc(segment: &str) -> bool { - is_doc_ident(ParseInput::from(segment)) -} +fn segment_is_doc(segment: &str) -> bool { is_doc_ident(ParseInput::from(segment)) } #[cfg(test)] mod tests { //! Unit tests for parsing helpers. + use rstest::rstest; + use super::skip_leading_whitespace; use crate::ParseInput; - use rstest::rstest; #[rstest] #[case("", 0, "")] diff --git a/crates/module_must_have_inner_docs/src/lib.rs b/crates/module_must_have_inner_docs/src/lib.rs index 1f79354b..7821c2de 100644 --- a/crates/module_must_have_inner_docs/src/lib.rs +++ b/crates/module_must_have_inner_docs/src/lib.rs @@ -26,5 +26,16 @@ #[cfg(feature = "dylint-driver")] mod driver; +// The snippet newtypes are parser plumbing shared between the driver's +// submodules through the crate root. They are generated by `base_newtype!`, +// so the underlying struct has no documentable source location either; +// keeping the aliases crate-visible leaves them outwith the public surface. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub(crate) use driver::{AttributeBody, MetaList, ParseInput}; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. +#[cfg(feature = "dylint-driver")] +pub use driver::{MODULE_MUST_HAVE_INNER_DOCS, ModuleMustHaveInnerDocs}; diff --git a/crates/module_must_have_inner_docs/src/tests/behaviour.rs b/crates/module_must_have_inner_docs/src/tests/behaviour.rs index 0e245e9c..5bd99dc0 100644 --- a/crates/module_must_have_inner_docs/src/tests/behaviour.rs +++ b/crates/module_must_have_inner_docs/src/tests/behaviour.rs @@ -3,10 +3,12 @@ //! These scenarios exercise the snippet classifier to ensure modules only pass //! when they begin with an inner doc comment. -use super::{ModuleDocDisposition, detect_module_docs_from_snippet}; +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; + +use super::{ModuleDocDisposition, detect_module_docs_from_snippet}; #[derive(Default)] struct ModuleWorld { @@ -15,9 +17,7 @@ struct ModuleWorld { } impl ModuleWorld { - fn push(&self, text: &str) { - self.prefix.borrow_mut().push_str(text); - } + fn push(&self, text: &str) { self.prefix.borrow_mut().push_str(text); } fn evaluate(&self) { let snippet = self.prefix.borrow(); @@ -34,30 +34,21 @@ impl ModuleWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> ModuleWorld { - ModuleWorld::default() -} +fn world() -> ModuleWorld { ModuleWorld::default() } #[given("the module begins with an inner doc comment")] -fn given_inner_doc(world: &ModuleWorld) { - world.push("//! module docs\n"); -} +fn given_inner_doc(world: &ModuleWorld) { world.push("//! module docs\n"); } #[given("the module body starts with code only")] -fn given_no_attributes(world: &ModuleWorld) { - world.push("pub fn demo() {}\n"); -} +fn given_no_attributes(world: &ModuleWorld) { world.push("pub fn demo() {}\n"); } #[given("the module contains an inner configuration attribute")] -fn given_inner_allow(world: &ModuleWorld) { - world.push("#![allow(dead_code)]\n"); -} +fn given_inner_allow(world: &ModuleWorld) { world.push("#![allow(dead_code)]\n"); } #[given("documentation follows that attribute")] -fn given_doc_after(world: &ModuleWorld) { - world.push("//! trailing docs\n"); -} +fn given_doc_after(world: &ModuleWorld) { world.push("//! trailing docs\n"); } #[given("the module contains an inner configuration attribute and inline documentation")] fn given_inline_attr_doc(world: &ModuleWorld) { @@ -65,14 +56,10 @@ fn given_inline_attr_doc(world: &ModuleWorld) { } #[given("the module declares only outer documentation")] -fn given_outer_doc(world: &ModuleWorld) { - world.push("/// outer docs\n"); -} +fn given_outer_doc(world: &ModuleWorld) { world.push("/// outer docs\n"); } #[when("I validate the module documentation requirements")] -fn when_detect(world: &ModuleWorld) { - world.evaluate(); -} +fn when_detect(world: &ModuleWorld) { world.evaluate(); } #[then("the module is accepted")] fn then_accept(world: &ModuleWorld) { @@ -93,31 +80,19 @@ fn then_misordered(world: &ModuleWorld) { } #[scenario(path = "tests/features/module_docs.feature", index = 0)] -fn scenario_docs_first(world: ModuleWorld) { - let _ = world; -} +fn scenario_docs_first(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_docs.feature", index = 1)] -fn scenario_missing_docs(world: ModuleWorld) { - let _ = world; -} +fn scenario_missing_docs(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_docs.feature", index = 2)] -fn scenario_misordered_docs(world: ModuleWorld) { - let _ = world; -} +fn scenario_misordered_docs(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_docs.feature", index = 3)] -fn scenario_inline_attr_doc(world: ModuleWorld) { - let _ = world; -} +fn scenario_inline_attr_doc(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_docs.feature", index = 4)] -fn scenario_inner_attribute_only(world: ModuleWorld) { - let _ = world; -} +fn scenario_inner_attribute_only(world: ModuleWorld) { let _ = world; } #[scenario(path = "tests/features/module_docs.feature", index = 5)] -fn scenario_outer_docs(world: ModuleWorld) { - let _ = world; -} +fn scenario_outer_docs(world: ModuleWorld) { let _ = world; } diff --git a/crates/module_must_have_inner_docs/src/tests/classifier.rs b/crates/module_must_have_inner_docs/src/tests/classifier.rs index 74e3e7c2..4eddbce3 100644 --- a/crates/module_must_have_inner_docs/src/tests/classifier.rs +++ b/crates/module_must_have_inner_docs/src/tests/classifier.rs @@ -1,8 +1,9 @@ //! Unit tests for snippet-based module doc detection. -use super::{ModuleDocDisposition, detect_module_docs_from_snippet}; use rstest::rstest; +use super::{ModuleDocDisposition, detect_module_docs_from_snippet}; + #[rstest] #[case("\n \n", ModuleDocDisposition::MissingDocs)] #[case("//! module docs", ModuleDocDisposition::HasLeadingDoc)] @@ -58,7 +59,8 @@ fn rejects_mixed_case_doc_identifiers(#[case] snippet: &str) { fn accepts_nested_cfg_attr_doc() { assert_eq!( detect_module_docs_from_snippet( - "#![cfg_attr(feature = \"outer\", cfg_attr(feature = \"inner\", doc = \"Module docs\"))]" + "#![cfg_attr(feature = \"outer\", cfg_attr(feature = \"inner\", doc = \"Module \ + docs\"))]" .into() ), ModuleDocDisposition::HasLeadingDoc diff --git a/crates/module_must_have_inner_docs/src/tests/span_to_snippet.rs b/crates/module_must_have_inner_docs/src/tests/span_to_snippet.rs index 34df4122..1039e57d 100644 --- a/crates/module_must_have_inner_docs/src/tests/span_to_snippet.rs +++ b/crates/module_must_have_inner_docs/src/tests/span_to_snippet.rs @@ -1,9 +1,13 @@ //! Span-to-snippet tests for module doc detection fallbacks. -use super::{ModuleDocDisposition, detect_module_docs_in_span, primary_span_for_disposition}; use rstest::{fixture, rstest}; -use rustc_span::source_map::{FilePathMapping, SourceMap}; -use rustc_span::{FileName, Span}; +use rustc_span::{ + FileName, + Span, + source_map::{FilePathMapping, SourceMap}, +}; + +use super::{ModuleDocDisposition, detect_module_docs_in_span, primary_span_for_disposition}; #[rstest] fn span_to_snippet_failure_returns_unknown(unresolvable_span_fixture: (SourceMap, Span)) { @@ -25,10 +29,9 @@ fn span_to_snippet_failure_skips_diagnostic(unresolvable_span_fixture: (SourceMa ); } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn unresolvable_span_fixture() -> (SourceMap, Span) { - unresolvable_span() -} +fn unresolvable_span_fixture() -> (SourceMap, Span) { unresolvable_span() } /// Builds a cross-file span (start in "first.rs", end in "second.rs") with the /// root context so the `SourceMap` cannot resolve it to a single file. This 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 88673b05..7782bbd2 100644 --- a/crates/module_must_have_inner_docs/src/tests/ui.rs +++ b/crates/module_must_have_inner_docs/src/tests/ui.rs @@ -3,7 +3,7 @@ use rstest::rstest; use serial_test::serial; -use whitaker_common::test_support::LocaleOverride; +use whitaker_common::test_support::with_locale; /// Runs UI regression tests for the `module_must_have_inner_docs` lint under /// different locale configurations, verifying that diagnostics render correctly. @@ -12,8 +12,8 @@ use whitaker_common::test_support::LocaleOverride; /// /// - `default_locale`: Uses the default English locale (`"ui"` fixtures, `None`). /// - `welsh_locale`: Uses Welsh localization (`"ui-cy"` fixtures, `Some("cy")`). -/// - `unsupported_locale_falls_back_to_english`: Uses an unsupported locale -/// (`"xx-YY"`), expecting fallback to English (`"ui"` fixtures). +/// - `unsupported_locale_falls_back_to_english`: Uses an unsupported locale (`"xx-YY"`), expecting +/// fallback to English (`"ui"` fixtures). /// /// # Example /// @@ -27,6 +27,7 @@ use whitaker_common::test_support::LocaleOverride; #[case::unsupported_locale_falls_back_to_english("ui", Some("xx-YY"))] #[serial] fn ui_tests_across_locales(#[case] directory: &str, #[case] locale: Option<&str>) { - let _guard = locale.map(LocaleOverride::set); - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + with_locale(locale, || { + whitaker::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 8e32153f..4453452f 100644 --- a/crates/no_expect_outside_tests/Cargo.toml +++ b/crates/no_expect_outside_tests/Cargo.toml @@ -37,6 +37,7 @@ serde = { version = "1.0", features = ["derive"], optional = true } log = { workspace = true, optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } @@ -44,3 +45,6 @@ dylint_testing = { workspace = true } camino = { workspace = true } tokio = { version = "1.50.0", features = ["macros", "rt", "rt-multi-thread"] } temp-env = { workspace = true } + +[lints] +workspace = true diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr index 17b1568d..a9beb036 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = parsed.expect("ordinary code must not inherit rstest harness status"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `parse` which is not recognised as a test. + = note: The call originates within function `parse` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. = note: requested on the command line with `-D no-expect-outside-tests` diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs index 4da9179f..97f6f537 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs @@ -1,7 +1,7 @@ //! Negative regression ensuring Tokio-specific test configuration does not //! leak into ordinary code. //! -//! The fixture config recognises `#[tokio::test]`, but `parse_config` remains +//! The fixture config recognizes `#[tokio::test]`, but `parse_config` remains //! production code and must still trigger the lint even in the same crate. fn parse_config() { diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr index add3a1f4..1aece484 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = parsed.expect("ordinary code in a Tokio crate must still lint"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `parse_config` which is not recognised as a test. + = note: The call originates within function `parse_config` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. = note: requested on the command line with `-D no-expect-outside-tests` diff --git a/crates/no_expect_outside_tests/src/behaviour.rs b/crates/no_expect_outside_tests/src/behaviour.rs index 2c94e008..8cfd7859 100644 --- a/crates/no_expect_outside_tests/src/behaviour.rs +++ b/crates/no_expect_outside_tests/src/behaviour.rs @@ -1,12 +1,17 @@ //! Behaviour-driven tests covering context summarization for the lint's context //! world and BDD steps. -use crate::context::{ContextSummary, summarise_context}; +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -use whitaker_common::{ContextEntry, ContextKind}; +use whitaker_common::{ + ContextEntry, + ContextKind, + attributes::{Attribute, AttributeKind, AttributePath}, +}; + +use crate::context::{ContextSummary, summarize_context}; #[derive(Default)] struct ContextWorld { @@ -41,21 +46,17 @@ impl ContextWorld { .push(ContextEntry::new(name, ContextKind::Module, Vec::new())); } - fn enable_cfg_test(&self) { - *self.cfg_test.borrow_mut() = true; - } + fn enable_cfg_test(&self) { *self.cfg_test.borrow_mut() = true; } fn register_additional_attribute(&self, path: &str) { self.additional.borrow_mut().push(AttributePath::from(path)); } - fn mark_doctest(&self) { - *self.is_doctest.borrow_mut() = true; - } + fn mark_doctest(&self) { *self.is_doctest.borrow_mut() = true; } fn evaluate(&self) { let entries = self.entries.borrow(); - let summary = summarise_context( + let summary = summarize_context( entries.as_slice(), *self.cfg_test.borrow(), self.additional.borrow().as_slice(), @@ -64,29 +65,20 @@ impl ContextWorld { *self.summary.borrow_mut() = summary; } - fn summary_ref(&self) -> std::cell::Ref<'_, ContextSummary> { - self.summary.borrow() - } + fn summary_ref(&self) -> std::cell::Ref<'_, ContextSummary> { self.summary.borrow() } - fn should_skip_lint(&self) -> bool { - *self.skip_lint.borrow() - } + fn should_skip_lint(&self) -> bool { *self.skip_lint.borrow() } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> ContextWorld { - ContextWorld::default() -} +fn world() -> ContextWorld { ContextWorld::default() } #[given("a non-test function named {name}")] -fn given_plain_function(world: &ContextWorld, name: String) { - world.push_function(&name); -} +fn given_plain_function(world: &ContextWorld, name: String) { world.push_function(&name); } #[given("a test function named {name}")] -fn given_test_function(world: &ContextWorld, name: String) { - world.push_test_function(&name); -} +fn given_test_function(world: &ContextWorld, name: String) { world.push_test_function(&name); } #[given("a module with cfg(test)")] fn given_cfg_test_module(world: &ContextWorld) { @@ -111,14 +103,10 @@ fn given_function_with_additional_attribute(world: &ContextWorld, path: String) } #[given("the lint is running within a doctest")] -fn given_doctest(world: &ContextWorld) { - world.mark_doctest(); -} +fn given_doctest(world: &ContextWorld) { world.mark_doctest(); } -#[when("I summarise the context")] -fn when_summarise(world: &ContextWorld) { - world.evaluate(); -} +#[when("I summarize the context")] +fn when_summarize(world: &ContextWorld) { world.evaluate(); } #[then("the context is marked as production")] fn then_production(world: &ContextWorld) { diff --git a/crates/no_expect_outside_tests/src/context.rs b/crates/no_expect_outside_tests/src/context/mod.rs similarity index 81% rename from crates/no_expect_outside_tests/src/context.rs rename to crates/no_expect_outside_tests/src/context/mod.rs index 59b8fc2d..367b25a6 100644 --- a/crates/no_expect_outside_tests/src/context.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -2,17 +2,23 @@ //! guards (for example, `cfg(test)`), supporting the lint's context //! summarization. -use rustc_ast::AttrStyle; -use rustc_ast::ast::{MetaItem, MetaItemInner}; +use rustc_ast::{ + AttrStyle, + ast::{MetaItem, MetaItemInner}, +}; use rustc_hir as hir; -use rustc_hir::Node; -use rustc_hir::attrs::AttributeKind as HirAttributeKind; +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, AttributePath, ContextEntry, ContextKind, - PARSED_ATTRIBUTE_PLACEHOLDER, in_test_like_context_with, + Attribute, + AttributeKind, + AttributePath, + ContextEntry, + ContextKind, + PARSED_ATTRIBUTE_PLACEHOLDER, + in_test_like_context_with, }; #[derive(Default, Debug, Clone, PartialEq, Eq)] @@ -31,8 +37,8 @@ pub(crate) struct ContextSummary { /// /// - `cx`: Lint context used to walk the HIR and inspect ancestor attributes. /// - `hir_id`: The HIR node whose ancestor chain should be summarized. -/// - `additional_test_attributes`: Extra user-configured attribute paths that -/// should be treated as test markers alongside Whitaker's built-in list. +/// - `additional_test_attributes`: Extra user-configured attribute paths that should be treated as +/// test markers alongside Whitaker's built-in list. /// /// # Returns /// @@ -47,8 +53,8 @@ pub(crate) struct ContextSummary { /// collect_context(cx, expr.hir_id, additional_test_attributes); /// assert!(!entries.is_empty() || !has_test_context_ancestry); /// ``` -pub(crate) fn collect_context<'tcx>( - cx: &LateContext<'tcx>, +pub(crate) fn collect_context( + cx: &LateContext<'_>, hir_id: hir::HirId, additional_test_attributes: &[AttributePath], ) -> (Vec, bool) { @@ -95,11 +101,10 @@ fn has_test_ancestry( /// # Parameters /// /// - `entries`: Simplified ancestor contexts produced by `collect_context`. -/// - `has_test_context_ancestry`: Whether any ancestor already established -/// test-only ancestry via propagation, `cfg(test)`, or a recognized -/// test-marker attribute. -/// - `additional_test_attributes`: Extra user-configured attribute paths that -/// should be considered test markers during the final summary check. +/// - `has_test_context_ancestry`: Whether any ancestor already established test-only ancestry via +/// propagation, `cfg(test)`, or a recognized test-marker attribute. +/// - `additional_test_attributes`: Extra user-configured attribute paths that should be considered +/// test markers during the final summary check. /// /// # Returns /// @@ -109,7 +114,7 @@ fn has_test_ancestry( /// # Examples /// /// ```ignore -/// let summary = summarise_context( +/// let summary = summarize_context( /// &entries, /// has_test_context_ancestry, /// additional_test_attributes, @@ -118,7 +123,7 @@ fn has_test_ancestry( /// // `.expect()` is allowed in this context. /// } /// ``` -pub(crate) fn summarise_context( +pub(crate) fn summarize_context( entries: &[ContextEntry], has_test_context_ancestry: bool, additional_test_attributes: &[AttributePath], @@ -129,7 +134,7 @@ pub(crate) fn summarise_context( entry .kind() .matches_function() - .then(|| entry.name().to_string()) + .then(|| entry.name().to_owned()) }); ContextSummary { @@ -148,7 +153,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option Some(ContextEntry::new( - "impl".to_string(), + "impl".to_owned(), ContextKind::Impl, convert_attributes(attrs), )), @@ -169,7 +174,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option None, }, Node::Block(_) => Some(ContextEntry::new( - "block".to_string(), + "block".to_owned(), ContextKind::Block, convert_attributes(attrs), )), @@ -195,16 +200,16 @@ fn convert_attribute(attr: &hir::Attribute) -> Attribute { return Attribute::new(AttributePath::from(PARSED_ATTRIBUTE_PLACEHOLDER), kind); }; let mut names = attr.path().into_iter().map(|symbol| symbol.to_string()); - match names.next() { - Some(first) => AttributePath::new(std::iter::once(first).chain(names)), - None => AttributePath::from("unknown"), - } + names.next().map_or_else( + || AttributePath::from("unknown"), + |first| AttributePath::new(std::iter::once(first).chain(names)), + ) }; Attribute::new(path, kind) } -/// Check if a cfg_attr has a test condition and contains nested cfg(test). +/// Check if a `cfg_attr` has a test condition and contains nested cfg(test). fn check_cfg_attr_for_test(items: I) -> bool where I: IntoIterator, @@ -253,24 +258,21 @@ pub(crate) fn is_cfg_test_attribute(attr: &hir::Attribute) -> bool { }; let path = attr.path(); - if path.len() != 1 { + let [name] = path.as_slice() else { return false; - } + }; - if path[0] == sym::cfg { + if *name == sym::cfg { return attr .meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false); + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)); } - if path[0] != sym::cfg_attr { + if *name != sym::cfg_attr { return false; } - attr.meta_item_list() - .map(check_cfg_attr_for_test) - .unwrap_or(false) + attr.meta_item_list().is_some_and(check_cfg_attr_for_test) } fn meta_item_inner_contains_test(item: MetaItemInner) -> bool { @@ -290,33 +292,27 @@ fn meta_contains_test_with_polarity(meta: &MetaItem, is_positive: bool) -> bool } if path_is_ident(&meta.path, sym::not) { - return meta - .meta_item_list() - .map(|items| { - items - .iter() - .cloned() - .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) - }) - .unwrap_or(false); - } - - meta.meta_item_list() - .map(|items| { + return meta.meta_item_list().is_some_and(|items| { items .iter() .cloned() - .any(|item| meta_item_inner_contains_test_with_polarity(item, is_positive)) - }) - .unwrap_or(false) + .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) + }); + } + + meta.meta_item_list().is_some_and(|items| { + items + .iter() + .cloned() + .any(|item| meta_item_inner_contains_test_with_polarity(item, is_positive)) + }) } fn meta_contains_test_cfg(meta: &MetaItem) -> bool { if path_is_ident(&meta.path, sym::cfg) { return meta .meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false); + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)); } if !path_is_ident(&meta.path, sym::cfg_attr) { @@ -324,8 +320,7 @@ fn meta_contains_test_cfg(meta: &MetaItem) -> bool { } meta.meta_item_list() - .map(|items| check_cfg_attr_for_test(items.iter().cloned())) - .unwrap_or(false) + .is_some_and(|items| check_cfg_attr_for_test(items.iter().cloned())) } fn item_name(item: &hir::Item<'_>) -> Option { @@ -336,12 +331,12 @@ fn attribute_style(attr: &hir::Attribute) -> AttrStyle { match attr { hir::Attribute::Unparsed(item) => item.style, hir::Attribute::Parsed(HirAttributeKind::DocComment { style, .. }) => *style, - _ => AttrStyle::Outer, + hir::Attribute::Parsed(_) => AttrStyle::Outer, } } fn path_is_ident(path: &rustc_ast::Path, symbol: rustc_span::Symbol) -> bool { - path.segments.len() == 1 && path.segments[0].ident.name == symbol + matches!(&*path.segments, [segment] if segment.ident.name == symbol) } #[cfg(test)] diff --git a/crates/no_expect_outside_tests/src/context/tests.rs b/crates/no_expect_outside_tests/src/context/tests.rs index 0c251905..35e8622f 100644 --- a/crates/no_expect_outside_tests/src/context/tests.rs +++ b/crates/no_expect_outside_tests/src/context/tests.rs @@ -3,8 +3,6 @@ //! Verifies HIR attribute conversion to `whitaker_common::Attribute` and `cfg(test)` //! detection for both parsed and unparsed attribute variants. -#[cfg(feature = "dylint-driver")] -use super::{convert_attribute, has_test_ancestry, is_cfg_test_attribute, meta_contains_test_cfg}; #[cfg(feature = "dylint-driver")] use rstest::rstest; #[cfg(feature = "dylint-driver")] @@ -24,6 +22,9 @@ use rustc_span::{AttrId, DUMMY_SP, create_default_session_globals_then}; #[cfg(feature = "dylint-driver")] use whitaker_common::{AttributeKind, AttributePath, PARSED_ATTRIBUTE_PLACEHOLDER}; +#[cfg(feature = "dylint-driver")] +use super::{convert_attribute, has_test_ancestry, is_cfg_test_attribute, meta_contains_test_cfg}; + /// Type-safe wrapper for AST path segments. #[cfg(feature = "dylint-driver")] #[derive(Debug, Clone, Copy)] @@ -31,16 +32,12 @@ struct PathSegments(&'static [&'static str]); #[cfg(feature = "dylint-driver")] impl PathSegments { - const fn new(segments: &'static [&'static str]) -> Self { - Self(segments) - } + const fn new(segments: &'static [&'static str]) -> Self { Self(segments) } } #[cfg(feature = "dylint-driver")] impl AsRef<[&'static str]> for PathSegments { - fn as_ref(&self) -> &[&'static str] { - self.0 - } + fn as_ref(&self) -> &[&'static str] { self.0 } } #[cfg(feature = "dylint-driver")] @@ -54,6 +51,25 @@ enum AttributeFixture { CfgAndBuiltInTest, } +/// Outcome expected from `has_test_ancestry` for a table-driven case. +#[cfg(feature = "dylint-driver")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AncestryOutcome { + TestContext, + NotTestContext, +} + +#[cfg(feature = "dylint-driver")] +impl AncestryOutcome { + const fn from_detection(is_test_context: bool) -> Self { + if is_test_context { + Self::TestContext + } else { + Self::NotTestContext + } + } +} + #[cfg(feature = "dylint-driver")] #[derive(Clone, Copy, Debug)] struct HasTestAncestryCase { @@ -61,7 +77,7 @@ struct HasTestAncestryCase { attr_fixture: AttributeFixture, is_function_item: bool, include_custom_attribute: bool, - expected: bool, + expected: AncestryOutcome, } // Common path constants @@ -183,9 +199,7 @@ fn meta_list(segments: PathSegments, children: Vec) -> MetaItem { } #[cfg(feature = "dylint-driver")] -fn meta_inner(meta: MetaItem) -> MetaItemInner { - MetaItemInner::MetaItem(meta) -} +fn meta_inner(meta: MetaItem) -> MetaItemInner { MetaItemInner::MetaItem(meta) } // --------------------------------------------------------------------------- // cfg pattern helpers @@ -257,9 +271,7 @@ fn build_cfg_all_test_unix() -> MetaItem { /// Builds `cfg(not(test))`. #[cfg(feature = "dylint-driver")] -fn build_cfg_not_test() -> MetaItem { - cfg_not(meta_word(PATH_TEST)) -} +fn build_cfg_not_test() -> MetaItem { cfg_not(meta_word(PATH_TEST)) } /// Builds `cfg_attr(test, cfg(test))`. #[cfg(feature = "dylint-driver")] @@ -279,8 +291,12 @@ fn build_cfg_attr_test_allow() -> MetaItem { /// Helper function to test `meta_contains_test_cfg` behaviour. /// Must be called within `create_default_session_globals_then`. #[cfg(feature = "dylint-driver")] -fn assert_meta_test_cfg(meta: MetaItem, expected: bool) { - assert_eq!(meta_contains_test_cfg(&meta), expected); +fn assert_meta_test_cfg(meta: &MetaItem, expected: bool) { + assert_eq!( + meta_contains_test_cfg(meta), + expected, + "meta item should report test-cfg detection as {expected}" + ); } /// Asserts that `convert_attribute` preserves path segments for the given @@ -340,42 +356,42 @@ fn build_additional_test_attributes(include_custom: bool) -> Vec attr_fixture: AttributeFixture::None, is_function_item: false, include_custom_attribute: false, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::cfg_test_attribute_detected(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::CfgTest, is_function_item: false, include_custom_attribute: false, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::built_in_test_attribute_on_function_item(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::BuiltInTest, is_function_item: true, include_custom_attribute: false, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::configured_test_attribute_on_function_item(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::CustomTest, is_function_item: true, include_custom_attribute: true, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::all_detection_paths_together(HasTestAncestryCase { has_test_context_ancestry: true, attr_fixture: AttributeFixture::CfgAndBuiltInTest, is_function_item: true, include_custom_attribute: true, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::negative_case(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::Allow, is_function_item: false, include_custom_attribute: false, - expected: false, + expected: AncestryOutcome::NotTestContext, })] fn has_test_ancestry_detects_test_context(#[case] case: HasTestAncestryCase) { create_default_session_globals_then(|| { @@ -383,14 +399,16 @@ fn has_test_ancestry_detects_test_context(#[case] case: HasTestAncestryCase) { let additional_test_attributes = build_additional_test_attributes(case.include_custom_attribute); + let outcome = AncestryOutcome::from_detection(has_test_ancestry( + case.has_test_context_ancestry, + &attrs, + case.is_function_item, + &additional_test_attributes, + )); + assert_eq!( - has_test_ancestry( - case.has_test_context_ancestry, - &attrs, - case.is_function_item, - &additional_test_attributes, - ), - case.expected, + outcome, case.expected, + "test-ancestry detection should match the expected outcome for {case:?}" ); }); } @@ -400,7 +418,7 @@ fn has_test_ancestry_detects_test_context(#[case] case: HasTestAncestryCase) { #[test] fn meta_contains_test_cfg_any_test_doctest() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_any_test_doctest(), true); + assert_meta_test_cfg(&build_cfg_any_test_doctest(), true); }); } @@ -409,7 +427,7 @@ fn meta_contains_test_cfg_any_test_doctest() { #[test] fn meta_contains_test_cfg_all_test_unix() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_all_test_unix(), true); + assert_meta_test_cfg(&build_cfg_all_test_unix(), true); }); } @@ -418,7 +436,7 @@ fn meta_contains_test_cfg_all_test_unix() { #[test] fn meta_contains_test_cfg_not_test() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_not_test(), false); + assert_meta_test_cfg(&build_cfg_not_test(), false); }); } @@ -427,7 +445,7 @@ fn meta_contains_test_cfg_not_test() { #[test] fn meta_contains_test_cfg_attr_test_cfg_test() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_attr_test_cfg_test(), true); + assert_meta_test_cfg(&build_cfg_attr_test_cfg_test(), true); }); } @@ -436,7 +454,7 @@ fn meta_contains_test_cfg_attr_test_cfg_test() { #[test] fn meta_contains_test_cfg_attr_test_allow() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_attr_test_allow(), false); + assert_meta_test_cfg(&build_cfg_attr_test_allow(), false); }); } @@ -457,7 +475,7 @@ fn convert_attribute_handles_parsed_must_use() { // Should return a placeholder "parsed" path instead of panicking. assert_eq!( attribute.path().segments(), - &[PARSED_ATTRIBUTE_PLACEHOLDER.to_string()] + &[PARSED_ATTRIBUTE_PLACEHOLDER.to_owned()] ); assert_eq!(attribute.kind(), AttributeKind::Outer); } diff --git a/crates/no_expect_outside_tests/src/dependency_rlib_tests.rs b/crates/no_expect_outside_tests/src/dependency_rlib_tests.rs index 0200c8a7..5a5c9f00 100644 --- a/crates/no_expect_outside_tests/src/dependency_rlib_tests.rs +++ b/crates/no_expect_outside_tests/src/dependency_rlib_tests.rs @@ -1,10 +1,14 @@ //! Coverage for dependency artefact selection and related test fixtures. -use super::dependency_rlib; +use std::{ + fs::File, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + use rstest::{fixture, rstest}; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; + +use super::dependency_rlib; /// A temporary directory that is removed automatically when dropped. #[derive(Debug)] @@ -49,10 +53,9 @@ const TIED_ARTEFACTS: [ArtefactSpec<'static>; 2] = [ /// rstest fixture that creates a uniquely named temporary directory for a /// selection test. +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn selection_directory() -> TemporaryDirectory { - TemporaryDirectory::new("selection") -} +fn selection_directory() -> TemporaryDirectory { TemporaryDirectory::new("selection") } /// Creates `artefacts` inside `directory`, sets their modification times, then /// invokes `dependency_rlib` and returns both the expected and selected paths @@ -79,11 +82,10 @@ fn resolve_dependency_rlib_selection( } #[rstest] -#[case("newest", &NEWEST_ARTEFACTS, "libtokio-newer.rlib")] -#[case("ties", &TIED_ARTEFACTS, "libtokio-alpha.rlib")] +#[case::newest(&NEWEST_ARTEFACTS, "libtokio-newer.rlib")] +#[case::ties(&TIED_ARTEFACTS, "libtokio-alpha.rlib")] fn dependency_rlib_selects_expected_artefact( selection_directory: TemporaryDirectory, - #[case] _directory_name: &str, #[case] artefacts: &[ArtefactSpec<'_>], #[case] expected_file_name: &str, ) { @@ -122,14 +124,14 @@ impl TemporaryDirectory { } /// Returns the path to the temporary directory. - fn path(&self) -> &Path { - &self.0 - } + fn path(&self) -> &Path { &self.0 } } impl Drop for TemporaryDirectory { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + // Best-effort cleanup: the directory lives under the OS temporary root, + // so a removal failure only leaks a fixture rather than failing a test. + let _cleanup_result = std::fs::remove_dir_all(&self.0); } } @@ -154,8 +156,10 @@ fn set_modified_time(path: &Path, seconds_since_epoch: u64) { .expect("rlib fixture metadata should be readable") .accessed(); let times = existing_accessed - .map(|accessed| std::fs::FileTimes::new().set_accessed(accessed)) - .unwrap_or_else(|_| std::fs::FileTimes::new()) + .map_or_else( + |_| std::fs::FileTimes::new(), + |accessed| std::fs::FileTimes::new().set_accessed(accessed), + ) .set_modified(modified); file.set_times(times) .expect("rlib fixture modified time should be set"); diff --git a/crates/no_expect_outside_tests/src/diagnostics.rs b/crates/no_expect_outside_tests/src/diagnostics.rs index 48d582e5..0161f0a7 100644 --- a/crates/no_expect_outside_tests/src/diagnostics.rs +++ b/crates/no_expect_outside_tests/src/diagnostics.rs @@ -2,47 +2,45 @@ //! diagnostics; the driver detects violations and context supplies //! test-context evidence. -use crate::NO_EXPECT_OUTSIDE_TESTS; -use crate::context::ContextSummary; +use std::{borrow::Cow, fmt}; + use rustc_hir as hir; use rustc_lint::{DiagDecorator, LateContext, LintContext}; use rustc_middle::ty; use rustc_span::sym; -use std::borrow::Cow; -use std::fmt; use whitaker_common::i18n::{ - Arguments, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, MessageResolution, - noop_reporter, safe_resolve_message_set, + Arguments, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + noop_reporter, + safe_resolve_message_set, }; #[cfg(test)] use whitaker_common::i18n::{BundleLookup, I18nError, resolve_message_set}; +use crate::{NO_EXPECT_OUTSIDE_TESTS, context::ContextSummary}; + /// A formatted label for the receiver type (e.g., "`Result`"). #[derive(Debug, Clone)] pub(crate) struct ReceiverLabel(String); impl ReceiverLabel { - pub(crate) fn new(label: impl Into) -> Self { - Self(label.into()) - } + pub(crate) fn new(label: impl Into) -> Self { Self(label.into()) } } impl Default for ReceiverLabel { - fn default() -> Self { - Self::new(String::new()) - } + fn default() -> Self { Self::new(String::new()) } } impl AsRef for ReceiverLabel { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for ReceiverLabel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -81,7 +79,7 @@ impl ReceiverCategory { } } - fn as_key(self) -> &'static str { + const fn as_key(self) -> &'static str { match self { Self::Option => "option", Self::Result => "result", @@ -109,21 +107,15 @@ impl ReceiverCategory { pub(crate) struct ContextLabel(String); impl ContextLabel { - pub(crate) fn new(label: impl Into) -> Self { - Self(label.into()) - } + pub(crate) fn new(label: impl Into) -> Self { Self(label.into()) } } impl AsRef for ContextLabel { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for ContextLabel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } pub(crate) struct DiagnosticContext<'a> { @@ -132,7 +124,7 @@ pub(crate) struct DiagnosticContext<'a> { } impl<'a> DiagnosticContext<'a> { - pub(crate) fn new(summary: &'a ContextSummary, localizer: &'a Localizer) -> Self { + pub(crate) const fn new(summary: &'a ContextSummary, localizer: &'a Localizer) -> Self { Self { summary, localizer } } } @@ -144,7 +136,7 @@ pub(crate) fn emit_diagnostic( context: &DiagnosticContext<'_>, ) { let receiver_ty = cx.typeck_results().expr_ty(receiver).peel_refs(); - let receiver_label = ReceiverLabel::new(format!("`{}`", receiver_ty)); + let receiver_label = ReceiverLabel::new(format!("`{receiver_ty}`")); let call_context = context_label(context.summary); let category = ReceiverCategory::classify_ty(cx, receiver_ty); @@ -152,15 +144,15 @@ pub(crate) fn emit_diagnostic( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("receiver"), - FluentValue::from(receiver_label.as_ref().to_string()), + FluentValue::from(receiver_label.as_ref().to_owned()), ); args.insert( Cow::Borrowed("context"), - FluentValue::from(call_context.as_ref().to_string()), + FluentValue::from(call_context.as_ref().to_owned()), ); args.insert( Cow::Borrowed("handling"), - FluentValue::from(category.as_key().to_string()), + FluentValue::from(category.as_key().to_owned()), ); let fallback_receiver = receiver_label.clone(); @@ -175,9 +167,9 @@ pub(crate) fn emit_diagnostic( fallback_messages(&fallback_receiver, &fallback_context, category) }); - let primary = messages.primary().to_string(); - let note = messages.note().to_string(); - let help = messages.help().to_string(); + let primary = messages.primary().to_owned(); + let note = messages.note().to_owned(); + let help = messages.help().to_owned(); cx.emit_span_lint( NO_EXPECT_OUTSIDE_TESTS, @@ -195,7 +187,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("no_expect_outside_test type NoExpectMessages = DiagnosticMessageSet; #[cfg(test)] -fn localised_messages( +fn localized_messages( lookup: &impl BundleLookup, receiver: &ReceiverLabel, context: &ContextLabel, @@ -204,15 +196,15 @@ fn localised_messages( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("receiver"), - FluentValue::from(receiver.as_ref().to_string()), + FluentValue::from(receiver.as_ref().to_owned()), ); args.insert( Cow::Borrowed("context"), - FluentValue::from(context.as_ref().to_string()), + FluentValue::from(context.as_ref().to_owned()), ); args.insert( Cow::Borrowed("handling"), - FluentValue::from(category.as_key().to_string()), + FluentValue::from(category.as_key().to_owned()), ); resolve_message_set(lookup, MESSAGE_KEY, &args) @@ -224,18 +216,17 @@ fn fallback_messages( category: ReceiverCategory, ) -> NoExpectMessages { let primary = format!("Avoid calling expect on {receiver} outside test-only code."); - let note = format!("The call originates within {context} which is not recognised as a test.",); + let note = format!("The call originates within {context} which is not recognized as a test."); let help = category.fallback_help(receiver); NoExpectMessages::new(primary, note, help) } fn context_label(summary: &ContextSummary) -> ContextLabel { - let label = summary - .function_name - .as_ref() - .map(|name| format!("function `{name}`")) - .unwrap_or_else(|| "the surrounding scope".to_string()); + let label = summary.function_name.as_ref().map_or_else( + || "the surrounding scope".to_owned(), + |name| format!("function `{name}`"), + ); ContextLabel::new(label) } diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 25d1485d..756dca67 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -9,9 +9,7 @@ //! extend the recognized test attributes through `dylint.toml` when bespoke //! macros are in play. -use std::collections::HashSet; -use std::ffi::OsStr; -use std::path::Path; +use std::{collections::HashSet, ffi::OsStr, path::Path}; use log::debug; use rustc_hir as hir; @@ -19,20 +17,39 @@ use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, Ty}; use rustc_span::{RemapPathScopeComponents, sym}; use serde::Deserialize; -use whitaker::SharedConfig; -use whitaker::hir::has_test_like_hir_attributes; +use whitaker::{SharedConfig, hir::has_test_like_hir_attributes}; use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint}; -use crate::context::{collect_context, is_cfg_test_attribute, summarise_context}; -use crate::diagnostics::{DiagnosticContext, emit_diagnostic}; - -dylint_linting::impl_late_lint! { - pub NO_EXPECT_OUTSIDE_TESTS, - Deny, - "`.expect(..)` must not be used outside of test or doctest contexts", - NoExpectOutsideTests::default() +use crate::{ + context::{collect_context, is_cfg_test_attribute, summarize_context}, + diagnostics::{DiagnosticContext, emit_diagnostic}, +}; + +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::NoExpectOutsideTests; + + dylint_linting::impl_late_lint! { + /// Denies `.expect(..)` calls outside test and doctest contexts. + pub NO_EXPECT_OUTSIDE_TESTS, + Deny, + "`.expect(..)` must not be used outside of test or doctest contexts", + NoExpectOutsideTests::default() + } } +pub use declaration::NO_EXPECT_OUTSIDE_TESTS; + #[derive(Default, Deserialize)] struct Config { #[serde(default)] @@ -122,7 +139,7 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { let additional = self.additional_test_attributes.as_slice(); let (entries, has_test_context_ancestry) = collect_context(cx, expr.hir_id, additional); - let summary = summarise_context(entries.as_slice(), has_test_context_ancestry, additional); + let summary = summarize_context(entries.as_slice(), has_test_context_ancestry, additional); if summary.is_test { return; @@ -153,12 +170,12 @@ fn receiver_is_option_or_result<'tcx>( } fn ty_is_option_or_result<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { - let ty = cx + let normalized = cx .tcx .normalize_erasing_regions(cx.typing_env(), ty::Unnormalized::new_wip(ty)) .peel_refs(); - let Some(adt) = ty.ty_adt_def() else { + let Some(adt) = normalized.ty_adt_def() else { return false; }; @@ -193,7 +210,7 @@ fn ancestor_function_is_test<'tcx>( }) } -fn is_in_tests_directory<'tcx>(cx: &LateContext<'tcx>) -> bool { +fn is_in_tests_directory(cx: &LateContext<'_>) -> bool { cx.tcx.sess.local_crate_source_file().is_some_and(|source| { is_integration_test_crate_root(source.path(RemapPathScopeComponents::DIAGNOSTICS)) }) @@ -233,7 +250,7 @@ fn is_likely_test_function<'tcx>( || is_in_tests_directory(cx) } -fn is_in_cfg_test_module<'tcx>(cx: &LateContext<'tcx>, hir_id: hir::HirId) -> bool { +fn is_in_cfg_test_module(cx: &LateContext<'_>, hir_id: hir::HirId) -> bool { cx.tcx.hir_parent_iter(hir_id).any(|(ancestor_id, node)| { let hir::Node::Item(item) = node else { return false; diff --git a/crates/no_expect_outside_tests/src/driver/tests.rs b/crates/no_expect_outside_tests/src/driver/tests.rs index 0659e1a3..26a0b8b7 100644 --- a/crates/no_expect_outside_tests/src/driver/tests.rs +++ b/crates/no_expect_outside_tests/src/driver/tests.rs @@ -1,11 +1,13 @@ //! Unit tests for test attribute detection helpers in the driver module. -use super::*; +use std::path::Path; + use rstest::rstest; use rustc_ast::AttrStyle; use rustc_hir::attrs::AttributeKind as HirAttributeKind; use rustc_span::{AttrId, DUMMY_SP, create_default_session_globals_then}; -use std::path::Path; + +use super::*; // ------------------------------------------------------------------------- // Test fixtures for HIR attributes @@ -33,7 +35,7 @@ fn hir_attribute_from_segments(segments: &[&str]) -> hir::Attribute { hir::Attribute::Unparsed(Box::new(attr_item)) } -fn parsed_must_use_attribute() -> hir::Attribute { +const fn parsed_must_use_attribute() -> hir::Attribute { hir::Attribute::Parsed(HirAttributeKind::MustUse { span: DUMMY_SP, reason: None, @@ -113,6 +115,7 @@ fn assert_has_test_like_attributes( assert_eq!( has_test_like_hir_attributes(&attrs, additional_test_attributes), expected, + "attribute segments {attr_segments:?} should report test-like as {expected}", ); }); } @@ -129,7 +132,10 @@ fn has_test_like_hir_attributes_detects_test_attributes(#[case] case_index: usiz &[&["tokio", "test"]], &[&["core", "prelude", "v1", "test"]], ]; - assert_has_test_like_attributes(test_cases[case_index], &[], true); + let segments = test_cases + .get(case_index) + .expect("case index should address a declared test case"); + assert_has_test_like_attributes(segments, &[], true); } #[test] @@ -166,36 +172,32 @@ fn has_test_like_hir_attributes_accepts_additional_test_attributes() { // // Behavioural coverage is achieved through: // -// 1. UI tests for attribute detection (is_test_attribute, -// has_test_like_hir_attributes): +// 1. UI tests for attribute detection (is_test_attribute, has_test_like_hir_attributes): // - pass_expect_in_test.rs, pass_expect_in_rstest.rs, pass_expect_in_tokio_test.rs // - These verify that test attributes are recognized without the fallback // // 2. UI tests for arbitrary cfg(test) ancestry detection: // - pass_expect_in_test_module.rs, pass_expect_in_tests_module.rs -// - These verify that `#[cfg(test)]` module ancestry marks nested contexts -// as test-only regardless of the exact module-name heuristic -// - fail_expect_in_file_backed_non_test_fn.rs confirms the ancestry does -// not leak into ordinary top-level functions next to a file-backed test -// module +// - These verify that `#[cfg(test)]` module ancestry marks nested contexts as test-only +// regardless of the exact module-name heuristic +// - fail_expect_in_file_backed_non_test_fn.rs confirms the ancestry does not leak into ordinary +// top-level functions next to a file-backed test module // // 3. Example-based regression coverage for the `rustc --test` harness path: -// - `pass_expect_in_tokio_test_harness` compiles a real `#[tokio::test]` -// example target under `--test`, placing `.expect(...)` calls inside -// nested closure and async-block bodies so the parent walk and sibling -// const descriptor fallback are both exercised. +// - `pass_expect_in_tokio_test_harness` compiles a real `#[tokio::test]` example target under +// `--test`, placing `.expect(...)` calls inside nested closure and async-block bodies so the +// parent walk and sibling const descriptor fallback are both exercised. // - `pass_expect_in_tokio_nonstandard_module_harness` and -// `pass_expect_in_tokio_path_module_harness` cover non-standard module -// names and `#[path]`-loaded Tokio tests under the harness path. -// - `pass_expect_in_tokio_path_module_harness_no_config` keeps the -// `cfg(test)` ancestor walk as the load-bearing path for file-backed -// Tokio tests without extra configuration. -// - `fail_expect_in_tokio_crate_non_test_fn` verifies that configured -// Tokio test attributes remain scoped to actual test functions. +// `pass_expect_in_tokio_path_module_harness` cover non-standard module names and +// `#[path]`-loaded Tokio tests under the harness path. +// - `pass_expect_in_tokio_path_module_harness_no_config` keeps the `cfg(test)` ancestor walk as +// the load-bearing path for file-backed Tokio tests without extra configuration. +// - `fail_expect_in_tokio_crate_non_test_fn` verifies that configured Tokio test attributes +// remain scoped to actual test functions. // -// 4. Real-world validation: The lint is used on this repository's own -// integration tests (compiled with --test), validating the fallback works -// correctly for `cfg(test)` ancestry checks and harness-based recovery. +// 4. Real-world validation: The lint is used on this repository's own integration tests (compiled +// with --test), validating the fallback works correctly for `cfg(test)` ancestry checks and +// harness-based recovery. // // The remaining helper with isolated unit coverage is straightforward: // - extract_function_item: matches `hir::Node::Item` values whose kind is `Fn` diff --git a/crates/no_expect_outside_tests/src/lib.rs b/crates/no_expect_outside_tests/src/lib.rs index f399d4d2..2d3372fb 100644 --- a/crates/no_expect_outside_tests/src/lib.rs +++ b/crates/no_expect_outside_tests/src/lib.rs @@ -25,8 +25,13 @@ mod ui { whitaker::declare_ui_tests!("ui"); } +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{NO_EXPECT_OUTSIDE_TESTS, NoExpectOutsideTests}; #[cfg(not(feature = "dylint-driver"))] mod stub { 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 64dc2e9f..a642015f 100644 --- a/crates/no_expect_outside_tests/src/lib_ui_tests.rs +++ b/crates/no_expect_outside_tests/src/lib_ui_tests.rs @@ -1,11 +1,14 @@ //! Additional UI-style regressions that need compiler flags or example-target //! support beyond the basic `ui/` source fixtures. +use std::{ + path::{Path, PathBuf}, + time::SystemTime, +}; + use camino::Utf8Path; use dylint_testing::ui::Test; use rstest::rstest; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; use temp_env::with_vars_unset; use whitaker_common::test_support::{env_test_guard, prepare_fixture, run_test_runner}; @@ -25,7 +28,7 @@ struct ExampleHarnessRun<'a> { impl<'a> ExampleHarnessRun<'a> { /// Creates a run spec using the default `--test` harness flag. - fn new(name: &'a str, label: &'a str) -> Self { + const fn new(name: &'a str, label: &'a str) -> Self { Self { name, label, @@ -35,7 +38,7 @@ impl<'a> ExampleHarnessRun<'a> { /// Creates a run spec with caller-supplied rustc flags (no defaults /// applied). - fn with_flags(name: &'a str, label: &'a str, rustc_flags: &'a [&'a str]) -> Self { + const fn with_flags(name: &'a str, label: &'a str, rustc_flags: &'a [&'a str]) -> Self { Self { name, label, @@ -81,11 +84,15 @@ 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, |crate_name, _| { + whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { run_test_runner(spec.name, || { let _guard = env_test_guard(); with_vars_unset( - ["RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"], + [ + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + ], || { let mut test = Test::example(crate_name, spec.name); test.rustc_flags(spec.rustc_flags); @@ -96,7 +103,8 @@ fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { }) .unwrap_or_else(|error| { panic!( - "{} example regression should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error:?} }}", + "{} example regression should execute without diffs: RunnerFailure {{ crate_name: \ + \"{crate_name}\", directory: \"{directory}\", message: {error:?} }}", spec.label ) }); @@ -150,9 +158,8 @@ fn run_fixture_harness_test(spec: &FixtureHarnessRun<'_>) { }) .unwrap_or_else(|error| { panic!( - "{} regression should execute without diffs: \ - RunnerFailure {{ crate_name: \"{crate_name}\", \ - directory: \"{directory}\", message: {error:?} }}", + "{} regression should execute without diffs: RunnerFailure {{ crate_name: \ + \"{crate_name}\", directory: \"{directory}\", message: {error:?} }}", spec.label ) }); @@ -252,7 +259,7 @@ fn dependency_rlib_matches(deps_dir: &Path, prefix: &str) -> Result bool { - path.file_name().is_some_and(|name| { - name.to_str() - .is_some_and(|name| name.starts_with(prefix) && name.ends_with(".rlib")) - }) + let has_rlib_extension = path + .extension() + .is_some_and(|extension| extension == "rlib"); + has_rlib_extension + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(prefix)) } #[rstest] diff --git a/crates/no_expect_outside_tests/src/tests.rs b/crates/no_expect_outside_tests/src/tests.rs index f569b8b4..f6e84231 100644 --- a/crates/no_expect_outside_tests/src/tests.rs +++ b/crates/no_expect_outside_tests/src/tests.rs @@ -1,10 +1,14 @@ //! Unit tests validating context summarization outcomes across default and //! configured test attributes. -use crate::context::summarise_context; use rstest::rstest; -use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -use whitaker_common::{ContextEntry, ContextKind}; +use whitaker_common::{ + ContextEntry, + ContextKind, + attributes::{Attribute, AttributeKind, AttributePath}, +}; + +use crate::context::summarize_context; fn function_entry(name: &str, attrs: Vec) -> ContextEntry { ContextEntry::new(name, ContextKind::Function, attrs) @@ -19,18 +23,18 @@ fn test_attribute() -> Attribute { } #[rstest] -fn summarises_plain_context() { +fn summarizes_plain_context() { let entries = vec![function_entry("handler", Vec::new())]; - let summary = summarise_context(&entries, false, &[]); + let summary = summarize_context(&entries, false, &[]); assert!(!summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("handler")); } #[rstest] -fn recognises_test_attribute() { +fn recognizes_test_attribute() { let entries = vec![function_entry("test_case", vec![test_attribute()])]; - let summary = summarise_context(&entries, false, &[]); + let summary = summarize_context(&entries, false, &[]); assert!(summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("test_case")); @@ -39,7 +43,7 @@ fn recognises_test_attribute() { #[rstest] fn honours_cfg_test() { let entries = vec![module_entry("tests", Vec::new())]; - let summary = summarise_context(&entries, true, &[]); + let summary = summarize_context(&entries, true, &[]); assert!(summary.is_test); assert_eq!(summary.function_name, None); @@ -55,7 +59,7 @@ fn honours_additional_attributes() { )], )]; let additional = vec![AttributePath::from("custom::test")]; - let summary = summarise_context(&entries, false, additional.as_slice()); + let summary = summarize_context(&entries, false, additional.as_slice()); assert!(summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("custom")); diff --git a/crates/no_expect_outside_tests/src/tests/localization.rs b/crates/no_expect_outside_tests/src/tests/localization.rs index 879375be..33d65d31 100644 --- a/crates/no_expect_outside_tests/src/tests/localization.rs +++ b/crates/no_expect_outside_tests/src/tests/localization.rs @@ -1,20 +1,28 @@ -//! BDD-style localization tests for no_expect_outside_tests diagnostic +//! BDD-style localization tests for `no_expect_outside_tests` diagnostic //! messages. //! //! Exercises localization scenarios including locale selection, receiver type //! handling, context label generation, and error paths using `rstest-bdd` and a //! `FailingLookup` test double. +use std::cell::{Cell, Ref, RefCell}; + +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; +use whitaker_common::i18n::{BundleLookup, testing::FailingLookup}; + use super::{ - I18nError, Localizer, MESSAGE_KEY, NoExpectMessages, ReceiverCategory, ReceiverLabel, - context_label, fallback_messages, localised_messages, + I18nError, + Localizer, + MESSAGE_KEY, + NoExpectMessages, + ReceiverCategory, + ReceiverLabel, + context_label, + fallback_messages, + localized_messages, }; use crate::context::ContextSummary; -use rstest::fixture; -use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, Ref, RefCell}; -use whitaker_common::i18n::BundleLookup; -use whitaker_common::i18n::testing::FailingLookup; fn unquote(value: &str) -> &str { value @@ -25,7 +33,7 @@ fn unquote(value: &str) -> &str { fn format_receiver(receiver: &str) -> String { if receiver.is_empty() || receiver.starts_with('`') { - receiver.to_string() + receiver.to_owned() } else { format!("`{receiver}`") } @@ -65,7 +73,7 @@ impl LocalizationWorld { fn set_function(&self, name: Option<&str>) { let mut summary = self.summary.borrow_mut(); - summary.function_name = name.map(ToString::to_string); + summary.function_name = name.map(str::to_owned); } fn record_result(&self, value: Result) { @@ -91,10 +99,9 @@ impl LocalizationWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> LocalizationWorld { - LocalizationWorld::default() -} +fn world() -> LocalizationWorld { LocalizationWorld::default() } #[given("the locale {locale} is selected")] fn given_locale(world: &LocalizationWorld, locale: String) { @@ -108,15 +115,17 @@ fn given_receiver(world: &LocalizationWorld, receiver: String) { #[given("the function context is {name}")] fn given_function(world: &LocalizationWorld, name: String) { - let name = unquote(&name); - let value = if name.is_empty() { None } else { Some(name) }; + let function_name = unquote(&name); + let value = if function_name.is_empty() { + None + } else { + Some(function_name) + }; world.set_function(value); } #[given("the receiver type is empty")] -fn given_receiver_type_empty(world: &LocalizationWorld) { - world.set_receiver_type(""); -} +fn given_receiver_type_empty(world: &LocalizationWorld) { world.set_receiver_type(""); } #[given("the receiver type is malformed")] fn given_receiver_type_malformed(world: &LocalizationWorld) { @@ -129,16 +138,12 @@ fn given_receiver_type_unexpected(world: &LocalizationWorld) { } #[given("the call occurs outside any function")] -fn given_no_function(world: &LocalizationWorld) { - world.set_function(None); -} +fn given_no_function(world: &LocalizationWorld) { world.set_function(None); } #[given("localization fails")] -fn given_failure(world: &LocalizationWorld) { - world.failing.set(true); -} +fn given_failure(world: &LocalizationWorld) { world.failing.set(true); } -#[when("I localise the expect diagnostic")] +#[when("I localize the expect diagnostic")] fn when_localize(world: &LocalizationWorld) { let receiver = world.receiver.borrow().clone(); let summary = world.summary.borrow().clone(); @@ -155,31 +160,31 @@ fn when_localize(world: &LocalizationWorld) { #[then("the diagnostic mentions {snippet}")] fn then_primary(world: &LocalizationWorld, snippet: String) { - let snippet = normalize_for_assertion(unquote(&snippet)); + let expected = normalize_for_assertion(unquote(&snippet)); let primary = normalize_for_assertion(world.messages().primary()); assert!( - primary.contains(&snippet), - "primary message `{primary}` did not contain `{snippet}`" + primary.contains(&expected), + "primary message `{primary}` did not contain `{expected}`" ); } #[then("the note references {snippet}")] fn then_note(world: &LocalizationWorld, snippet: String) { - let snippet = normalize_for_assertion(unquote(&snippet)); + let expected = normalize_for_assertion(unquote(&snippet)); let note = normalize_for_assertion(world.messages().note()); assert!( - note.contains(&snippet), - "note `{note}` did not contain `{snippet}`" + note.contains(&expected), + "note `{note}` did not contain `{expected}`" ); } #[then("the help references {snippet}")] fn then_help(world: &LocalizationWorld, snippet: String) { - let snippet = normalize_for_assertion(unquote(&snippet)); + let expected = normalize_for_assertion(unquote(&snippet)); let help = normalize_for_assertion(world.messages().help()); assert!( - help.contains(&snippet), - "help `{help}` did not contain `{snippet}`" + help.contains(&expected), + "help `{help}` did not contain `{expected}`" ); } @@ -194,51 +199,40 @@ fn then_receiver_type_edge_cases_are_handled(world: &LocalizationWorld) { #[then("localization fails for {key}")] fn then_failure(world: &LocalizationWorld, key: String) { - let key = unquote(&key); + let expected_key = unquote(&key); let error = world.error(); match &*error { - I18nError::MissingMessage { key: missing, .. } => assert_eq!(missing, key), + I18nError::MissingMessage { key: missing, .. } => assert_eq!( + missing, &expected_key, + "localization should fail for the requested key" + ), } } #[scenario(path = "tests/features/localization.feature", index = 0)] -fn scenario_fallback(world: LocalizationWorld) { - let _ = world; -} +fn scenario_fallback(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 1)] -fn scenario_cymraeg(world: LocalizationWorld) { - let _ = world; -} +fn scenario_cymraeg(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 2)] -fn scenario_unknown_locale(world: LocalizationWorld) { - let _ = world; -} +fn scenario_unknown_locale(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 3)] -fn scenario_receiver_empty(world: LocalizationWorld) { - let _ = world; -} +fn scenario_receiver_empty(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 4)] -fn scenario_receiver_malformed(world: LocalizationWorld) { - let _ = world; -} +fn scenario_receiver_malformed(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 5)] -fn scenario_receiver_unexpected(world: LocalizationWorld) { - let _ = world; -} +fn scenario_receiver_unexpected(world: LocalizationWorld) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 6)] -fn scenario_failure(world: LocalizationWorld) { - let _ = world; -} +fn scenario_failure(world: LocalizationWorld) { let _ = world; } #[then("the fallback help mentions {snippet}")] fn then_fallback(world: &LocalizationWorld, snippet: String) { - let snippet = normalize_for_assertion(unquote(&snippet)); + let expected = normalize_for_assertion(unquote(&snippet)); let summary = world.summary.borrow().clone(); let context = context_label(&summary); let receiver = world.receiver.borrow().clone(); @@ -246,8 +240,8 @@ fn then_fallback(world: &LocalizationWorld, snippet: String) { let fallback = fallback_messages(&receiver, &context, category); let help = normalize_for_assertion(fallback.help()); assert!( - help.contains(&snippet), - "fallback help `{help}` did not contain `{snippet}`" + help.contains(&expected), + "fallback help `{help}` did not contain `{expected}`" ); } @@ -258,9 +252,7 @@ fn execute_localization( ) -> Result { let context = context_label(summary); let category = ReceiverCategory::for_label(receiver); - localised_messages(lookup, receiver, &context, category) + localized_messages(lookup, receiver, &context, category) } -fn failing_lookup() -> FailingLookup { - FailingLookup::new(MESSAGE_KEY.as_ref()) -} +fn failing_lookup() -> FailingLookup { FailingLookup::new(MESSAGE_KEY.as_ref()) } diff --git a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs index 10e7761c..57ce8dfc 100644 --- a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs +++ b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs @@ -1,9 +1,15 @@ //! Edge-case localization tests covering unusual receiver labels. +use rstest::rstest; + use super::{ - ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages, + ContextLabel, + Localizer, + NoExpectMessages, + ReceiverCategory, + ReceiverLabel, + localized_messages, }; -use rstest::rstest; #[rstest] #[case("", "the surrounding scope", |messages: &NoExpectMessages| !messages.primary().is_empty())] @@ -26,7 +32,7 @@ fn handles_receiver_type_edge_cases( let receiver_label = ReceiverLabel::new(receiver); let context_label = ContextLabel::new(context); let category = ReceiverCategory::for_label(&receiver_label); - let messages = localised_messages(&lookup, &receiver_label, &context_label, category) + let messages = localized_messages(&lookup, &receiver_label, &context_label, category) .expect("localization succeeds"); assert!( assertion(&messages), diff --git a/crates/no_expect_outside_tests/tests/features/context_summary.feature b/crates/no_expect_outside_tests/tests/features/context_summary.feature index a9453c23..14469084 100644 --- a/crates/no_expect_outside_tests/tests/features/context_summary.feature +++ b/crates/no_expect_outside_tests/tests/features/context_summary.feature @@ -1,33 +1,33 @@ -Feature: Summarise traversal context for `.expect(..)` linting +Feature: Summarize traversal context for `.expect(..)` linting Scenario: Plain function without test attributes Given a non-test function named handler - When I summarise the context + When I summarize the context Then the context is marked as production And the function name is handler Scenario: Function marked as a test Given a test function named works - When I summarise the context + When I summarize the context Then the context is marked as test And the function name is works Scenario: Module guarded by cfg(test) Given a module with cfg(test) - When I summarise the context + When I summarize the context Then the context is marked as test And no function name is recorded - Scenario: Function recognised via configured attribute + Scenario: Function recognized via configured attribute Given an additional test attribute custom::test is configured And a function annotated with the additional attribute custom::test - When I summarise the context + When I summarize the context Then the context is marked as test And the function name is custom Scenario: Doctest crate bypasses linting Given a non-test function named handler And the lint is running within a doctest - When I summarise the context + When I summarize the context Then the lint is skipped And the function name is handler diff --git a/crates/no_expect_outside_tests/tests/features/localization.feature b/crates/no_expect_outside_tests/tests/features/localization.feature index b6075630..6ebdf5bf 100644 --- a/crates/no_expect_outside_tests/tests/features/localization.feature +++ b/crates/no_expect_outside_tests/tests/features/localization.feature @@ -1,9 +1,9 @@ -Feature: Localised diagnostics for expect usage +Feature: Localized diagnostics for expect usage Scenario: English fallback locale Given the locale "en-GB" is selected And the receiver type is "Result" And the function context is "handler" - When I localise the expect diagnostic + When I localize the expect diagnostic Then the diagnostic mentions "calling expect on `Result`" And the note references "function `handler`" And the help references "`Result`" @@ -13,7 +13,7 @@ Feature: Localised diagnostics for expect usage Given the locale "cy" is selected And the receiver type is "Option" And the function context is "" - When I localise the expect diagnostic + When I localize the expect diagnostic Then the diagnostic mentions "Peidiwch" And the note references "Daw’r galwad" @@ -21,7 +21,7 @@ Feature: Localised diagnostics for expect usage Given the locale "zz" is selected And the receiver type is "Result" And the call occurs outside any function - When I localise the expect diagnostic + When I localize the expect diagnostic Then the diagnostic mentions "calling expect on `Result`" And the fallback help mentions "`Result`" And the fallback help mentions "`Err` variant" @@ -30,26 +30,26 @@ Feature: Localised diagnostics for expect usage Given the locale "en-GB" is selected And the receiver type is empty And the function context is "" - When I localise the expect diagnostic + When I localize the expect diagnostic Then the fallback and localization logic should handle the receiver type robustly Scenario: Receiver type is malformed Given the locale "en-GB" is selected And the receiver type is malformed And the function context is "worker" - When I localise the expect diagnostic + When I localize the expect diagnostic Then the fallback and localization logic should handle the receiver type robustly Scenario: Receiver type is unexpected Given the locale "en-GB" is selected And the receiver type is unexpected And the function context is "handler" - When I localise the expect diagnostic + When I localize the expect diagnostic Then the fallback and localization logic should handle the receiver type robustly Scenario: Localization failure surfaces missing message Given localization fails And the receiver type is "Result<(), ()>" And the function context is "worker" - When I localise the expect diagnostic + When I localize the expect diagnostic Then localization fails for "no_expect_outside_tests" diff --git a/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr b/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr index 85082bd0..2778af4f 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = value.expect("file-backed cfg(test) ancestry must not leak into main"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `main` which is not recognised as a test. + = note: The call originates within function `main` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_in_file_backed_non_test_fn.rs:6:9 diff --git a/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr b/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr index 68847310..609da9dc 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option` outside test-only code LL | let _result = value.expect("value should exist"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `process` which is not recognised as a test. + = note: The call originates within function `process` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_in_fn.rs:2:9 @@ -18,7 +18,7 @@ error: Avoid calling expect on `std::result::Result<(), &str>` outside test-only LL | let _ = result.expect("result should be ok"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `fail_result` which is not recognised as a test. + = note: The call originates within function `fail_result` which is not recognized as a test. = help: Handle the `Err` variant of `std::result::Result<(), &str>` or move the code into a test. error: aborting due to 2 previous errors diff --git a/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr b/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr index e5d6c518..aa650a66 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option` outside test-only code LL | let _ = value.expect("handler should not ignore errors"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `handler` which is not recognised as a test. + = note: The call originates within function `handler` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_with_cfg_attr.rs:1:9 diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index 08f46a84..a979d087 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } anyhow = "1.0" cargo_metadata = { workspace = true } insta = { workspace = true } @@ -48,3 +49,6 @@ serial_test = "4.0.1" serde_json = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } + +[lints] +workspace = true diff --git a/crates/no_std_fs_operations/src/behaviour.rs b/crates/no_std_fs_operations/src/behaviour.rs index 4e3cb617..34a5680d 100644 --- a/crates/no_std_fs_operations/src/behaviour.rs +++ b/crates/no_std_fs_operations/src/behaviour.rs @@ -1,11 +1,12 @@ //! Behaviour-driven localization tests for the `no_std_fs_operations` lint. -use crate::diagnostics::{StdFsMessages, localised_messages}; +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use whitaker_common::i18n::testing::FailingLookup; -use whitaker_common::i18n::{I18nError, Localizer}; +use whitaker_common::i18n::{I18nError, Localizer, testing::FailingLookup}; + +use crate::diagnostics::{StdFsMessages, localized_messages}; #[derive(Default)] struct LocalizationWorld { @@ -20,39 +21,35 @@ impl LocalizationWorld { self.localizer = Some(Localizer::new(Some(locale))); } - fn set_operation(&mut self, operation: &str) { - self.operation = operation.to_owned(); - } + fn set_operation(&mut self, operation: &str) { operation.clone_into(&mut self.operation); } - fn mark_failure(&mut self) { - self.failing = true; - } + const fn mark_failure(&mut self) { self.failing = true; } fn resolve(&mut self) { let op = self.operation.clone(); let result = if self.failing { - localised_messages(&FailingLookup::new("no_std_fs_operations"), &op) + localized_messages(&FailingLookup::new("no_std_fs_operations"), &op) } else { - let localizer = self.localizer.as_ref().expect("a locale must be selected"); - localised_messages(localizer, &op) + let Some(localizer) = self.localizer.as_ref() else { + panic!("a locale must be selected before resolving messages") + }; + localized_messages(localizer, &op) }; self.result = Some(result); } fn messages(&self) -> &StdFsMessages { - self.result - .as_ref() - .expect("localization result should be recorded") - .as_ref() - .expect("localization should succeed") + let Some(Ok(messages)) = self.result.as_ref().map(Result::as_ref) else { + panic!("localization should have been resolved successfully") + }; + messages } fn error(&self) -> &I18nError { - self.result - .as_ref() - .expect("localization result should be recorded") - .as_ref() - .expect_err("localization should fail") + let Some(Err(error)) = self.result.as_ref().map(Result::as_ref) else { + panic!("localization should have been resolved to a failure") + }; + error } } @@ -79,34 +76,39 @@ fn given_operation(world: &WorldCell, operation: String) { } #[given("localization fails")] -fn given_failure(world: &WorldCell) { - world.borrow_mut().mark_failure(); -} +fn given_failure(world: &WorldCell) { world.borrow_mut().mark_failure(); } -#[when("I localise the std::fs diagnostic")] -fn when_localise(world: &WorldCell) { - world.borrow_mut().resolve(); -} +#[when("I localize the std::fs diagnostic")] +fn when_localize(world: &WorldCell) { world.borrow_mut().resolve(); } #[then("the primary mentions {snippet}")] fn then_primary(world: &WorldCell, snippet: String) { let needle = snippet.trim_matches('"'); let borrow = world.borrow(); - assert!(borrow.messages().primary().contains(needle)); + assert!( + borrow.messages().primary().contains(needle), + "primary message should mention `{needle}`" + ); } #[then("the note references {snippet}")] fn then_note(world: &WorldCell, snippet: String) { let needle = snippet.trim_matches('"'); let borrow = world.borrow(); - assert!(borrow.messages().note().contains(needle)); + assert!( + borrow.messages().note().contains(needle), + "note message should mention `{needle}`" + ); } #[then("the help references {snippet}")] fn then_help(world: &WorldCell, snippet: String) { let needle = snippet.trim_matches('"'); let borrow = world.borrow(); - assert!(borrow.messages().help().contains(needle)); + assert!( + borrow.messages().help().contains(needle), + "help message should mention `{needle}`" + ); } #[then("localization fails for {key}")] @@ -114,32 +116,26 @@ fn then_failure(world: &WorldCell, key: String) { let borrow = world.borrow(); match borrow.error() { I18nError::MissingMessage { key: missing, .. } => { - assert_eq!(missing, &key.trim_matches('"')) + assert_eq!( + missing, + &key.trim_matches('"'), + "localization should fail for the requested key" + ); } } } #[scenario(path = "tests/features/localization.feature", index = 0)] -fn scenario_english(world: WorldCell) { - let _ = world; -} +fn scenario_english(world: WorldCell) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 1)] -fn scenario_welsh(world: WorldCell) { - let _ = world; -} +fn scenario_welsh(world: WorldCell) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 2)] -fn scenario_gaelic(world: WorldCell) { - let _ = world; -} +fn scenario_gaelic(world: WorldCell) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 3)] -fn scenario_fallback(world: WorldCell) { - let _ = world; -} +fn scenario_fallback(world: WorldCell) { let _ = world; } #[scenario(path = "tests/features/localization.feature", index = 4)] -fn scenario_failure(world: WorldCell) { - let _ = world; -} +fn scenario_failure(world: WorldCell) { let _ = world; } diff --git a/crates/no_std_fs_operations/src/config.rs b/crates/no_std_fs_operations/src/config.rs index 6ffdbad2..a333aea9 100644 --- a/crates/no_std_fs_operations/src/config.rs +++ b/crates/no_std_fs_operations/src/config.rs @@ -1,10 +1,12 @@ //! Configuration for the `no_std_fs_operations` lint: the settings schema and //! the `dylint.toml` loading path, kept separate from the lint pass itself. -use crate::exclusion::PathExclusions; +use std::collections::HashSet; + use log::warn; use serde::Deserialize; -use std::collections::HashSet; + +use crate::exclusion::PathExclusions; /// Lint name used for `dylint.toml` lookups, diagnostic codes, and log targets. pub(crate) const LINT_NAME: &str = "no_std_fs_operations"; diff --git a/crates/no_std_fs_operations/src/config_tests.rs b/crates/no_std_fs_operations/src/config_tests.rs index fd5e77f8..65395732 100644 --- a/crates/no_std_fs_operations/src/config_tests.rs +++ b/crates/no_std_fs_operations/src/config_tests.rs @@ -9,25 +9,25 @@ //! behavioural tests validating new functionality. End-to-end integration testing //! of the exclusion feature is not feasible with `dylint_testing` because: //! -//! 1. **Technical constraint**: The `dylint_testing` harness uses `CARGO_PKG_NAME` -//! at compile time, preventing fixture crates from having controlled names. +//! 1. **Technical constraint**: The `dylint_testing` harness uses `CARGO_PKG_NAME` at compile time, +//! preventing fixture crates from having controlled names. //! -//! 2. **Unit test coverage is sufficient**: The exclusion implementation is -//! straightforward—when `self.excluded` is true, `emit_optional` returns early -//! (see `driver.rs`). All configuration parsing, deserialization, and matching -//! logic is fully validated by the tests below. +//! 2. **Unit test coverage is sufficient**: The exclusion implementation is straightforward—when +//! `self.excluded` is true, `emit_optional` returns early (see `driver.rs`). All configuration +//! parsing, deserialization, and matching logic is fully validated by the tests below. //! -//! 3. **Behaviour is deterministic**: The `check_crate` method sets `self.excluded` -//! based on `config.is_excluded(crate_name)`, which is exhaustively tested here. +//! 3. **Behaviour is deterministic**: The `check_crate` method sets `self.excluded` based on +//! `config.is_excluded(crate_name)`, which is exhaustively tested here. //! //! The integration tests in `tests/integration_exclusion.rs` provide additional //! coverage by invoking `cargo dylint` on fixture projects with real exclusion //! configurations. -use super::*; +use std::{collections::HashSet, io}; + use rstest::rstest; -use std::collections::HashSet; -use std::io; + +use super::*; #[test] fn config_default_has_empty_excluded_crates() { @@ -40,8 +40,8 @@ fn config_default_has_empty_excluded_paths() { } #[rstest] -#[case::empty_config(r#""#, &[])] -#[case::empty_excluded(r#"excluded_crates = []"#, &[])] +#[case::empty_config(r"", &[])] +#[case::empty_excluded(r"excluded_crates = []", &[])] #[case::single_crate(r#"excluded_crates = ["foo"]"#, &["foo"])] #[case::multiple_crates(r#"excluded_crates = ["foo", "bar", "baz"]"#, &["foo", "bar", "baz"])] fn config_deserializes_excluded_crates(#[case] toml: &str, #[case] expected: &[&str]) { @@ -56,8 +56,8 @@ fn config_deserializes_excluded_crates(#[case] toml: &str, #[case] expected: &[& } #[rstest] -#[case::empty_config(r#""#, &[])] -#[case::empty_excluded(r#"excluded_paths = []"#, &[])] +#[case::empty_config(r"", &[])] +#[case::empty_excluded(r"excluded_paths = []", &[])] #[case::single_path(r#"excluded_paths = ["my_app::legacy_io"]"#, &["my_app::legacy_io"])] #[case::multiple_paths( r#"excluded_paths = ["my_app::legacy_io", "my_app::bin::migrate"]"#, @@ -104,7 +104,7 @@ fn legacy_config_without_excluded_paths_still_parses() { #[rstest] #[case::wrong_type(r#"excluded_paths = "not_an_array""#)] -#[case::wrong_element_type(r#"excluded_paths = [1, 2, 3]"#)] +#[case::wrong_element_type(r"excluded_paths = [1, 2, 3]")] #[case::mixed_element_types(r#"excluded_paths = ["my_app::legacy_io", 1]"#)] fn config_rejects_invalid_excluded_paths(#[case] toml: &str) { assert!( @@ -136,9 +136,9 @@ fn path_exclusions_reflect_configuration( } #[rstest] -#[case::unknown_field(r#"unknown_field = true"#)] +#[case::unknown_field(r"unknown_field = true")] #[case::wrong_type(r#"excluded_crates = "not_an_array""#)] -#[case::wrong_element_type(r#"excluded_crates = [1, 2, 3]"#)] +#[case::wrong_element_type(r"excluded_crates = [1, 2, 3]")] fn config_rejects_invalid_toml(#[case] toml: &str) { assert!( toml::from_str::(toml).is_err(), diff --git a/crates/no_std_fs_operations/src/diagnostics.rs b/crates/no_std_fs_operations/src/diagnostics.rs index 6ba5a22d..ee147ce4 100644 --- a/crates/no_std_fs_operations/src/diagnostics.rs +++ b/crates/no_std_fs_operations/src/diagnostics.rs @@ -1,31 +1,38 @@ //! Localized diagnostics for the `no_std_fs_operations` lint. -use crate::NO_STD_FS_OPERATIONS; -use crate::usage::StdFsUsage; +use std::borrow::Cow; + use rustc_lint::{LateContext, LintContext}; use rustc_span::Span; -use std::borrow::Cow; use whitaker_common::i18n::{ - Arguments, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, MessageResolution, - noop_reporter, safe_resolve_message_set, + Arguments, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + noop_reporter, + safe_resolve_message_set, }; #[cfg(test)] use whitaker_common::i18n::{BundleLookup, I18nError, resolve_message_set}; +use crate::{NO_STD_FS_OPERATIONS, usage::StdFsUsage}; + /// Emit a diagnostic for a detected `std::fs` usage. pub(crate) fn emit_diagnostic( cx: &LateContext<'_>, span: Span, - usage: StdFsUsage, + usage: &StdFsUsage, localizer: &Localizer, ) { let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("operation"), - FluentValue::from(usage.operation().to_string()), + FluentValue::from(usage.operation().to_owned()), ); - let fallback_operation = usage.operation().to_string(); + let fallback_operation = usage.operation().to_owned(); let resolution = MessageResolution { lint_name: "no_std_fs_operations", key: MESSAGE_KEY, @@ -40,9 +47,9 @@ pub(crate) fn emit_diagnostic( NO_STD_FS_OPERATIONS, span, rustc_lint::errors::DiagDecorator(move |lint| { - lint.primary_message(sanitize_message(messages.primary().to_string())); - lint.note(sanitize_message(messages.note().to_string())); - lint.help(sanitize_message(messages.help().to_string())); + lint.primary_message(sanitize_message(messages.primary())); + lint.note(sanitize_message(messages.note())); + lint.help(sanitize_message(messages.help())); }), ); } @@ -59,30 +66,31 @@ fn fallback_messages(operation: &str) -> StdFsMessages { "std::fs reads the ambient working directory, ", "so it bypasses the capability model enforced by cap-std and camino." ) - .to_string(); + .to_owned(); let help = concat!( - "Pass `cap_std::fs::Dir` handles and camino::Utf8Path/Utf8PathBuf arguments down to the call ", + "Pass `cap_std::fs::Dir` handles and camino::Utf8Path/Utf8PathBuf arguments down to the \ + call ", "so only explicit capabilities touch the filesystem." ) - .to_string(); + .to_owned(); DiagnosticMessageSet::new(primary, note, help) } -fn sanitize_message(text: String) -> String { +fn sanitize_message(text: &str) -> String { text.chars() .filter(|ch| !matches!(ch, '\u{2068}' | '\u{2069}')) .collect() } #[cfg(test)] -pub(crate) fn localised_messages( +pub(crate) fn localized_messages( lookup: &impl BundleLookup, operation: &str, ) -> Result { let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("operation"), - FluentValue::from(operation.to_string()), + FluentValue::from(operation.to_owned()), ); resolve_message_set(lookup, MESSAGE_KEY, &args) } @@ -95,13 +103,13 @@ mod tests { #[test] fn removes_isolation_marks() { - let raw = String::from("\u{2068}std::fs::read\u{2069}"); + let raw = "\u{2068}std::fs::read\u{2069}"; assert_eq!(sanitize_message(raw), "std::fs::read"); } #[test] fn preserves_clean_text() { - let raw = String::from("std::fs::File::open"); - assert_eq!(sanitize_message(raw.clone()), raw); + let raw = "std::fs::File::open"; + assert_eq!(sanitize_message(raw), raw); } } diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index 98515f4d..bf339cb1 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -1,12 +1,6 @@ //! Lint crate enforcing capability-based filesystem access by forbidding //! `std::fs` operations. -use crate::config::{LINT_NAME, load_configuration}; -use crate::diagnostics::emit_diagnostic; -use crate::exclusion::PathExclusions; -use crate::usage::{ - StdFsUsage, UsageCategory, classify_def_id, classify_qpath, classify_res, label_is_std_fs, -}; use log::{debug, info}; use rustc_hir as hir; use rustc_hir::AmbigArg; @@ -14,10 +8,27 @@ use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty; use rustc_span::{Span, sym}; use whitaker::SharedConfig; -use whitaker_common::SimplePath; -use whitaker_common::i18n::Localizer; -use whitaker_common::i18n::get_localizer_for_lint; +use whitaker_common::{ + SimplePath, + i18n::{Localizer, get_localizer_for_lint}, +}; +use crate::{ + config::{LINT_NAME, load_configuration}, + diagnostics::emit_diagnostic, + exclusion::PathExclusions, + usage::{ + StdFsUsage, + UsageCategory, + classify_def_id, + classify_qpath, + classify_res, + label_is_std_fs, + }, +}; + +/// Lint pass that tracks localization and exclusion state while checking +/// `std::fs` usage. pub struct NoStdFsOperations { localizer: Localizer, excluded: bool, @@ -34,13 +45,31 @@ impl Default for NoStdFsOperations { } } -dylint_linting::impl_late_lint! { - pub NO_STD_FS_OPERATIONS, - Deny, - "std::fs operations bypass Whitaker's capability-based filesystem policy", - NoStdFsOperations::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::NoStdFsOperations; + + dylint_linting::impl_late_lint! { + /// Denies direct `std::fs` use that bypasses the capability policy. + pub NO_STD_FS_OPERATIONS, + Deny, + "std::fs operations bypass Whitaker's capability-based filesystem policy", + NoStdFsOperations::default() + } } +pub use declaration::NO_STD_FS_OPERATIONS; + impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { fn check_crate(&mut self, cx: &LateContext<'tcx>) { let shared_config = SharedConfig::load(); @@ -106,7 +135,7 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { .and_then(|def_id| classify_def_id(cx, def_id, UsageCategory::Call)); if usage.is_none() { - usage = self.receiver_usage_for_method(cx, receiver, segment.ident.as_str()); + usage = Self::receiver_usage_for_method(cx, receiver, segment.ident.as_str()); } self.emit_optional(cx, site, usage); @@ -147,15 +176,13 @@ struct LintSite { impl NoStdFsOperations { /// Centralizes exclusion logic for all lint pass methods. #[inline] - fn should_skip(&self) -> bool { - self.excluded - } + const fn should_skip(&self) -> bool { self.excluded } fn emit_optional(&self, cx: &LateContext<'_>, site: LintSite, usage: Option) { if self.should_skip() { return; } - let Some(usage) = usage else { + let Some(detected) = usage else { return; }; // Resolve the enclosing item's path only for genuine `std::fs` hits @@ -164,7 +191,7 @@ impl NoStdFsOperations { if self.is_path_excluded(cx, site.hir_id) { return; } - self.emit(cx, site.span, usage); + self.emit(cx, site.span, &detected); } /// Returns `true` when the item enclosing `hir_id` falls within a @@ -177,12 +204,11 @@ impl NoStdFsOperations { .excludes(&enclosing_item_path(cx, hir_id)) } - fn emit(&self, cx: &LateContext<'_>, span: Span, usage: StdFsUsage) { + fn emit(&self, cx: &LateContext<'_>, span: Span, usage: &StdFsUsage) { emit_diagnostic(cx, span, usage, &self.localizer); } fn receiver_usage_for_method( - &self, cx: &LateContext<'_>, receiver: &hir::Expr<'_>, method: &str, diff --git a/crates/no_std_fs_operations/src/exclusion.rs b/crates/no_std_fs_operations/src/exclusion.rs index e8861b85..eb232e7f 100644 --- a/crates/no_std_fs_operations/src/exclusion.rs +++ b/crates/no_std_fs_operations/src/exclusion.rs @@ -6,11 +6,13 @@ //! ordinary unit and behavioural tests; the driver supplies the enclosing item //! path resolved from the HIR. -use crate::config::LINT_NAME; -use log::warn; use std::collections::HashSet; + +use log::warn; use whitaker_common::SimplePath; +use crate::config::LINT_NAME; + /// Maximum length of a malformed entry echoed into a warning, so a pathological /// configuration value cannot produce an unbounded log line. const MAX_LOGGED_ENTRY_LEN: usize = 64; @@ -67,9 +69,7 @@ impl PathExclusions { /// /// The driver consults this before resolving an item's path so the common /// case pays no lookup cost. - pub(crate) fn is_empty(&self) -> bool { - self.prefixes.is_empty() - } + pub(crate) const fn is_empty(&self) -> bool { self.prefixes.is_empty() } /// Returns `true` when `item_path` falls within a configured exclusion. /// @@ -77,10 +77,9 @@ impl PathExclusions { /// `std::fs` usage. pub(crate) fn excludes(&self, item_path: &SimplePath) -> bool { let item = item_path.segments(); - self.prefixes.iter().any(|prefix| { - let prefix = prefix.segments(); - prefix.len() <= item.len() && item[..prefix.len()] == *prefix - }) + self.prefixes + .iter() + .any(|prefix| item.starts_with(prefix.segments())) } } @@ -105,7 +104,8 @@ fn bounded_entry(entry: &str) -> String { while !entry.is_char_boundary(end) { end -= 1; } - format!("{:?}… ({} bytes total)", &entry[..end], entry.len()) + let truncated = entry.get(..end).unwrap_or(entry); + format!("{truncated:?}… ({} bytes total)", entry.len()) } #[cfg(test)] @@ -114,12 +114,14 @@ mod tests { //! prefix matching (example-based and property-based), and the bounded //! rendering used when warning about rejected entries. - use super::PathExclusions; + use std::collections::HashSet; + use proptest::prelude::*; use rstest::rstest; - use std::collections::HashSet; use whitaker_common::SimplePath; + use super::PathExclusions; + fn exclusions(paths: &[&str]) -> PathExclusions { PathExclusions::new( &paths @@ -222,9 +224,7 @@ mod tests { // item paths that share segment *text* with a prefix but differ at a segment // boundary — exactly the `a::b` vs `a::bc` hazard segment-wise matching must // reject. - fn segment() -> impl Strategy { - "[a-c]{1,3}" - } + fn segment() -> impl Strategy { "[a-c]{1,3}" } proptest! { #[test] @@ -247,9 +247,9 @@ mod tests { // Oracle: excluded iff some configured prefix is a genuine // segment-wise prefix of the item path. - let expected = configured.iter().any(|prefix| { - prefix.len() <= item.len() && item[..prefix.len()] == prefix[..] - }); + let expected = configured + .iter() + .any(|prefix| item.starts_with(prefix.as_slice())); prop_assert_eq!(exclusions.excludes(&item_path), expected); } diff --git a/crates/no_std_fs_operations/src/exclusion_behaviour.rs b/crates/no_std_fs_operations/src/exclusion_behaviour.rs index e77e9ab8..2eeb5c2f 100644 --- a/crates/no_std_fs_operations/src/exclusion_behaviour.rs +++ b/crates/no_std_fs_operations/src/exclusion_behaviour.rs @@ -3,13 +3,14 @@ //! These scenarios exercise the pure `PathExclusions` decision the driver makes //! for each detected `std::fs` usage, without needing a live `rustc` session. -use crate::exclusion::PathExclusions; +use std::{cell::RefCell, collections::HashSet}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; -use std::collections::HashSet; use whitaker_common::SimplePath; +use crate::exclusion::PathExclusions; + #[derive(Default)] struct ExclusionWorld { excluded_paths: HashSet, @@ -17,9 +18,7 @@ struct ExclusionWorld { } impl ExclusionWorld { - fn exclude_path(&mut self, path: &str) { - self.excluded_paths.insert(path.to_owned()); - } + fn exclude_path(&mut self, path: &str) { self.excluded_paths.insert(path.to_owned()); } fn evaluate(&mut self, item_path: &str) { let exclusions = PathExclusions::new(&self.excluded_paths); @@ -27,17 +26,18 @@ impl ExclusionWorld { } fn suppressed(&self) -> bool { - self.suppressed - .expect("a usage should have been evaluated before asserting") + let Some(suppressed) = self.suppressed else { + panic!("a usage should have been evaluated before asserting") + }; + suppressed } } type WorldCell = RefCell; +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> WorldCell { - RefCell::new(ExclusionWorld::default()) -} +fn world() -> WorldCell { RefCell::new(ExclusionWorld::default()) } #[given("the module path {path} is excluded")] fn given_excluded_path(world: &WorldCell, path: String) { @@ -45,9 +45,7 @@ fn given_excluded_path(world: &WorldCell, path: String) { } #[given("no module paths are excluded")] -fn given_no_excluded_paths(world: &WorldCell) { - world.borrow_mut().excluded_paths.clear(); -} +fn given_no_excluded_paths(world: &WorldCell) { world.borrow_mut().excluded_paths.clear(); } #[when("a std::fs usage is found in item {item}")] fn when_usage_found(world: &WorldCell, item: String) { diff --git a/crates/no_std_fs_operations/src/lib.rs b/crates/no_std_fs_operations/src/lib.rs index 0be3f30c..eb83a36d 100644 --- a/crates/no_std_fs_operations/src/lib.rs +++ b/crates/no_std_fs_operations/src/lib.rs @@ -20,8 +20,13 @@ mod tests; mod usage; #[cfg(feature = "dylint-driver")] pub use config::NoStdFsConfig; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{NO_STD_FS_OPERATIONS, NoStdFsOperations}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/no_std_fs_operations/src/tests/ui.rs b/crates/no_std_fs_operations/src/tests/ui.rs index cc86012c..b0e05e71 100644 --- a/crates/no_std_fs_operations/src/tests/ui.rs +++ b/crates/no_std_fs_operations/src/tests/ui.rs @@ -1,33 +1,26 @@ //! UI regression tests for the `no_std_fs_operations` lint. use serial_test::serial; -use whitaker_common::test_support::LocaleOverride; +use whitaker_common::test_support::with_locale; #[test] #[serial] -fn ui() { - run_with_locale("ui", None); -} +fn ui() { run_with_locale("ui", None); } #[test] #[serial] -fn ui_runs_in_welsh() { - run_with_locale("ui-cy", Some("cy")); -} +fn ui_runs_in_welsh() { run_with_locale("ui-cy", Some("cy")); } #[test] #[serial] -fn ui_runs_in_gaelic() { - run_with_locale("ui-gd", Some("gd")); -} +fn ui_runs_in_gaelic() { run_with_locale("ui-gd", Some("gd")); } #[test] #[serial] -fn ui_runs_in_fallback_locale() { - run_with_locale("ui-fallback", Some("zz")); -} +fn ui_runs_in_fallback_locale() { run_with_locale("ui-fallback", Some("zz")); } fn run_with_locale(directory: &str, locale: Option<&str>) { - let _locale_guard = locale.map(LocaleOverride::set); - whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + with_locale(locale, || { + whitaker::run_ui_tests!(directory).expect("UI tests should execute without diffs"); + }); } diff --git a/crates/no_std_fs_operations/src/usage.rs b/crates/no_std_fs_operations/src/usage/mod.rs similarity index 88% rename from crates/no_std_fs_operations/src/usage.rs rename to crates/no_std_fs_operations/src/usage/mod.rs index f2790260..8cfd243c 100644 --- a/crates/no_std_fs_operations/src/usage.rs +++ b/crates/no_std_fs_operations/src/usage/mod.rs @@ -1,8 +1,7 @@ //! Classifies `std::fs` usages encountered by the lint into diagnostic inputs. use rustc_hir as hir; -use rustc_hir::def::Res; -use rustc_hir::def_id::DefId; +use rustc_hir::{def::Res, def_id::DefId}; use rustc_lint::LateContext; use rustc_span::sym; use whitaker_common::SimplePath; @@ -49,7 +48,7 @@ impl StdFsUsage { /// let usage = StdFsUsage::new(String::from("std::fs::read"), UsageCategory::Call); /// assert_eq!(usage.operation(), "std::fs::read"); /// ``` - pub fn new(operation: String, category: UsageCategory) -> Self { + pub const fn new(operation: String, category: UsageCategory) -> Self { Self { operation, category, @@ -66,16 +65,12 @@ impl StdFsUsage { /// let usage = StdFsUsage::new(String::from("std::fs::remove_file"), UsageCategory::Call); /// assert_eq!(usage.operation(), "std::fs::remove_file"); /// ``` - pub fn operation(&self) -> &str { - &self.operation - } + pub fn operation(&self) -> &str { &self.operation } /// Returns the usage category. #[cfg(test)] #[must_use] - pub const fn category(&self) -> UsageCategory { - self.category - } + pub const fn category(&self) -> UsageCategory { self.category } } /// Classify a resolved path (expression, type, import) into a usage record. @@ -147,14 +142,11 @@ pub fn classify_def_id( } fn is_std_fs_path(path: &SimplePath) -> bool { - let segments = path.segments(); - segments.len() >= 2 && segments[0] == "std" && segments[1] == "fs" + matches!(path.segments(), [first, second, ..] if first == "std" && second == "fs") } -/// Returns true if the character should be rejected in a valid std::fs label. -fn is_invalid_label_char(ch: char) -> bool { - ch.is_whitespace() || matches!(ch, '(' | ')') -} +/// Returns true if the character should be rejected in a valid `std::fs` label. +const fn is_invalid_label_char(ch: char) -> bool { ch.is_whitespace() || matches!(ch, '(' | ')') } pub(crate) fn label_is_std_fs(label: &str) -> bool { if label != label.trim() { @@ -165,11 +157,9 @@ pub(crate) fn label_is_std_fs(label: &str) -> bool { return false; } - if !label.starts_with("std::fs") { + let Some(remainder) = label.strip_prefix("std::fs") else { return false; - } - - let remainder = &label["std::fs".len()..]; + }; if remainder.is_empty() { return true; } diff --git a/crates/no_std_fs_operations/src/usage/tests.rs b/crates/no_std_fs_operations/src/usage/tests.rs index d7c9b794..d0680b7a 100644 --- a/crates/no_std_fs_operations/src/usage/tests.rs +++ b/crates/no_std_fs_operations/src/usage/tests.rs @@ -1,7 +1,8 @@ -//! Tests for classifying std::fs usage and its reporting metadata. -use super::{StdFsUsage, UsageCategory, label_is_std_fs}; +//! Tests for classifying `std::fs` usage and its reporting metadata. use rstest::rstest; +use super::{StdFsUsage, UsageCategory, label_is_std_fs}; + #[rstest] #[case("std::fs", true)] #[case("std::fs::File::open", true)] @@ -21,7 +22,7 @@ use rstest::rstest; #[case("fs::std", false)] #[case("std::", false)] #[case("std::filesystem", false)] -fn recognises_std_fs_paths(#[case] path: &str, #[case] expected: bool) { +fn recognizes_std_fs_paths(#[case] path: &str, #[case] expected: bool) { assert_eq!(label_is_std_fs(path), expected); } diff --git a/crates/no_std_fs_operations/tests/features/localization.feature b/crates/no_std_fs_operations/tests/features/localization.feature index 41ac1d82..de937654 100644 --- a/crates/no_std_fs_operations/tests/features/localization.feature +++ b/crates/no_std_fs_operations/tests/features/localization.feature @@ -1,9 +1,9 @@ -Feature: Localised diagnostics for std::fs usage +Feature: Localized diagnostics for std::fs usage Scenario: English messaging encourages cap-std Given the locale "en-GB" is selected And the operation is "std::fs::read_to_string" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::read_to_string" And the note references "cap_std::fs::Dir" And the help references "camino::Utf8Path" @@ -11,26 +11,26 @@ Feature: Localised diagnostics for std::fs usage Scenario: Welsh messaging reflects the capability note Given the locale "cy" is selected And the operation is "std::fs::remove_file" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::remove_file" And the note references "cyfeiriadur" Scenario: Scottish Gaelic messaging mirrors the policy Given the locale "gd" is selected And the operation is "std::fs::metadata" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::metadata" And the note references "neach-gairm" Scenario: Unsupported locale falls back to English Given the locale "zz" is selected And the operation is "std::fs::read_dir" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::read_dir" And the help references "cap_std::fs::Dir" Scenario: Localization failure surfaces the missing key Given localization fails And the operation is "std::fs::canonicalize" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then localization fails for "no_std_fs_operations" diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 0741591e..e40a8882 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -12,11 +12,13 @@ //! These tests are marked `#[ignore]` by default because they require external //! dependencies. Run with `--ignored` to execute. -use std::env; -use std::io::Cursor; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::OnceLock; +use std::{ + env, + io::Cursor, + path::{Path, PathBuf}, + process::Command, + sync::OnceLock, +}; use anyhow::Context as _; use cargo_metadata::{Message, Metadata, MetadataCommand}; @@ -124,8 +126,8 @@ fn find_cdylib_in_artefacts( stdout: &[u8], package_id: &cargo_metadata::PackageId, ) -> anyhow::Result { - for message in Message::parse_stream(Cursor::new(stdout)) { - let message = message.context("failed to parse cargo build JSON output")?; + for parsed in Message::parse_stream(Cursor::new(stdout)) { + let message = parsed.context("failed to parse cargo build JSON output")?; let Message::CompilerArtifact(artefact) = message else { continue; }; @@ -198,7 +200,7 @@ fn diagnostic_count(output: &[u8]) -> Result { Ok(messages .into_iter() .filter_map(|message| match message { - Message::CompilerMessage(message) => Some(message.message), + Message::CompilerMessage(compiler_message) => Some(compiler_message.message), _ => None, }) .filter(|diagnostic| { @@ -219,12 +221,12 @@ fn redact_path_prefix(value: serde_json::Value, prefix: &str) -> serde_json::Val } serde_json::Value::Array(arr) => serde_json::Value::Array( arr.into_iter() - .map(|value| redact_path_prefix(value, prefix)) + .map(|element| redact_path_prefix(element, prefix)) .collect(), ), serde_json::Value::Object(map) => serde_json::Value::Object( map.into_iter() - .map(|(key, value)| (key, redact_path_prefix(value, prefix))) + .map(|(key, entry)| (key, redact_path_prefix(entry, prefix))) .collect(), ), other => other, @@ -234,7 +236,8 @@ fn redact_path_prefix(value: serde_json::Value, prefix: &str) -> serde_json::Val #[expect( clippy::useless_asref, clippy::redundant_closure, - reason = "anyhow::Error is not Clone, so .as_ref().map(Clone::clone) is necessary to convert &Result into Result" + reason = "anyhow::Error is not Clone, so .as_ref().map(Clone::clone) is necessary to convert \ + &Result into Result" )] fn lint_library_path() -> anyhow::Result { static LINT_LIBRARY_PATH: OnceLock> = OnceLock::new(); @@ -246,6 +249,7 @@ fn lint_library_path() -> anyhow::Result { .map_err(|e| anyhow::anyhow!("{e:#}")) } +#[derive(Clone, Copy)] struct Expectation { should_emit_diagnostics: bool, should_succeed: bool, @@ -355,19 +359,19 @@ fn assert_fixture_behaviour( ) -> anyhow::Result<()> { let (is_success, count) = evaluate_fixture(fixture_dir, lint_library_path, crate_name)?; - assert!( + anyhow::ensure!( is_success == expectation.should_succeed, "crate `{crate_name}` should return success={}", expectation.should_succeed ); if expectation.should_emit_diagnostics { - assert!( + anyhow::ensure!( count > 0, "crate `{crate_name}` should emit `no_std_fs_operations` diagnostics" ); } else { - assert!( + anyhow::ensure!( count == 0, "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics" ); @@ -400,7 +404,8 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { .collect::, _>>() .with_context(|| { format!( - "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo output\nstderr:\n{}", + "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo \ + output\nstderr:\n{}", result.stderr ) })?; @@ -408,16 +413,16 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { let diagnostics: Vec = messages .into_iter() .filter_map(|message| match message { - Message::CompilerMessage(message) - if message + Message::CompilerMessage(compiler_message) + if compiler_message .message .code .as_ref() .is_some_and(|code| code.code == LINT_CRATE_NAME) => { Some( - serde_json::to_value(message.message) - .context("failed to serialise diagnostic for snapshot"), + serde_json::to_value(compiler_message.message) + .context("failed to serialize diagnostic for snapshot"), ) } _ => None, diff --git a/crates/no_std_fs_operations/tests/test_support/mod.rs b/crates/no_std_fs_operations/tests/test_support/mod.rs index 55778e5b..84079d74 100644 --- a/crates/no_std_fs_operations/tests/test_support/mod.rs +++ b/crates/no_std_fs_operations/tests/test_support/mod.rs @@ -1,8 +1,10 @@ //! Shared, test-only fixture helpers for `no_std_fs_operations` integration //! tests. -use std::fs; -use std::path::{Path, PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use anyhow::Context as _; use tempfile::TempDir; @@ -15,9 +17,7 @@ pub(super) struct FixtureProject { impl FixtureProject { /// Returns the fixture project root directory. - pub(super) fn root(&self) -> &Path { - &self.root - } + pub(super) fn root(&self) -> &Path { &self.root } } /// Selects which suppression mechanism a fixture exercises. @@ -35,7 +35,7 @@ pub(super) enum FixtureKind { impl FixtureKind { /// Short label naming the mechanism, used in error context. - pub(super) fn label(self) -> &'static str { + pub(super) const fn label(self) -> &'static str { match self { Self::CrateExclusion => "crate", Self::PathExclusion => "path", @@ -220,12 +220,13 @@ mod tests { let config = fixture_dylint_config(crate_name, FixtureKind::CrateExclusion, true); let parsed: toml::Value = toml::from_str(&config).expect("config should parse as TOML"); - assert_eq!( - parsed["no_std_fs_operations"]["excluded_crates"][0] - .as_str() - .expect("excluded crate should be a string"), - crate_name - ); + let excluded_crate = parsed + .get("no_std_fs_operations") + .and_then(|section| section.get("excluded_crates")) + .and_then(|entries| entries.get(0)) + .and_then(toml::Value::as_str) + .expect("excluded crate should be a string"); + assert_eq!(excluded_crate, crate_name); assert!(parsed.get("other").is_none(), "config was:\n{config}"); assert!(parsed.get("injected").is_none(), "config was:\n{config}"); } @@ -237,14 +238,17 @@ mod tests { let manifest = std::fs::read_to_string(fixture.root().join("Cargo.toml"))?; let parsed: toml::Value = toml::from_str(&manifest)?; - assert_eq!( - parsed["package"]["name"] - .as_str() - .expect("package name should be a string"), - crate_name + let package_name = parsed + .get("package") + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .expect("package name should be a string"); + anyhow::ensure!( + package_name == crate_name, + "manifest package name should round-trip, manifest was:\n{manifest}" ); - assert!(parsed.get("other").is_none(), "manifest was:\n{manifest}"); - assert!( + anyhow::ensure!(parsed.get("other").is_none(), "manifest was:\n{manifest}"); + anyhow::ensure!( parsed.get("injected").is_none(), "manifest was:\n{manifest}" ); @@ -257,12 +261,13 @@ mod tests { let config = fixture_dylint_config("my_app", FixtureKind::PathExclusion, true); let parsed: toml::Value = toml::from_str(&config).expect("config should parse as TOML"); - assert_eq!( - parsed["no_std_fs_operations"]["excluded_paths"][0] - .as_str() - .expect("excluded path should be a string"), - "my_app::guarded" - ); + let excluded_path = parsed + .get("no_std_fs_operations") + .and_then(|section| section.get("excluded_paths")) + .and_then(|entries| entries.get(0)) + .and_then(toml::Value::as_str) + .expect("excluded path should be a string"); + assert_eq!(excluded_path, "my_app::guarded"); } #[test] @@ -270,11 +275,14 @@ mod tests { let config = fixture_dylint_config("my_app", FixtureKind::PathExclusion, false); let parsed: toml::Value = toml::from_str(&config).expect("config should parse as TOML"); + let excluded_paths = parsed + .get("no_std_fs_operations") + .and_then(|section| section.get("excluded_paths")) + .and_then(toml::Value::as_array) + .expect("excluded_paths should be an array"); assert!( - parsed["no_std_fs_operations"]["excluded_paths"] - .as_array() - .expect("excluded_paths should be an array") - .is_empty() + excluded_paths.is_empty(), + "no paths should be excluded, config was:\n{config}" ); } @@ -285,22 +293,20 @@ mod tests { let source = std::fs::read_to_string(fixture.root().join("src/lib.rs"))?; // The fixture must declare the module the config excludes. - let guarded_start = source.find("pub mod guarded").unwrap_or_else(|| { - panic!("fixture should declare a guarded module, source was:\n{source}") - }); + let Some(guarded_start) = source.find("pub mod guarded") else { + anyhow::bail!("fixture should declare a guarded module, source was:\n{source}"); + }; // The `std::fs` usage must be the *only* one, and must sit inside the // guarded module. Otherwise a passing exclusion test could reflect an // accidental global suppression (or a stray crate-root usage) rather // than genuine module-scoped suppression. let fs_usages: Vec = source.match_indices("std::fs").map(|(i, _)| i).collect(); - assert_eq!( - fs_usages.len(), - 1, - "expected exactly one std::fs usage, source was:\n{source}" - ); - assert!( - fs_usages[0] > guarded_start, + let [fs_usage] = fs_usages.as_slice() else { + anyhow::bail!("expected exactly one std::fs usage, source was:\n{source}"); + }; + anyhow::ensure!( + *fs_usage > guarded_start, "the std::fs usage must sit inside the guarded module, source was:\n{source}" ); Ok(()) diff --git a/crates/no_unwrap_or_else_panic/Cargo.toml b/crates/no_unwrap_or_else_panic/Cargo.toml index 2b30b4ec..c390d311 100644 --- a/crates/no_unwrap_or_else_panic/Cargo.toml +++ b/crates/no_unwrap_or_else_panic/Cargo.toml @@ -39,6 +39,7 @@ whitaker = { workspace = true, features = ["dylint-driver"], optional = true } clippy_utils = { workspace = true, optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } camino = { workspace = true } dylint_testing = { workspace = true } rstest = { workspace = true } @@ -46,6 +47,5 @@ rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } temp-env = { workspace = true } -[lints.clippy] -expect_used = "deny" -unwrap_used = "deny" +[lints] +workspace = true diff --git a/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs b/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs index 36fd1636..8315dc7d 100644 --- a/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs +++ b/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs @@ -35,7 +35,7 @@ fn parse() { /// Const-only sibling module used as a fixture. /// /// Verifies that `collect_rstest_companion_test_functions` does not mistake an -/// arbitrary `const`-only module for a synthesised rstest harness descriptor. +/// arbitrary `const`-only module for a synthesized rstest harness descriptor. /// A genuine rstest companion module contains `#[test]` functions generated /// by the proc-macro; a module containing only `pub const` items must never /// be treated as one. diff --git a/crates/no_unwrap_or_else_panic/src/context.rs b/crates/no_unwrap_or_else_panic/src/context/mod.rs similarity index 83% rename from crates/no_unwrap_or_else_panic/src/context.rs rename to crates/no_unwrap_or_else_panic/src/context/mod.rs index ffc03bd6..7c3620b5 100644 --- a/crates/no_unwrap_or_else_panic/src/context.rs +++ b/crates/no_unwrap_or_else_panic/src/context/mod.rs @@ -31,10 +31,7 @@ use rustc_span::sym; /// Summarize the context for a given HIR node. #[cfg(feature = "dylint-driver")] -pub(crate) fn summarise_context<'tcx>( - cx: &LateContext<'tcx>, - hir_id: hir::HirId, -) -> ContextSummary { +pub(crate) fn summarize_context(cx: &LateContext<'_>, hir_id: hir::HirId) -> ContextSummary { let mut entries = Vec::new(); let mut has_cfg_test = false; @@ -69,7 +66,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option Some(ContextEntry::new( - "impl".to_string(), + "impl".to_owned(), ContextKind::Impl, convert_attributes(attrs), )), @@ -90,7 +87,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option None, }, Node::Block(_) => Some(ContextEntry::new( - "block".to_string(), + "block".to_owned(), ContextKind::Block, convert_attributes(attrs), )), @@ -118,10 +115,10 @@ fn convert_attribute(attr: &hir::Attribute) -> Attribute { return Attribute::new(AttributePath::from(PARSED_ATTRIBUTE_PLACEHOLDER), kind); }; let mut names = attr.path().into_iter().map(|symbol| symbol.to_string()); - match names.next() { - Some(first) => AttributePath::new(std::iter::once(first).chain(names)), - None => AttributePath::from("unknown"), - } + names.next().map_or_else( + || AttributePath::from("unknown"), + |first| AttributePath::new(std::iter::once(first).chain(names)), + ) }; Attribute::new(path, kind) @@ -132,7 +129,7 @@ fn attribute_style(attr: &hir::Attribute) -> AttrStyle { match attr { hir::Attribute::Unparsed(item) => item.style, hir::Attribute::Parsed(HirAttributeKind::DocComment { style, .. }) => *style, - _ => AttrStyle::Outer, + hir::Attribute::Parsed(_) => AttrStyle::Outer, } } @@ -152,24 +149,21 @@ fn is_cfg_test_attribute(attr: &hir::Attribute) -> bool { }; let path = attr.path(); - if path.len() != 1 { + let [name] = path.as_slice() else { return false; - } + }; - if path[0] == sym::cfg { + if *name == sym::cfg { return attr .meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false); + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)); } - if path[0] != sym::cfg_attr { + if *name != sym::cfg_attr { return false; } - attr.meta_item_list() - .map(check_cfg_attr_for_test) - .unwrap_or(false) + attr.meta_item_list().is_some_and(check_cfg_attr_for_test) } #[cfg(feature = "dylint-driver")] @@ -212,32 +206,27 @@ fn meta_contains_test_with_polarity(meta: &MetaItem, is_positive: bool) -> bool } if path_is_ident(&meta.path, sym::not) { - return meta - .meta_item_list() - .map(|items| { - items - .iter() - .cloned() - .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) - }) - .unwrap_or(false); + return meta.meta_item_list().is_some_and(|items| { + items + .iter() + .cloned() + .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) + }); } meta.meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false) + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)) } #[cfg(feature = "dylint-driver")] fn meta_contains_test_cfg(meta: &MetaItem) -> bool { meta.meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false) + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)) } #[cfg(feature = "dylint-driver")] fn path_is_ident(path: &AstPath, ident: rustc_span::Symbol) -> bool { - path.segments.len() == 1 && path.segments[0].ident.name == ident + matches!(&*path.segments, [segment] if segment.ident.name == ident) } #[cfg(test)] diff --git a/crates/no_unwrap_or_else_panic/src/context/tests.rs b/crates/no_unwrap_or_else_panic/src/context/tests.rs index 20a410c8..6041e869 100644 --- a/crates/no_unwrap_or_else_panic/src/context/tests.rs +++ b/crates/no_unwrap_or_else_panic/src/context/tests.rs @@ -3,8 +3,6 @@ //! Verifies that `convert_attribute` and `is_cfg_test_attribute` handle //! parsed attributes (e.g., `#[must_use]`) without panicking. -#[cfg(feature = "dylint-driver")] -use super::{convert_attribute, is_cfg_test_attribute}; #[cfg(feature = "dylint-driver")] use rustc_hir as hir; #[cfg(feature = "dylint-driver")] @@ -14,6 +12,9 @@ use rustc_span::DUMMY_SP; #[cfg(feature = "dylint-driver")] use whitaker_common::{AttributeKind, PARSED_ATTRIBUTE_PLACEHOLDER}; +#[cfg(feature = "dylint-driver")] +use super::{convert_attribute, is_cfg_test_attribute}; + /// Verify that `convert_attribute` handles parsed attributes without panicking. /// /// Parsed attributes (e.g., `#[must_use]`) are pre-processed by rustc and don't @@ -31,7 +32,7 @@ fn convert_attribute_handles_parsed_must_use() { // Should return a placeholder "parsed" path instead of panicking. assert_eq!( attribute.path().segments(), - &[PARSED_ATTRIBUTE_PLACEHOLDER.to_string()] + &[PARSED_ATTRIBUTE_PLACEHOLDER.to_owned()] ); assert_eq!(attribute.kind(), AttributeKind::Outer); } diff --git a/crates/no_unwrap_or_else_panic/src/diagnostics.rs b/crates/no_unwrap_or_else_panic/src/diagnostics.rs index 97ea2d72..a7515d0e 100644 --- a/crates/no_unwrap_or_else_panic/src/diagnostics.rs +++ b/crates/no_unwrap_or_else_panic/src/diagnostics.rs @@ -1,14 +1,22 @@ //! Diagnostic emission for the lint, including localization fallbacks. -use crate::{LINT_NAME, NO_UNWRAP_OR_ELSE_PANIC}; +use std::borrow::Cow; + use rustc_hir as hir; use rustc_lint::{LateContext, LintContext}; -use std::borrow::Cow; use whitaker_common::i18n::{ - Arguments, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, MessageResolution, - noop_reporter, safe_resolve_message_set, + Arguments, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + noop_reporter, + safe_resolve_message_set, }; +use crate::{LINT_NAME, NO_UNWRAP_OR_ELSE_PANIC}; + const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); /// Emit the lint diagnostic using localized messages. @@ -48,9 +56,9 @@ pub(crate) fn emit_diagnostic( NO_UNWRAP_OR_ELSE_PANIC, expr.span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); - lint.span_note(receiver.span, messages.note().to_string()); - lint.help(messages.help().to_string()); + lint.primary_message(messages.primary().to_owned()); + lint.span_note(receiver.span, messages.note().to_owned()); + lint.help(messages.help().to_owned()); }), ); } diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index 2260f891..920d5358 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -1,26 +1,48 @@ //! Lint wiring that flags panicking `unwrap_or_else` fallbacks. -use crate::LINT_NAME; -use crate::context::ContextSummary; -use crate::diagnostics::emit_diagnostic; -use crate::panic_detector::{closure_panics, receiver_is_option_or_result}; -use crate::policy::{LintPolicy, should_flag}; +use std::collections::HashSet; + use log::debug; use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; use serde::Deserialize; -use std::collections::HashSet; use whitaker::SharedConfig; use whitaker_common::i18n::{Localizer, get_localizer_for_lint}; -dylint_linting::impl_late_lint! { - pub NO_UNWRAP_OR_ELSE_PANIC, - Deny, - "forbid `unwrap_or_else` whose closure panics (directly or via unwrap/expect)", - NoUnwrapOrElsePanic::default() +use crate::{ + LINT_NAME, + context::ContextSummary, + diagnostics::emit_diagnostic, + panic_detector::{closure_panics, receiver_is_option_or_result}, + policy::{LintPolicy, should_flag}, +}; + +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::NoUnwrapOrElsePanic; + + dylint_linting::impl_late_lint! { + /// Denies `unwrap_or_else` fallbacks whose closure panics. + pub NO_UNWRAP_OR_ELSE_PANIC, + Deny, + "forbid `unwrap_or_else` whose closure panics (directly or via unwrap/expect)", + NoUnwrapOrElsePanic::default() + } } +pub use declaration::NO_UNWRAP_OR_ELSE_PANIC; + #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct Config { @@ -28,9 +50,7 @@ struct Config { } impl Config { - fn resolved_allow_in_main(&self) -> bool { - self.allow_in_main.unwrap_or(false) - } + fn resolved_allow_in_main(&self) -> bool { self.allow_in_main.unwrap_or(false) } } /// Lint pass that inspects `unwrap_or_else` fallbacks for panics. @@ -98,7 +118,7 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { return; }; - let summary = summarise_context_with_harness( + let summary = summarize_context_with_harness( cx, expr.hir_id, self.is_test_harness, @@ -106,7 +126,7 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { ); let panic_info = closure_panics(cx, body_id); - if !should_flag(&self.policy, &summary, &panic_info, self.is_doctest) { + if !should_flag(self.policy, summary, panic_info, self.is_doctest) { return; } @@ -114,8 +134,8 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { } } -fn is_inside_harness_test_function<'tcx>( - cx: &LateContext<'tcx>, +fn is_inside_harness_test_function( + cx: &LateContext<'_>, hir_id: hir::HirId, harness_test_functions: &HashSet, ) -> bool { @@ -134,20 +154,20 @@ fn is_inside_harness_test_function<'tcx>( /// Summarizes the lint context for an expression, merging attribute-based and /// harness-based test detection into a single immutable result. -fn summarise_context_with_harness<'tcx>( - cx: &LateContext<'tcx>, +fn summarize_context_with_harness( + cx: &LateContext<'_>, hir_id: hir::HirId, is_test_harness: bool, harness_test_functions: &HashSet, ) -> ContextSummary { - let mut summary = crate::context::summarise_context(cx, hir_id); + let mut summary = crate::context::summarize_context(cx, hir_id); if !summary.is_test && is_test_harness { summary.is_test = is_inside_harness_test_function(cx, hir_id, harness_test_functions); } summary } -fn closure_body(expr: &hir::Expr<'_>) -> Option { +const fn closure_body(expr: &hir::Expr<'_>) -> Option { match expr.kind { ExprKind::Closure(hir::Closure { body, .. }) => Some(*body), _ => None, diff --git a/crates/no_unwrap_or_else_panic/src/lib.rs b/crates/no_unwrap_or_else_panic/src/lib.rs index 7407c8d8..c862c6fa 100644 --- a/crates/no_unwrap_or_else_panic/src/lib.rs +++ b/crates/no_unwrap_or_else_panic/src/lib.rs @@ -23,8 +23,13 @@ mod panic_detector; #[cfg(feature = "dylint-driver")] mod policy; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{NO_UNWRAP_OR_ELSE_PANIC, NoUnwrapOrElsePanic}; #[cfg(not(feature = "dylint-driver"))] mod stub { 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 dd67f9a4..3783215a 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 @@ -1,11 +1,11 @@ //! UI harness for `no_unwrap_or_else_panic` fixtures. +use std::{fs, io, path::Path}; + use camino::Utf8Path; use dylint_testing::ui::Test; #[cfg(not(windows))] use rstest::rstest; -use std::path::Path; -use std::{fs, io}; #[cfg(not(windows))] use temp_env::with_vars_unset; #[cfg(not(windows))] @@ -30,7 +30,7 @@ struct ExampleHarnessRun<'a> { #[cfg(not(windows))] impl<'a> ExampleHarnessRun<'a> { /// Creates a run spec using the default `--test` harness flag. - fn new(name: &'a str, label: &'a str) -> Self { + const fn new(name: &'a str, label: &'a str) -> Self { Self { name, label, @@ -40,7 +40,7 @@ impl<'a> ExampleHarnessRun<'a> { /// Creates a run spec with caller-supplied rustc flags (no defaults /// applied). - fn with_flags(name: &'a str, label: &'a str, rustc_flags: &'a [&'a str]) -> Self { + const fn with_flags(name: &'a str, label: &'a str, rustc_flags: &'a [&'a str]) -> Self { Self { name, label, @@ -53,14 +53,14 @@ 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, |crate_name, dir| { - run_fixtures(crate_name, dir) - }) - .unwrap_or_else(|error| { - panic!( - "UI tests should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error} }}" - ) - }); + whitaker::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 +71,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, |crate_name, _| { + whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { run_test_runner(spec.name, || { let _guard = env_test_guard(); with_vars_unset( @@ -93,7 +93,8 @@ fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { }) .unwrap_or_else(|error| { panic!( - "{} example regression should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error:?} }}", + "{} example regression should execute without diffs: RunnerFailure {{ crate_name: \ + \"{crate_name}\", directory: \"{directory}\", message: {error:?} }}", spec.label ) }); diff --git a/crates/no_unwrap_or_else_panic/src/panic_detector.rs b/crates/no_unwrap_or_else_panic/src/panic_detector.rs index d114a762..01b1ad06 100644 --- a/crates/no_unwrap_or_else_panic/src/panic_detector.rs +++ b/crates/no_unwrap_or_else_panic/src/panic_detector.rs @@ -1,8 +1,7 @@ //! Detect panics inside `unwrap_or_else` fallback closures. use rustc_hir as hir; -use rustc_hir::def_id::DefId; -use rustc_hir::{Expr, ExprKind}; +use rustc_hir::{Expr, ExprKind, def_id::DefId}; use rustc_lint::LateContext; use rustc_middle::ty; use rustc_span::sym; @@ -50,7 +49,7 @@ impl PanicInfo { /// Returns `true` when the closure has at least one interpolated panic /// and no plain (non-interpolating) panic. #[must_use] - pub(crate) fn is_interpolated_only(&self) -> bool { + pub(crate) const fn is_interpolated_only(self) -> bool { self.has_interpolated_panic && !self.has_plain_panic } } @@ -58,7 +57,7 @@ impl PanicInfo { /// Analyses the closure referenced by `body_id` and returns a [`PanicInfo`] /// describing whether it panics and distinguishing plain vs interpolated panics. #[must_use] -pub(crate) fn closure_panics<'tcx>(cx: &LateContext<'tcx>, body_id: hir::BodyId) -> PanicInfo { +pub(crate) fn closure_panics(cx: &LateContext<'_>, body_id: hir::BodyId) -> PanicInfo { let mut detector = PanicDetector { cx, panics: false, @@ -85,12 +84,12 @@ pub(crate) fn receiver_is_option_or_result<'tcx>( } fn ty_is_option_or_result<'tcx>(cx: &LateContext<'tcx>, ty: ty::Ty<'tcx>) -> bool { - let ty = cx + let normalized = cx .tcx .normalize_erasing_regions(cx.typing_env(), ty::Unnormalized::new_wip(ty)) .peel_refs(); - let Some(adt) = ty.ty_adt_def() else { + let Some(adt) = normalized.ty_adt_def() else { return false; }; @@ -105,7 +104,7 @@ struct PanicDetector<'a, 'tcx> { has_interpolated_panic: bool, } -impl<'a, 'tcx> rustc_hir::intravisit::Visitor<'tcx> for PanicDetector<'a, 'tcx> { +impl<'tcx> rustc_hir::intravisit::Visitor<'tcx> for PanicDetector<'_, 'tcx> { fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) { if is_panic_call(self.cx, expr) { self.panics = true; @@ -157,11 +156,11 @@ fn is_panic_call(cx: &LateContext<'_>, expr: &Expr<'_>) -> bool { /// entry point) for `Arguments::new_v1` or `Arguments::new_v1_formatted`, /// which are only used when format arguments are present. /// -/// The check examines the message argument's call tree for fmt::Arguments +/// The check examines the message argument's call tree for `fmt::Arguments` /// constructors, but only considers calls that are part of the compiler- -/// generated format_args expansion. This avoids false positives from unrelated +/// generated `format_args` expansion. This avoids false positives from unrelated /// user code like `panic_any(MyType::new_v1())` where `MyType::new_v1()` is -/// the payload, not a format_args constructor. +/// the payload, not a `format_args` constructor. fn panic_args_use_interpolation<'tcx>(cx: &LateContext<'tcx>, expr: &Expr<'tcx>) -> bool { // Extract the panic message argument (first argument to the panic call). let ExprKind::Call(_, args) = expr.kind else { @@ -187,7 +186,7 @@ struct RuntimeArgsFinder<'a, 'tcx> { found: bool, } -impl<'a, 'tcx> rustc_hir::intravisit::Visitor<'tcx> for RuntimeArgsFinder<'a, 'tcx> { +impl<'tcx> rustc_hir::intravisit::Visitor<'tcx> for RuntimeArgsFinder<'_, 'tcx> { fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) { if self.found { return; diff --git a/crates/no_unwrap_or_else_panic/src/policy.rs b/crates/no_unwrap_or_else_panic/src/policy.rs index df7c4a6e..a979c37e 100644 --- a/crates/no_unwrap_or_else_panic/src/policy.rs +++ b/crates/no_unwrap_or_else_panic/src/policy.rs @@ -1,7 +1,6 @@ //! Pure lint policy evaluation logic shared by driver and behaviour tests. -use crate::context::ContextSummary; -use crate::panic_detector::PanicInfo; +use crate::{context::ContextSummary, panic_detector::PanicInfo}; /// Configuration flags controlling when the lint should emit diagnostics. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -12,9 +11,7 @@ pub(crate) struct LintPolicy { impl LintPolicy { /// Create a policy with the given `allow_in_main` flag. #[must_use] - pub(crate) const fn new(allow_in_main: bool) -> Self { - Self { allow_in_main } - } + pub(crate) const fn new(allow_in_main: bool) -> Self { Self { allow_in_main } } } /// Decide whether the lint should emit based on context and closure behaviour. @@ -39,13 +36,13 @@ impl LintPolicy { /// has_plain_panic: true, /// has_interpolated_panic: false, /// }; -/// assert!(should_flag(&policy, &summary, &info, false)); +/// assert!(should_flag(policy, summary, info, false)); /// ``` #[must_use] -pub(crate) fn should_flag( - policy: &LintPolicy, - summary: &ContextSummary, - panic_info: &PanicInfo, +pub(crate) const fn should_flag( + policy: LintPolicy, + summary: ContextSummary, + panic_info: PanicInfo, is_doctest: bool, ) -> bool { if !panic_info.panics { @@ -69,9 +66,10 @@ pub(crate) fn should_flag( #[cfg(test)] mod tests { - use super::*; use rstest::rstest; + use super::*; + #[derive(Clone, Copy, Debug)] struct PolicyCase { policy: LintPolicy, @@ -155,13 +153,9 @@ mod tests { })] fn policy_evaluation(#[case] case: PolicyCase) { assert_eq!( - should_flag( - &case.policy, - &case.context, - &case.panic_info, - case.is_doctest - ), - case.should_flag + should_flag(case.policy, case.context, case.panic_info, case.is_doctest), + case.should_flag, + "policy evaluation should match the expected decision" ); } } diff --git a/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs b/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs index dcdaef7b..96a89b0d 100644 --- a/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs +++ b/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs @@ -1,11 +1,15 @@ //! Behaviour-driven coverage for lint decision logic. -use crate::context::ContextSummary; -use crate::panic_detector::PanicInfo; -use crate::policy::{LintPolicy, should_flag}; +use std::cell::Cell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::Cell; + +use crate::{ + context::ContextSummary, + panic_detector::PanicInfo, + policy::{LintPolicy, should_flag}, +}; #[derive(Default)] struct DecisionWorld { @@ -17,21 +21,20 @@ struct DecisionWorld { } impl DecisionWorld { - fn evaluate(&self) -> bool { + const fn evaluate(&self) -> bool { let policy = LintPolicy::new(self.allow_in_main.get()); should_flag( - &policy, - &self.summary.get(), - &self.panic_info.get(), + policy, + self.summary.get(), + self.panic_info.get(), self.is_doctest.get(), ) } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> DecisionWorld { - DecisionWorld::default() -} +fn world() -> DecisionWorld { DecisionWorld::default() } #[given("a panicking unwrap_or_else fallback outside tests")] fn given_panicking(world: &DecisionWorld) { @@ -46,9 +49,7 @@ fn given_panicking(world: &DecisionWorld) { } #[given("a panicking unwrap_or_else fallback")] -fn given_panicking_alias(world: &DecisionWorld) { - given_panicking(world); -} +fn given_panicking_alias(world: &DecisionWorld) { given_panicking(world); } #[given("the panic message interpolates a value")] fn given_interpolating(world: &DecisionWorld) { @@ -80,24 +81,16 @@ fn given_main(world: &DecisionWorld) { } #[given("allow in main is enabled")] -fn given_allow_main(world: &DecisionWorld) { - world.allow_in_main.set(true); -} +fn given_allow_main(world: &DecisionWorld) { world.allow_in_main.set(true); } #[given("the fallback is safe")] -fn given_safe_fallback(world: &DecisionWorld) { - world.panic_info.set(PanicInfo::default()); -} +fn given_safe_fallback(world: &DecisionWorld) { world.panic_info.set(PanicInfo::default()); } #[given("a doctest harness is active")] -fn given_doctest(world: &DecisionWorld) { - world.is_doctest.set(true); -} +fn given_doctest(world: &DecisionWorld) { world.is_doctest.set(true); } #[when("the lint policy is evaluated")] -fn when_policy_evaluated(world: &DecisionWorld) { - world.should_flag.set(Some(world.evaluate())); -} +fn when_policy_evaluated(world: &DecisionWorld) { world.should_flag.set(Some(world.evaluate())); } #[then("the lint triggers")] fn then_triggers(world: &DecisionWorld) { @@ -110,31 +103,19 @@ fn then_skipped(world: &DecisionWorld) { } #[scenario(path = "tests/features/policy.feature", index = 0)] -fn scenario_panicking_outside_tests(world: DecisionWorld) { - let _ = world; -} +fn scenario_panicking_outside_tests(world: DecisionWorld) { let _ = world; } #[scenario(path = "tests/features/policy.feature", index = 1)] -fn scenario_panicking_inside_test(world: DecisionWorld) { - let _ = world; -} +fn scenario_panicking_inside_test(world: DecisionWorld) { let _ = world; } #[scenario(path = "tests/features/policy.feature", index = 2)] -fn scenario_panicking_in_main_with_allow(world: DecisionWorld) { - let _ = world; -} +fn scenario_panicking_in_main_with_allow(world: DecisionWorld) { let _ = world; } #[scenario(path = "tests/features/policy.feature", index = 3)] -fn scenario_safe_fallback(world: DecisionWorld) { - let _ = world; -} +fn scenario_safe_fallback(world: DecisionWorld) { let _ = world; } #[scenario(path = "tests/features/policy.feature", index = 4)] -fn scenario_doctest(world: DecisionWorld) { - let _ = world; -} +fn scenario_doctest(world: DecisionWorld) { let _ = world; } #[scenario(path = "tests/features/policy.feature", index = 5)] -fn scenario_interpolated_panic_in_test(world: DecisionWorld) { - let _ = world; -} +fn scenario_interpolated_panic_in_test(world: DecisionWorld) { let _ = world; } diff --git a/crates/rstest_helper_should_be_fixture/Cargo.toml b/crates/rstest_helper_should_be_fixture/Cargo.toml index 61176318..e44bb18d 100644 --- a/crates/rstest_helper_should_be_fixture/Cargo.toml +++ b/crates/rstest_helper_should_be_fixture/Cargo.toml @@ -53,3 +53,6 @@ proptest = { workspace = true } rstest = { workspace = true } toml = { workspace = true } trybuild = { workspace = true } + +[lints] +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 b7c45825..f9dea57b 100644 --- a/crates/rstest_helper_should_be_fixture/src/collector.rs +++ b/crates/rstest_helper_should_be_fixture/src/collector.rs @@ -8,8 +8,10 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use log::debug; use rustc_hir as hir; -use rustc_hir::def::{DefKind, Res}; -use rustc_hir::def_id::{DefId, LOCAL_CRATE}; +use rustc_hir::{ + def::{DefKind, Res}, + def_id::{DefId, LOCAL_CRATE}, +}; use rustc_lint::LateContext; use rustc_span::{BytePos, FileName, Span}; use whitaker_common::rstest::{ArgAtom, ArgFingerprint}; @@ -38,7 +40,7 @@ impl CallSiteRecord { /// assert_eq!(record.callee_def_id, callee); /// # } /// ``` - pub(crate) fn new( + pub(crate) const fn new( callee_def_id: DefId, fingerprint: ArgFingerprint, test_source_def_id: DefId, @@ -137,10 +139,7 @@ impl CallSiteCollector { if !self.seen.insert(location) { debug!( target: "rstest_helper_should_be_fixture", - "dropping duplicate rstest helper call-site evidence: callee={}, lo={:?}, hi={:?}", - callee_key, - lo, - hi, + "dropping duplicate rstest helper call-site evidence: callee={callee_key}, lo={lo:?}, hi={hi:?}", ); return false; } @@ -193,14 +192,10 @@ impl CallSiteCollector { } /// Returns the number of distinct callees with collected evidence. - pub(crate) fn callee_count(&self) -> usize { - self.by_callee.len() - } + pub(crate) fn callee_count(&self) -> usize { self.by_callee.len() } /// Returns the number of deduplicated call-site records. - pub(crate) fn record_count(&self) -> usize { - self.by_callee.values().map(Vec::len).sum() - } + pub(crate) fn record_count(&self) -> usize { self.by_callee.values().map(Vec::len).sum() } /// Removes all stored evidence from the collector. pub(crate) fn clear(&mut self) { @@ -291,8 +286,7 @@ fn local_fixture_atom( } else { debug!( target: "rstest_helper_should_be_fixture", - "lowering unsupported local argument: `{}` is not an rstest fixture local", - name, + "lowering unsupported local argument: `{name}` is not an rstest fixture local", ); ArgAtom::unsupported() } @@ -308,9 +302,7 @@ fn literal_atom(cx: &LateContext<'_>, span: Span, lit: &hir::Lit) -> ArgAtom { literal_text_atom(text) } -fn literal_text_atom(text: String) -> ArgAtom { - ArgAtom::const_lit(text) -} +fn literal_text_atom(text: String) -> ArgAtom { ArgAtom::const_lit(text) } /// Resolves a helper call expression to a local function definition. #[must_use] @@ -336,8 +328,7 @@ pub(crate) fn resolve_local_callee<'tcx>( } else { debug!( target: "rstest_helper_should_be_fixture", - "callee resolution skipped non-local or non-function callee: {:?}", - def_id, + "callee resolution skipped non-local or non-function callee: {def_id:?}", ); None } diff --git a/crates/rstest_helper_should_be_fixture/src/collector_tests.rs b/crates/rstest_helper_should_be_fixture/src/collector_tests.rs index af287e96..51471a36 100644 --- a/crates/rstest_helper_should_be_fixture/src/collector_tests.rs +++ b/crates/rstest_helper_should_be_fixture/src/collector_tests.rs @@ -6,19 +6,23 @@ //! collector module, while these tests keep the record store cheap to exercise //! without constructing a rustc lint context. +use proptest::prelude::*; use rstest::rstest; -use rustc_hir::ItemLocalId; -use rustc_hir::def_id::{DefId, DefIndex}; +use rustc_hir::{ + ItemLocalId, + def_id::{DefId, DefIndex}, +}; use rustc_span::{BytePos, DUMMY_SP, FileName, Span}; use whitaker_common::rstest::{ArgAtom, ArgFingerprint}; use super::{ - CallSiteCollector, CallSiteLocation, CallSiteRecord, literal_text_atom, + CallSiteCollector, + CallSiteLocation, + CallSiteRecord, + literal_text_atom, should_skip_arg_for_unrecoverable_span, }; -use proptest::prelude::*; - #[test] fn collector_iterates_callees_in_definition_path_order() { let mut collector = CallSiteCollector::default(); @@ -34,7 +38,7 @@ fn collector_iterates_callees_in_definition_path_order() { let keys = collector .iter() - .map(|(callee, _)| callee.to_string()) + .map(|(callee, _)| callee.to_owned()) .collect::>(); assert_eq!(keys, ["crate::a_helper", "crate::z_helper"]); @@ -111,7 +115,7 @@ fn collector_orders_large_single_callee_bucket_by_span() { #[test] fn literal_lowering_records_const_lit_atom() { assert_eq!( - literal_text_atom("\"literal\"".to_string()), + literal_text_atom("\"literal\"".to_owned()), ArgAtom::const_lit("\"literal\""), ); } @@ -139,17 +143,13 @@ fn collect_two_calls(lo2: u32, hi2: u32) -> (CallSiteCollector, [bool; 2]) { (collector, inserted) } -fn record(callee_def_id: DefId) -> CallSiteRecord { - record_at(callee_def_id, DUMMY_SP) -} +fn record(callee_def_id: DefId) -> CallSiteRecord { record_at(callee_def_id, DUMMY_SP) } fn record_at(callee_def_id: DefId, span: Span) -> CallSiteRecord { CallSiteRecord::new(callee_def_id, ArgFingerprint::default(), def_id(99), span) } -fn def_id(index: u32) -> DefId { - DefId::local(DefIndex::from_u32(index)) -} +fn def_id(index: u32) -> DefId { DefId::local(DefIndex::from_u32(index)) } fn location(callee: &str, lo: BytePos, hi: BytePos) -> CallSiteLocation { location_with_hir_id(callee, lo, hi, 0) @@ -162,16 +162,14 @@ fn location_with_hir_id( hir_local_id: u32, ) -> CallSiteLocation { CallSiteLocation::new( - callee.to_string(), + callee.to_owned(), source_file(), Span::with_root_ctxt(lo, hi), ItemLocalId::from_u32(hir_local_id), ) } -fn source_file() -> FileName { - FileName::Custom("src/lib.rs".to_string()) -} +fn source_file() -> FileName { FileName::Custom("src/lib.rs".to_owned()) } proptest! { #[test] @@ -232,11 +230,11 @@ fn collect_spans(spans: &[(u32, u32, u32)]) -> Vec<(String, Vec<(u32, u32)>)> { collector .iter() .map(|(callee, records)| { - let spans = records + let recorded_spans = records .iter() .map(|record| (record.span.lo().0, record.span.hi().0)) .collect(); - (callee.to_string(), spans) + (callee.to_owned(), recorded_spans) }) .collect() } diff --git a/crates/rstest_helper_should_be_fixture/src/driver.rs b/crates/rstest_helper_should_be_fixture/src/driver.rs index fe40f999..f5b4e4ae 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver.rs @@ -4,26 +4,30 @@ //! configuration normalization stays in small helper methods so it can be //! tested without constructing rustc contexts. -use crate::collector::CallSiteCollector; -use crate::visitor::{ - CallSiteVisitor, attribute_from_hir, fixture_local_ids, redacted_fingerprint_shape, -}; +use std::{collections::HashSet, io::Write}; + use camino::{Utf8Path, Utf8PathBuf}; -use cap_std::ambient_authority; -use cap_std::fs_utf8::{Dir, OpenOptions}; +use cap_std::{ + ambient_authority, + fs_utf8::{Dir, OpenOptions}, +}; use log::debug; use rustc_hir as hir; -use rustc_hir::def_id::LocalDefId; -use rustc_hir::intravisit::Visitor; +use rustc_hir::{def_id::LocalDefId, intravisit::Visitor}; use rustc_lint::{LateContext, LateLintPass}; use rustc_span::Span; use serde::Deserialize; -use std::collections::HashSet; -use std::io::Write; use whitaker::SharedConfig; -use whitaker_common::attributes::AttributePath; -use whitaker_common::i18n::{Localizer, get_localizer_for_lint}; -use whitaker_common::rstest::{RstestDetectionOptions, is_rstest_test_with}; +use whitaker_common::{ + attributes::AttributePath, + i18n::{Localizer, get_localizer_for_lint}, + rstest::{RstestDetectionOptions, is_rstest_test_with}, +}; + +use crate::{ + collector::CallSiteCollector, + visitor::{CallSiteVisitor, attribute_from_hir, fixture_local_ids, redacted_fingerprint_shape}, +}; const LINT_NAME: &str = "rstest_helper_should_be_fixture"; /// Internal test-only hook used by the UI harness to assert passive collection. @@ -40,13 +44,31 @@ const DEFAULT_PROVIDER_PARAM_ATTRIBUTES: &[&str] = type ConfigLoadResult = Result; -dylint_linting::impl_late_lint! { - pub RSTEST_HELPER_SHOULD_BE_FIXTURE, - Warn, - "repeated rstest helper calls should be extracted into fixtures", - RstestHelperShouldBeFixture::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::RstestHelperShouldBeFixture; + + dylint_linting::impl_late_lint! { + /// Warns when repeated rstest helper calls should become fixtures. + pub RSTEST_HELPER_SHOULD_BE_FIXTURE, + Warn, + "repeated rstest helper calls should be extracted into fixtures", + RstestHelperShouldBeFixture::default() + } } +pub use declaration::RSTEST_HELPER_SHOULD_BE_FIXTURE; + /// Configuration for the `rstest_helper_should_be_fixture` lint. /// /// Values are loaded from `dylint.toml` and normalized so threshold settings @@ -131,10 +153,10 @@ impl RstestHelperShouldBeFixture { fn apply_loaded_crate_configuration( &mut self, config: ConfigLoadResult, - shared_config: SharedConfig, + shared_config: &SharedConfig, ) { - let config = match config { - Ok(config) => config, + let resolved = match config { + Ok(loaded) => loaded, Err(error) => { debug!( target: LINT_NAME, @@ -144,10 +166,10 @@ impl RstestHelperShouldBeFixture { } }; - self.apply_crate_configuration(config, shared_config); + self.apply_crate_configuration(resolved, shared_config); } - fn apply_crate_configuration(&mut self, config: Config, shared_config: SharedConfig) { + fn apply_crate_configuration(&mut self, config: Config, shared_config: &SharedConfig) { debug!( target: LINT_NAME, "applying `{LINT_NAME}` configuration: min_calls={}, min_distinct_tests={}, \ @@ -185,8 +207,7 @@ impl RstestHelperShouldBeFixture { { debug!( target: LINT_NAME, - "skipping helper call-site collection for non-rstest function: def_id={:?}", - def_id, + "skipping helper call-site collection for non-rstest function: def_id={def_id:?}", ); return; } @@ -204,7 +225,7 @@ 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.apply_loaded_crate_configuration(load_configuration(), &load_shared_config()); self.rstest_collection_roots = whitaker::hir::collect_rstest_companion_test_functions(cx); } @@ -241,8 +262,7 @@ impl<'tcx> LateLintPass<'tcx> for RstestHelperShouldBeFixture { if let Err(error) = self.write_collection_summary() { debug!( target: LINT_NAME, - "failed to write rstest helper call-site collection summary: {}", - error, + "failed to write rstest helper call-site collection summary: {error}", ); } debug!( @@ -262,12 +282,15 @@ impl RstestHelperShouldBeFixture { self.collector.record_count(), ); for (callee, records) in self.collector.iter() { - summary.push_str(&format!("callee={callee};records={}\n", records.len())); + summary.push_str("callee="); + summary.push_str(callee); + summary.push_str(";records="); + summary.push_str(&records.len().to_string()); + summary.push('\n'); for record in records { - summary.push_str(&format!( - "fingerprint={}\n", - redacted_fingerprint_shape(&record.fingerprint) - )); + summary.push_str("fingerprint="); + summary.push_str(&redacted_fingerprint_shape(&record.fingerprint)); + summary.push('\n'); } } summary 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 ecc59bbe..843f8d26 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs @@ -2,7 +2,7 @@ //! `rstest` detection option construction. //! //! NOTE: `SharedConfig::load` is treated as infallible at the driver call site -//! pending https://github.com/leynos/whitaker/issues/233. +//! pending . use proptest::prelude::*; use rstest::rstest; @@ -69,24 +69,24 @@ fn normalizes_numeric_thresholds_to_two() { } #[rstest] -#[case::plain(vec!["case".to_string()], vec!["case"])] -#[case::qualified(vec!["rstest::values".to_string()], vec!["values"])] +#[case::plain(vec!["case".to_owned()], vec!["case"])] +#[case::qualified(vec!["rstest::values".to_owned()], vec!["values"])] #[case::mixed_equivalent_spellings( - vec!["case".to_string(), "rstest::case".to_string()], + vec!["case".to_owned(), "rstest::case".to_owned()], vec!["case"] )] -#[case::blank(vec![" ".to_string()], vec!["case", "values", "files", "future", "context"])] +#[case::blank(vec![" ".to_owned()], vec!["case", "values", "files", "future", "context"])] fn normalizes_provider_attributes(#[case] input: Vec, #[case] expected: Vec<&str>) { let normalized = normalize_provider_attributes(input); - let expected: Vec = expected.into_iter().map(ToString::to_string).collect(); + let expected_owned: Vec = expected.into_iter().map(str::to_owned).collect(); - assert_eq!(normalized, expected); + assert_eq!(normalized, expected_owned); } #[rstest] fn detection_options_expand_plain_and_rstest_qualified_provider_paths() { let config = Config { - provider_param_attributes: vec!["case".to_string(), "custom".to_string()], + provider_param_attributes: vec!["case".to_owned(), "custom".to_owned()], use_source_callee_fallback: true, ..Config::default() }; @@ -133,7 +133,7 @@ fn loaded_configuration_normalizes_present_config() { let config = Config { min_calls: 1, min_distinct_tests: 1, - provider_param_attributes: vec!["rstest::case".to_string()], + provider_param_attributes: vec!["rstest::case".to_owned()], ..Config::default() }; @@ -149,12 +149,12 @@ fn loaded_configuration_normalizes_present_config() { fn applying_crate_configuration_initializes_pass_state() { let mut pass = RstestHelperShouldBeFixture::default(); let config = Config { - provider_param_attributes: vec!["custom".to_string()], + provider_param_attributes: vec!["custom".to_owned()], use_source_callee_fallback: true, ..Config::default() }; - pass.apply_crate_configuration(config.clone(), SharedConfig::default()); + pass.apply_crate_configuration(config.clone(), &SharedConfig::default()); assert_eq!(pass.config, config.normalized()); assert!(pass.detection_options.use_expansion_trace_fallback()); @@ -167,14 +167,14 @@ fn check_crate_configuration_loads_and_normalizes_config() { let config = Config { min_calls: 0, min_distinct_tests: 1, - provider_param_attributes: vec!["rstest::case".to_string()], + provider_param_attributes: vec!["rstest::case".to_owned()], use_source_callee_fallback: true, ..Config::default() }; pass.apply_loaded_crate_configuration( loaded_configuration::(Ok(Some(config))), - SharedConfig::default(), + &SharedConfig::default(), ); assert_eq!(pass.config.min_calls, 2); diff --git a/crates/rstest_helper_should_be_fixture/src/lib.rs b/crates/rstest_helper_should_be_fixture/src/lib.rs index fe2b6a98..4d2af2b1 100644 --- a/crates/rstest_helper_should_be_fixture/src/lib.rs +++ b/crates/rstest_helper_should_be_fixture/src/lib.rs @@ -13,5 +13,10 @@ mod driver; #[cfg(feature = "dylint-driver")] mod visitor; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{RSTEST_HELPER_SHOULD_BE_FIXTURE, RstestHelperShouldBeFixture}; diff --git a/crates/rstest_helper_should_be_fixture/src/visitor.rs b/crates/rstest_helper_should_be_fixture/src/visitor.rs index 28ae8c41..de7b835c 100644 --- a/crates/rstest_helper_should_be_fixture/src/visitor.rs +++ b/crates/rstest_helper_should_be_fixture/src/visitor.rs @@ -3,21 +3,36 @@ //! This module keeps rustc HIR mechanics separate from the lint-pass bootstrap //! so the driver remains focused on configuration and crate-level lifecycle. -use crate::collector::{ - CallSiteCollector, CallSiteLocation, CallSiteRecord, lower_arg_atom, resolve_local_callee, -}; +use std::collections::HashSet; + use log::debug; use rustc_ast::AttrStyle; use rustc_hir as hir; -use rustc_hir::def_id::DefId; -use rustc_hir::intravisit::{self, Visitor}; +use rustc_hir::{ + def_id::DefId, + intravisit::{self, Visitor}, +}; use rustc_lint::LateContext; use rustc_span::Span; -use std::collections::HashSet; -use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; -use whitaker_common::rstest::{ - ArgAtom, ArgFingerprint, ParameterBinding, RstestDetectionOptions, RstestParameter, - RstestParameterKind, classify_rstest_parameter, +use whitaker_common::{ + attributes::{Attribute, AttributeKind, AttributePath}, + rstest::{ + ArgAtom, + ArgFingerprint, + ParameterBinding, + RstestDetectionOptions, + RstestParameter, + RstestParameterKind, + classify_rstest_parameter, + }, +}; + +use crate::collector::{ + CallSiteCollector, + CallSiteLocation, + CallSiteRecord, + lower_arg_atom, + resolve_local_callee, }; const LINT_NAME: &str = "rstest_helper_should_be_fixture"; @@ -31,7 +46,7 @@ pub(crate) struct CallSiteVisitor<'a, 'tcx> { } impl<'a, 'tcx> CallSiteVisitor<'a, 'tcx> { - pub(crate) fn new( + pub(crate) const fn new( cx: &'a LateContext<'tcx>, collector: &'a mut CallSiteCollector, test_source_def_id: DefId, @@ -94,7 +109,7 @@ impl<'tcx> Visitor<'tcx> for CallSiteVisitor<'_, 'tcx> { match expr.kind { hir::ExprKind::Call(_, args) => self.collect_call(expr, args), hir::ExprKind::MethodCall(_, receiver, args, _) => { - self.collect_call(expr, std::iter::once(receiver).chain(args)) + self.collect_call(expr, std::iter::once(receiver).chain(args)); } hir::ExprKind::Closure(hir::Closure { .. }) => { self.closure_span_fallbacks.push(expr.span); @@ -115,7 +130,7 @@ impl CallSiteVisitor<'_, '_> { self.closure_span_fallbacks .iter() .rev() - .find_map(|span| whitaker::hir::recover_user_editable_hir_span(*span)) + .find_map(|fallback| whitaker::hir::recover_user_editable_hir_span(*fallback)) }) } } @@ -190,11 +205,13 @@ fn attribute_kind(attr: &hir::Attribute) -> AttributeKind { } } +/// Parsed attributes never reach this helper because `attribute_path` filters +/// them out; treating them as outer keeps the mapping total. fn attribute_style(attr: &hir::Attribute) -> AttrStyle { - let hir::Attribute::Unparsed(item) = attr else { - unreachable!("attribute_path filters parsed attributes"); - }; - item.style + match attr { + hir::Attribute::Unparsed(item) => item.style, + hir::Attribute::Parsed(_) => AttrStyle::Outer, + } } pub(crate) fn redacted_fingerprint_shape(fingerprint: &ArgFingerprint) -> String { @@ -208,7 +225,7 @@ pub(crate) fn redacted_fingerprint_shape(fingerprint: &ArgFingerprint) -> String .join(",") } -fn redacted_atom_shape(atom: &ArgAtom) -> &'static str { +const fn redacted_atom_shape(atom: &ArgAtom) -> &'static str { match atom { ArgAtom::FixtureLocal { .. } => "fixture-local", ArgAtom::ConstLit { .. } => "const-lit", diff --git a/crates/rstest_helper_should_be_fixture/tests/ui.rs b/crates/rstest_helper_should_be_fixture/tests/ui.rs index 33f66f2d..25c39ca4 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui.rs @@ -11,13 +11,15 @@ #[cfg(feature = "dylint-driver")] extern crate rustc_driver; -use dylint_testing::ui::Test; -use rstest::rstest; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use whitaker_common::test_support::{EnvVarGuard, run_test_runner}; +use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; +use dylint_testing::ui::Test; use harness_lock::ExampleHarnessLock; +use rstest::rstest; +use whitaker_common::test_support::{run_test_runner, with_env_var}; // Internal test-only hook mirrored in the lint driver. It asks // `check_crate_post` to append redacted, shape-only passive collection @@ -41,7 +43,11 @@ fn example_harness_collects_call_site_evidence() { for expected in [ "callee_count=3", "record_count=9", - "callee=Builder::<'_>::build;records=2\nfingerprint=unsupported,fixture-local\nfingerprint=unsupported,fixture-local", + concat!( + "callee=Builder::<'_>::build;records=2\n", + "fingerprint=unsupported,fixture-local\n", + "fingerprint=unsupported,fixture-local", + ), "callee=helper;records=2", "callee=nested_helper;records=5", "fingerprint=unsupported,fixture-local", @@ -72,29 +78,34 @@ fn trybuild_fixtures_compile_without_diagnostics() { /// releasing the lock mid-assertion, which would let a concurrent run append to /// the same summary path. struct ExampleHarness { - _lock: ExampleHarnessLock, + lock: ExampleHarnessLock, } impl ExampleHarness { fn acquire() -> Self { - let lock = ExampleHarnessLock::acquire().expect("example harness lock should be acquired"); - Self { _lock: lock } + match ExampleHarnessLock::acquire() { + Ok(lock) => Self { lock }, + Err(error) => panic!("example harness lock should be acquired: {error}"), + } } /// Compiles and runs one example while the lock is held. fn run_example(&self, example: &str) { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |crate_name, _| { + 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(crate_name, 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: \"{crate_name}\", directory: \"{directory}\", message: {error} }}" + "UI tests should execute without diffs: RunnerFailure {{ crate_name: \ + \"{crate_name}\", directory: \"{directory}\", lock: \"{lock_path}\", message: \ + {error} }}" ) }); } @@ -102,17 +113,20 @@ impl ExampleHarness { /// Runs `example` with the collection-summary env var pointed at a fresh /// path, returning the appended summary text. /// - /// The env-var guard and the harness lock are both held across the run and - /// the read, and the summary file is removed before returning, so no - /// concurrently scheduled run can append to the same path mid-read. + /// The scoped env override and the harness lock are both held across the + /// run and the read, and the summary file is removed before returning, so + /// no concurrently scheduled run can append to the same path mid-read. fn collect_summary(&self, example: &str) -> String { let summary_path = unique_summary_path(); - let _guard = EnvVarGuard::set(COLLECTION_SUMMARY_ENV, summary_path.as_os_str()); - self.run_example(example); - let summary = - std::fs::read_to_string(&summary_path).expect("collection summary should be written"); - let _ = std::fs::remove_file(&summary_path); - summary + with_env_var(COLLECTION_SUMMARY_ENV, summary_path.as_os_str(), || { + self.run_example(example); + let summary = match std::fs::read_to_string(&summary_path) { + Ok(summary) => summary, + Err(error) => panic!("collection summary should be written: {error}"), + }; + let _cleanup_result = std::fs::remove_file(&summary_path); + summary + }) } } diff --git a/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs b/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs index 553d9354..ac69cca2 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs @@ -6,24 +6,27 @@ //! tests that exercise it — so `ui.rs` stays focused on the example and //! trybuild assertions. +use std::{ + fs::{File, OpenOptions}, + io, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + use filetime::{FileTime, set_file_mtime}; use fs2::FileExt; use log::debug; use rstest::rstest; -use std::fs::{File, OpenOptions}; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; // The example harness lock coordinates separate nextest processes. Windows CI // can legitimately hold it for several minutes, so only remove directories // old enough to be abandoned by a crashed process. -const EXAMPLE_HARNESS_LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60); +const EXAMPLE_HARNESS_LOCK_STALE_AFTER: Duration = Duration::from_mins(30); // Bound the default `acquire()` wait so a wedged live owner surfaces a timeout // instead of polling forever. It exceeds the stale-recovery window so genuinely // abandoned locks are reclaimed before this ceiling is reached. -const EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(31 * 60); +const EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_mins(31); const EXAMPLE_HARNESS_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100); const EXAMPLE_HARNESS_LOCK_OWNER_FILENAME: &str = "owner"; const EXAMPLE_HARNESS_LOCK_LIVENESS_EXTENSION: &str = "owner-lock"; @@ -35,6 +38,11 @@ pub(crate) struct ExampleHarnessLock { owner: ExampleHarnessLockOwner, owner_liveness: File, } + +impl ExampleHarnessLock { + /// Returns the lock directory serializing the current example run. + pub(crate) fn path(&self) -> &Path { &self.path } +} #[derive(Clone, Debug, Eq, PartialEq)] struct ExampleHarnessLockOwner(String); impl ExampleHarnessLockOwner { @@ -51,30 +59,30 @@ impl ExampleHarnessLockOwner { impl ExampleHarnessLock { pub(crate) fn acquire() -> io::Result { Self::acquire_at( - std::env::temp_dir().join("rstest-helper-example-harness.lock"), + &std::env::temp_dir().join("rstest-helper-example-harness.lock"), Some(EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT), ) } - fn acquire_at(path: PathBuf, wait_limit: Option) -> io::Result { + fn acquire_at(path: &Path, wait_limit: Option) -> io::Result { let started_at = Instant::now(); let mut attempt = 0_u64; loop { attempt += 1; - let state_guard = lock_example_harness_state(&path)?; - match create_example_harness_lock(path.clone()) { + let state_guard = lock_example_harness_state(path)?; + match create_example_harness_lock(path.to_path_buf()) { Ok(lock) => { debug!(target: EXAMPLE_HARNESS_LOCK_LOG_TARGET, "event=acquired path={} owner={} attempt={} elapsed_ms={}", path.display(), lock.owner.0, attempt, started_at.elapsed().as_millis()); return Ok(lock); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { debug!(target: EXAMPLE_HARNESS_LOCK_LOG_TARGET, "event=contended path={} attempt={} elapsed_ms={}", path.display(), attempt, started_at.elapsed().as_millis()); - recover_stale_example_harness_lock_while_locked(&path)?; + recover_stale_example_harness_lock_while_locked(path)?; } Err(error) => return Err(error), } drop(state_guard); - wait_for_example_harness_lock_release(&path, started_at, wait_limit)?; + wait_for_example_harness_lock_release(path, started_at, wait_limit)?; } } } @@ -85,7 +93,8 @@ fn create_example_harness_lock(path: PathBuf) -> io::Result std::fs::create_dir(&path)?; let owner = ExampleHarnessLockOwner::new(); if let Err(error) = write_lock_owner(&path, &owner) { - let _ = remove_example_harness_lock_directory(&path); + // Best-effort rollback; the original write failure is the useful one. + let _rollback_result = remove_example_harness_lock_directory(&path); return Err(error); } Ok(ExampleHarnessLock { @@ -97,10 +106,11 @@ fn create_example_harness_lock(path: PathBuf) -> io::Result impl Drop for ExampleHarnessLock { fn drop(&mut self) { if let Ok(_state_guard) = lock_example_harness_state(&self.path) { - let _ = remove_lock_if_owned(&self.path, &self.owner); + // Best-effort cleanup: a failure only leaves a directory to reclaim. + let _cleanup_result = remove_lock_if_owned(&self.path, &self.owner); } // Release liveness after owner-aware cleanup to avoid successor races. - let _ = FileExt::unlock(&self.owner_liveness); + let _unlock_result = FileExt::unlock(&self.owner_liveness); } } @@ -110,7 +120,7 @@ fn wait_for_example_harness_lock_release( wait_limit: Option, ) -> io::Result<()> { recover_stale_example_harness_lock(path)?; - if wait_limit.is_some_and(|wait_limit| started_at.elapsed() >= wait_limit) { + if wait_limit.is_some_and(|limit| started_at.elapsed() >= limit) { debug!(target: EXAMPLE_HARNESS_LOCK_LOG_TARGET, "event=timed_out path={} elapsed_ms={}", path.display(), started_at.elapsed().as_millis()); return Err(example_harness_lock_timeout(path)); } @@ -154,10 +164,10 @@ fn remove_stale_example_harness_lock_while_locked(path: &Path) -> io::Result<()> debug!(target: EXAMPLE_HARNESS_LOCK_LOG_TARGET, "event=stale_recovery_live_owner path={}", path.display()); return Ok(()); }; - match read_example_harness_lock_owner(path)? { - Some(owner) => remove_lock_if_owned(path, &owner), - None => remove_example_harness_lock_directory(path), - } + read_example_harness_lock_owner(path)?.map_or_else( + || remove_example_harness_lock_directory(path), + |owner| remove_lock_if_owned(path, &owner), + ) } fn lock_example_harness_state(path: &Path) -> io::Result { let state_path = path.with_extension("state"); @@ -230,14 +240,18 @@ fn example_harness_lock_is_stale(modified: SystemTime, now: SystemTime) -> bool } fn make_example_harness_lock_stale(path: &Path) { - let stale_modified = SystemTime::now() - .checked_sub(EXAMPLE_HARNESS_LOCK_STALE_AFTER + Duration::from_secs(1)) - .expect("stale timestamp should be representable"); - set_file_mtime(path, FileTime::from_system_time(stale_modified)).expect("adjust lock mtime"); + let Some(stale_modified) = + SystemTime::now().checked_sub(EXAMPLE_HARNESS_LOCK_STALE_AFTER + Duration::from_secs(1)) + else { + panic!("stale timestamp should be representable") + }; + if let Err(error) = set_file_mtime(path, FileTime::from_system_time(stale_modified)) { + panic!("lock mtime should be adjustable: {error}"); + } } #[rstest] -#[case(Duration::from_secs(60), false)] +#[case(Duration::from_mins(1), false)] #[case(EXAMPLE_HARNESS_LOCK_STALE_AFTER + Duration::from_secs(1), true)] fn example_harness_lock_stale_policy(#[case] age: Duration, #[case] expected: bool) { let now = SystemTime::now(); @@ -264,10 +278,10 @@ fn stale_lock_operations_treat_missing_directory_as_released(#[case] recover: bo fn example_harness_lock_reports_active_contention_timeout() { let path = super::unique_summary_path(); std::fs::create_dir(&path).expect("create test lock directory"); - let Err(error) = ExampleHarnessLock::acquire_at(path.clone(), Some(Duration::ZERO)) else { + let Err(error) = ExampleHarnessLock::acquire_at(&path, Some(Duration::ZERO)) else { panic!("active lock contention should time out"); }; - let _ = std::fs::remove_dir(&path); + let _cleanup_result = std::fs::remove_dir(&path); assert_eq!(error.kind(), io::ErrorKind::TimedOut); } @@ -293,7 +307,7 @@ fn held_example_harness_liveness_lock_is_reported_as_contended() { #[test] fn stale_recovery_preserves_active_owner_then_reclaims_after_release() { let path = super::unique_summary_path(); - let owner = ExampleHarnessLock::acquire_at(path.clone(), None).expect("acquire active owner"); + let owner = ExampleHarnessLock::acquire_at(&path, None).expect("acquire active owner"); make_example_harness_lock_stale(&path); recover_stale_example_harness_lock(&path).expect("active owner blocks recovery"); assert!(path.is_dir(), "live owner directory must remain intact"); @@ -315,7 +329,7 @@ fn stale_recovery_preserves_active_owner_then_reclaims_after_release() { #[test] fn lock_cleanup_does_not_remove_a_different_owner() { let path = super::unique_summary_path(); - let original = ExampleHarnessLock::acquire_at(path.clone(), None).expect("acquire lock"); + let original = ExampleHarnessLock::acquire_at(&path, None).expect("acquire lock"); let successor = ExampleHarnessLockOwner::new(); { let _state_guard = lock_example_harness_state(&path).expect("lock state"); @@ -323,7 +337,10 @@ fn lock_cleanup_does_not_remove_a_different_owner() { remove_lock_if_owned(&path, &original.owner).expect("inspect successor ownership"); } - assert!(path.is_dir()); + assert!( + path.is_dir(), + "a successor-owned lock directory must survive" + ); drop(original); remove_stale_example_harness_lock(&path).expect("remove released different-owner directory"); } diff --git a/crates/rstest_helper_should_be_fixture/tests/ui/lock_model.rs b/crates/rstest_helper_should_be_fixture/tests/ui/lock_model.rs index 599ea1b0..6a223959 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui/lock_model.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui/lock_model.rs @@ -90,7 +90,7 @@ impl LockModel { } } - fn acquire(&mut self, owner: Owner) { + const fn acquire(&mut self, owner: Owner) { if self.liveness_owner.is_none() && self.directory.is_none() { self.liveness_owner = Some(owner); self.directory = Some(Directory { @@ -100,7 +100,7 @@ impl LockModel { } } - fn mark_stale(&mut self) { + const fn mark_stale(&mut self) { if let Some(directory) = &mut self.directory { directory.is_stale = true; } @@ -117,13 +117,13 @@ impl LockModel { } } - fn replace_owner(&mut self, owner: Owner) { + const fn replace_owner(&mut self, owner: Owner) { if let Some(directory) = &mut self.directory { directory.owner = Some(owner); } } - fn remove_owner_metadata(&mut self) { + const fn remove_owner_metadata(&mut self) { if let Some(directory) = &mut self.directory { directory.owner = None; } @@ -134,7 +134,9 @@ impl LockModel { return; } - self.cleanup_attempted[owner.index()] = true; + if let Some(attempted) = self.cleanup_attempted.get_mut(owner.index()) { + *attempted = true; + } if self .directory .as_ref() @@ -144,7 +146,11 @@ impl LockModel { self.remove_directory(Some(owner)); } - self.last_release_followed_cleanup = self.cleanup_attempted[owner.index()]; + self.last_release_followed_cleanup = self + .cleanup_attempted + .get(owner.index()) + .copied() + .unwrap_or(false); self.liveness_owner = None; } @@ -164,13 +170,13 @@ impl LockModel { return; } - if let (Some(cleaner), Some(owner)) = ( + if let (Some(cleaning_owner), Some(owner)) = ( cleaner, self.directory .as_ref() .and_then(|directory| directory.owner), ) { - self.last_owner_aware_removal = Some((cleaner, owner)); + self.last_owner_aware_removal = Some((cleaning_owner, owner)); } self.directory = None; } diff --git a/crates/rustc_ast/Cargo.toml b/crates/rustc_ast/Cargo.toml index 76567216..c0941425 100644 --- a/crates/rustc_ast/Cargo.toml +++ b/crates/rustc_ast/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/rustc_attr_data_structures/Cargo.toml b/crates/rustc_attr_data_structures/Cargo.toml index e89341cd..91f9baf1 100644 --- a/crates/rustc_attr_data_structures/Cargo.toml +++ b/crates/rustc_attr_data_structures/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/rustc_hir/Cargo.toml b/crates/rustc_hir/Cargo.toml index dabd8d65..e3bf64bf 100644 --- a/crates/rustc_hir/Cargo.toml +++ b/crates/rustc_hir/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/rustc_lint/Cargo.toml b/crates/rustc_lint/Cargo.toml index a08bfa0f..72ba107b 100644 --- a/crates/rustc_lint/Cargo.toml +++ b/crates/rustc_lint/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/rustc_middle/Cargo.toml b/crates/rustc_middle/Cargo.toml index 23645079..da219a41 100644 --- a/crates/rustc_middle/Cargo.toml +++ b/crates/rustc_middle/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/rustc_session/Cargo.toml b/crates/rustc_session/Cargo.toml index bc54bb63..adba42fd 100644 --- a/crates/rustc_session/Cargo.toml +++ b/crates/rustc_session/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/rustc_span/Cargo.toml b/crates/rustc_span/Cargo.toml index 36dfd7cd..ba8fe723 100644 --- a/crates/rustc_span/Cargo.toml +++ b/crates/rustc_span/Cargo.toml @@ -6,3 +6,6 @@ publish = false [lib] test = false + +[lints] +workspace = true diff --git a/crates/test_must_not_have_example/Cargo.toml b/crates/test_must_not_have_example/Cargo.toml index 18605b91..492398ec 100644 --- a/crates/test_must_not_have_example/Cargo.toml +++ b/crates/test_must_not_have_example/Cargo.toml @@ -34,8 +34,12 @@ log = { workspace = true, optional = true } whitaker = { version = "0.2.7", path = "../../", features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } camino = { workspace = true } + +[lints] +workspace = true diff --git a/crates/test_must_not_have_example/src/behaviour.rs b/crates/test_must_not_have_example/src/behaviour.rs index 522268c8..7c82a288 100644 --- a/crates/test_must_not_have_example/src/behaviour.rs +++ b/crates/test_must_not_have_example/src/behaviour.rs @@ -1,9 +1,11 @@ //! Behaviour-driven coverage for documentation example heuristics. -use crate::heuristics::{DocExampleViolation, detect_example_violation}; +use std::cell::RefCell; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; + +use crate::heuristics::{DocExampleViolation, detect_example_violation}; #[derive(Default)] struct DocumentationWorld { @@ -12,34 +14,25 @@ struct DocumentationWorld { } impl DocumentationWorld { - fn push_line(&self, line: &str) { - self.lines.borrow_mut().push(line.to_string()); - } + fn push_line(&self, line: &str) { self.lines.borrow_mut().push(line.to_owned()); } fn evaluate(&self) { let doc = self.lines.borrow().join("\n"); self.outcome.replace(detect_example_violation(&doc)); } - fn outcome(&self) -> Option { - *self.outcome.borrow() - } + fn outcome(&self) -> Option { *self.outcome.borrow() } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> DocumentationWorld { - DocumentationWorld::default() -} +fn world() -> DocumentationWorld { DocumentationWorld::default() } #[given("documentation line {line}")] -fn given_line(world: &DocumentationWorld, line: String) { - world.push_line(line.trim_matches('"')); -} +fn given_line(world: &DocumentationWorld, line: String) { world.push_line(line.trim_matches('"')); } #[when("I evaluate the documentation")] -fn when_evaluate(world: &DocumentationWorld) { - world.evaluate(); -} +fn when_evaluate(world: &DocumentationWorld) { world.evaluate(); } #[then("the violation is examples heading")] fn then_examples_heading(world: &DocumentationWorld) { @@ -57,26 +50,16 @@ fn then_no_violation(world: &DocumentationWorld) { } #[scenario(path = "tests/features/doc_examples.feature", index = 0)] -fn scenario_examples_heading(world: DocumentationWorld) { - let _ = world; -} +fn scenario_examples_heading(world: DocumentationWorld) { let _ = world; } #[scenario(path = "tests/features/doc_examples.feature", index = 1)] -fn scenario_code_fence(world: DocumentationWorld) { - let _ = world; -} +fn scenario_code_fence(world: DocumentationWorld) { let _ = world; } #[scenario(path = "tests/features/doc_examples.feature", index = 2)] -fn scenario_inline_ticks(world: DocumentationWorld) { - let _ = world; -} +fn scenario_inline_ticks(world: DocumentationWorld) { let _ = world; } #[scenario(path = "tests/features/doc_examples.feature", index = 3)] -fn scenario_plain_prose(world: DocumentationWorld) { - let _ = world; -} +fn scenario_plain_prose(world: DocumentationWorld) { let _ = world; } #[scenario(path = "tests/features/doc_examples.feature", index = 4)] -fn scenario_source_order(world: DocumentationWorld) { - let _ = world; -} +fn scenario_source_order(world: DocumentationWorld) { let _ = world; } diff --git a/crates/test_must_not_have_example/src/driver.rs b/crates/test_must_not_have_example/src/driver.rs index bc4667a1..dc46e3ae 100644 --- a/crates/test_must_not_have_example/src/driver.rs +++ b/crates/test_must_not_have_example/src/driver.rs @@ -1,21 +1,31 @@ //! Lint crate enforcing example-free documentation for test functions. -use crate::heuristics::{DocExampleViolation, detect_example_violation}; +use std::borrow::Cow; + use log::debug; use rustc_hir as hir; use rustc_hir::Node; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::{Ident, Span, Symbol}; use serde::Deserialize; -use std::borrow::Cow; -use whitaker::SharedConfig; -use whitaker::hir::has_test_like_hir_attributes; -use whitaker_common::AttributePath; -use whitaker_common::i18n::{ - Arguments, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, MessageResolution, - get_localizer_for_lint, noop_reporter, safe_resolve_message_set, +use whitaker::{SharedConfig, hir::has_test_like_hir_attributes}; +use whitaker_common::{ + AttributePath, + i18n::{ + Arguments, + DiagnosticMessageSet, + FluentValue, + Localizer, + MessageKey, + MessageResolution, + get_localizer_for_lint, + noop_reporter, + safe_resolve_message_set, + }, }; +use crate::heuristics::{DocExampleViolation, detect_example_violation}; + const LINT_NAME: &str = "test_must_not_have_example"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("test_must_not_have_example"); @@ -25,13 +35,31 @@ struct Config { additional_test_attributes: Vec, } -dylint_linting::impl_late_lint! { - pub TEST_MUST_NOT_HAVE_EXAMPLE, - Warn, - "test functions should not include examples or fenced code in documentation", - TestMustNotHaveExample::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::TestMustNotHaveExample; + + dylint_linting::impl_late_lint! { + /// Warns when a test function's documentation contains an example. + pub TEST_MUST_NOT_HAVE_EXAMPLE, + Warn, + "test functions should not include examples or fenced code in documentation", + TestMustNotHaveExample::default() + } } +pub use declaration::TEST_MUST_NOT_HAVE_EXAMPLE; + /// Lint pass that checks test documentation for example sections. pub struct TestMustNotHaveExample { /// Additional attribute paths configured as test-like markers. @@ -61,14 +89,14 @@ enum ItemKindInfo<'a> { } impl<'a> ItemKindInfo<'a> { - fn ident(&self) -> &Ident { + const fn ident(&self) -> &Ident { match self { ItemKindInfo::Item { ident, .. } => ident, ItemKindInfo::ImplItem { ident, .. } | ItemKindInfo::TraitItem { ident, .. } => ident, } } - fn attrs(&self) -> &'a [hir::Attribute] { + const fn attrs(&self) -> &'a [hir::Attribute] { match self { ItemKindInfo::Item { attrs, .. } | ItemKindInfo::ImplItem { attrs, .. } @@ -85,7 +113,7 @@ macro_rules! impl_check_method { let attrs = cx.tcx.hir_attrs(item.hir_id()); self.check_function_item( cx, - ItemKindInfo::$variant { + &ItemKindInfo::$variant { ident: &item.ident, attrs, }, @@ -113,8 +141,7 @@ impl<'tcx> LateLintPass<'tcx> for TestMustNotHaveExample { Err(error) => { debug!( target: LINT_NAME, - "failed to parse `{}` configuration: {error}; using defaults", - LINT_NAME + "failed to parse `{LINT_NAME}` configuration: {error}; using defaults" ); Config::default() } @@ -136,7 +163,7 @@ impl<'tcx> LateLintPass<'tcx> for TestMustNotHaveExample { return; }; let attrs = cx.tcx.hir_attrs(item.hir_id()); - self.check_function_item(cx, ItemKindInfo::Item { ident, attrs }, Some(item)); + self.check_function_item(cx, &ItemKindInfo::Item { ident, attrs }, Some(item)); } } @@ -155,11 +182,7 @@ impl<'tcx> LateLintPass<'tcx> for TestMustNotHaveExample { } impl TestMustNotHaveExample { - fn detect_violation( - &self, - attrs: &[hir::Attribute], - is_test: bool, - ) -> Option { + fn detect_violation(attrs: &[hir::Attribute], is_test: bool) -> Option { if !is_test { return None; } @@ -175,13 +198,13 @@ impl TestMustNotHaveExample { fn emit_violation( &self, cx: &LateContext<'_>, - function: FunctionSite<'_>, + function: &FunctionSite<'_>, violation: DocExampleViolation, ) { let messages = localized_messages(&self.localizer, function.name, violation); - let primary = messages.primary().to_string(); - let note = messages.note().to_string(); - let help = messages.help().to_string(); + let primary = messages.primary().to_owned(); + let note = messages.note().to_owned(); + let help = messages.help().to_owned(); cx.emit_span_lint( TEST_MUST_NOT_HAVE_EXAMPLE, @@ -197,20 +220,26 @@ impl TestMustNotHaveExample { fn check_function_item<'tcx>( &mut self, cx: &LateContext<'tcx>, - item_info: ItemKindInfo<'_>, + item_info: &ItemKindInfo<'_>, item: Option<&'tcx hir::Item<'tcx>>, ) { let attrs = item_info.attrs(); - let is_test = if let Some(item) = item { - is_test_function_item(cx, item, attrs, self.additional_test_attributes.as_slice()) - } else { - has_test_like_hir_attributes(attrs, self.additional_test_attributes.as_slice()) - }; + let is_test = item.map_or_else( + || has_test_like_hir_attributes(attrs, self.additional_test_attributes.as_slice()), + |test_item| { + is_test_function_item( + cx, + test_item, + attrs, + self.additional_test_attributes.as_slice(), + ) + }, + ); - if let Some(violation) = self.detect_violation(attrs, is_test) { + if let Some(violation) = Self::detect_violation(attrs, is_test) { self.emit_violation( cx, - FunctionSite { + &FunctionSite { name: item_info.ident().name.as_str(), span: item_info.ident().span, }, @@ -319,13 +348,13 @@ fn localized_messages( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("test"), - FluentValue::from(function_name.to_string()), + FluentValue::from(function_name.to_owned()), ); let reason = violation.note_detail(); args.insert( Cow::Borrowed("reason"), - FluentValue::from(reason.to_string()), + FluentValue::from(reason.to_owned()), ); let resolution = MessageResolution { lint_name: LINT_NAME, diff --git a/crates/test_must_not_have_example/src/heuristics.rs b/crates/test_must_not_have_example/src/heuristics.rs index af5de74a..bba408d8 100644 --- a/crates/test_must_not_have_example/src/heuristics.rs +++ b/crates/test_must_not_have_example/src/heuristics.rs @@ -62,7 +62,10 @@ fn is_examples_heading(line: &str) -> bool { return false; } - let remainder = trimmed[heading_level..].trim_start(); + let Some(after_hashes) = trimmed.get(heading_level..) else { + return false; + }; + let remainder = after_hashes.trim_start(); matches!( remainder .trim_end_matches(|ch: char| ch.is_ascii_whitespace()) @@ -86,9 +89,10 @@ fn is_code_fence(line: &str) -> bool { #[cfg(test)] mod tests { - use super::{DocExampleViolation, detect_example_violation}; use rstest::rstest; + use super::{DocExampleViolation, detect_example_violation}; + #[rstest] #[case("No examples here.", None)] #[case("# Examples", Some(DocExampleViolation::ExamplesHeading))] diff --git a/crates/test_must_not_have_example/src/lib.rs b/crates/test_must_not_have_example/src/lib.rs index e2f8029b..a5ea1715 100644 --- a/crates/test_must_not_have_example/src/lib.rs +++ b/crates/test_must_not_have_example/src/lib.rs @@ -11,8 +11,13 @@ mod heuristics; #[path = "lib_ui_tests.rs"] mod ui; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{TEST_MUST_NOT_HAVE_EXAMPLE, TestMustNotHaveExample}; #[cfg(not(feature = "dylint-driver"))] mod stub { 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 1cf040e8..a97121d8 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 @@ -1,23 +1,23 @@ //! UI harness for `test_must_not_have_example` fixtures. +use std::{fs, io, path::Path}; + use camino::Utf8Path; use dylint_testing::ui::Test; -use std::path::Path; -use std::{fs, io}; use whitaker_common::test_support::{prepare_fixture, run_fixtures_with, run_test_runner}; #[test] fn ui() { let crate_name = env!("CARGO_PKG_NAME"); let directory = "ui"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |crate_name, dir| { - run_fixtures(crate_name, dir) - }) - .unwrap_or_else(|error| { - panic!( - "UI tests should execute without diffs: RunnerFailure {{ crate_name: \"{crate_name}\", directory: \"{directory}\", message: {error} }}" - ) - }); + whitaker::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_clones_core/Cargo.toml b/crates/whitaker_clones_core/Cargo.toml index d9c317d8..4f99eeb7 100644 --- a/crates/whitaker_clones_core/Cargo.toml +++ b/crates/whitaker_clones_core/Cargo.toml @@ -31,6 +31,7 @@ rstest-bdd-macros = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +whitaker_test_macros = { workspace = true } [build-dependencies] camino = { workspace = true } diff --git a/crates/whitaker_clones_core/build.rs b/crates/whitaker_clones_core/build.rs index ffda4682..4f193e05 100644 --- a/crates/whitaker_clones_core/build.rs +++ b/crates/whitaker_clones_core/build.rs @@ -12,7 +12,10 @@ use camino::Utf8PathBuf; mod build_support; use build_support::{ - exact_version, find_workspace_manifest, parser_dependency_requirement, read_workspace_manifest, + exact_version, + find_workspace_manifest, + parser_dependency_requirement, + read_workspace_manifest, }; const PARSER_VERSION_ENV: &str = "WHITAKER_RA_AP_SYNTAX_VERSION"; diff --git a/crates/whitaker_clones_core/build_support.rs b/crates/whitaker_clones_core/build_support.rs index 51860980..ee2a3554 100644 --- a/crates/whitaker_clones_core/build_support.rs +++ b/crates/whitaker_clones_core/build_support.rs @@ -76,8 +76,8 @@ fn read_manifest_text(candidate: &Utf8Path) -> Result, Box Ok(Some(manifest)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error.into()), @@ -118,8 +118,7 @@ fn workspace_manifest_not_found(manifest_dir: &Utf8Path) -> io::Error { io::Error::new( io::ErrorKind::NotFound, format!( - "could not find a parent Cargo.toml with a [workspace] table from `{}`", - manifest_dir + "could not find a parent Cargo.toml with a [workspace] table from `{manifest_dir}`" ), ) } diff --git a/crates/whitaker_clones_core/src/ast/features.rs b/crates/whitaker_clones_core/src/ast/features.rs index 1d12d973..8b48e353 100644 --- a/crates/whitaker_clones_core/src/ast/features.rs +++ b/crates/whitaker_clones_core/src/ast/features.rs @@ -9,7 +9,10 @@ use super::{Depth, KindId, NormalizedNode, NormalizedTree}; /// # Examples /// /// ``` -/// use whitaker_clones_core::{KindCounts, ast::{Depth, KindId}}; +/// use whitaker_clones_core::{ +/// KindCounts, +/// ast::{Depth, KindId}, +/// }; /// /// let counts = KindCounts::default(); /// assert_eq!(counts.count(KindId::new(1), Depth::root()), 0); @@ -57,19 +60,13 @@ impl KindWeight { /// Returns a zero weight. #[must_use] - pub const fn zero() -> Self { - Self(0) - } + pub const fn zero() -> Self { Self(0) } /// Returns the fixed-point value. #[must_use] - pub const fn get(self) -> u128 { - self.0 - } + pub const fn get(self) -> u128 { self.0 } - const fn from_raw(value: u128) -> Self { - Self(value) - } + const fn from_raw(value: u128) -> Self { Self(value) } } /// Depth-weighted histogram keyed by syntax kind. @@ -87,9 +84,7 @@ pub struct KindHistogram(BTreeMap); impl KindHistogram { /// Returns the weight for `kind`, if present. #[must_use] - pub fn get(&self, kind: KindId) -> Option { - self.0.get(&kind).copied() - } + pub fn get(&self, kind: KindId) -> Option { self.0.get(&kind).copied() } /// Iterates over kind weights in deterministic key order. pub fn iter(&self) -> impl Iterator + '_ { @@ -163,7 +158,6 @@ impl ProductionMultiset { } /// Extracts exact kind counts from `tree`. -/// #[must_use] pub fn kind_counts(tree: &NormalizedTree) -> KindCounts { let mut counts = KindCounts::default(); @@ -216,9 +210,7 @@ fn count_node_kinds(node: &NormalizedNode, depth: Depth, counts: &mut KindCounts } } -fn next_depth(depth: Depth) -> Depth { - Depth::new(depth.get().saturating_add(1)) -} +const fn next_depth(depth: Depth) -> Depth { Depth::new(depth.get().saturating_add(1)) } // Weights halve with depth (`2^-depth` in fixed point). Below the fixed-point // resolution the weight intentionally collapses to zero: depths 64..=127 shift @@ -272,10 +264,10 @@ mod tests { proptest! { #[test] fn each_representable_count_increases_its_weight( - depth in 0_u16..64, + raw_depth in 0_u16..64, count in 0_u32..u32::MAX ) { - let depth = Depth::new(depth); + let depth = Depth::new(raw_depth); let current_weight = weighted_contribution(depth, count); let increased_weight = weighted_contribution(depth, count + 1); diff --git a/crates/whitaker_clones_core/src/ast/hash.rs b/crates/whitaker_clones_core/src/ast/hash.rs index a56240e6..3a6945e8 100644 --- a/crates/whitaker_clones_core/src/ast/hash.rs +++ b/crates/whitaker_clones_core/src/ast/hash.rs @@ -4,7 +4,12 @@ use std::fmt; use super::{LeafClass, NormalizedNode, NormalizedTree}; use crate::hashing::{ - FNV_OFFSET_BASIS, PARSER_SCHEMA_VERSION, mix_byte, mix_bytes, mix_u16, mix_u64, + FNV_OFFSET_BASIS, + PARSER_SCHEMA_VERSION, + mix_byte, + mix_bytes, + mix_u16, + mix_u64, }; /// Opaque canonical AST subtree hash. @@ -12,8 +17,10 @@ use crate::hashing::{ /// # Examples /// /// ``` -/// use whitaker_clones_core::ast::{ByteSpan, KindId, NormalizedNode, NormalizedTree}; -/// use whitaker_clones_core::canonical_hash; +/// use whitaker_clones_core::{ +/// ast::{ByteSpan, KindId, NormalizedNode, NormalizedTree}, +/// canonical_hash, +/// }; /// /// let span = ByteSpan::new("fn f() {}", 0, 2)?; /// let tree = NormalizedTree::new(NormalizedNode::new(KindId::new(1), None, Vec::new()), span); @@ -26,9 +33,7 @@ pub struct AstHash(u64); impl AstHash { /// Renders the hash as a fixed-width lowercase hexadecimal string. #[must_use] - pub fn to_hex(&self) -> String { - format!("{:016x}", self.0) - } + pub fn to_hex(&self) -> String { format!("{:016x}", self.0) } } impl fmt::Display for AstHash { @@ -44,31 +49,28 @@ pub fn canonical_hash(tree: &NormalizedTree) -> AstHash { AstHash(hash_node(seed, tree.root())) } -fn seed_hash() -> u64 { - mix_bytes(FNV_OFFSET_BASIS, PARSER_SCHEMA_VERSION.as_bytes()) -} +fn seed_hash() -> u64 { mix_bytes(FNV_OFFSET_BASIS, PARSER_SCHEMA_VERSION.as_bytes()) } fn hash_node(seed: u64, node: &NormalizedNode) -> u64 { - let mut pending = vec![(node, 0, hash_node_header(seed, node))]; - loop { - let Some((current, child_index, _)) = pending.last_mut() else { - unreachable!("hash_node seeds pending with one node and returns before it empties") - }; - if let Some(child) = current.children().get(*child_index) { - *child_index += 1; + // Pop-based traversal keeps every stack access provably in bounds: each + // iteration pops the top entry, either revisits it with the next child + // pushed on top, or folds its completed hash into the parent. The final + // completed hash is the root's, so the loop needs no unreachable arms. + let mut pending = vec![(node, 0_usize, hash_node_header(seed, node))]; + let mut completed_hash = 0_u64; + while let Some((current, child_index, current_hash)) = pending.pop() { + if let Some(child) = current.children().get(child_index) { + pending.push((current, child_index.saturating_add(1), current_hash)); pending.push((child, 0, hash_node_header(seed, child))); continue; } - let Some((_, _, completed_hash)) = pending.pop() else { - unreachable!("hash_node pops the node it just observed through last_mut") - }; + completed_hash = current_hash; if let Some((_, _, parent_hash)) = pending.last_mut() { - *parent_hash = mix_u64(*parent_hash, completed_hash); - } else { - return completed_hash; + *parent_hash = mix_u64(*parent_hash, current_hash); } } + completed_hash } fn hash_node_header(mut hash: u64, node: &NormalizedNode) -> u64 { @@ -83,7 +85,7 @@ fn child_count(node: &NormalizedNode) -> u64 { u64::try_from(node.children().len()).unwrap_or(u64::MAX) } -fn leaf_tag(leaf: Option) -> u8 { +const fn leaf_tag(leaf: Option) -> u8 { match leaf { Some(LeafClass::Ident) => b'i', Some(LeafClass::Literal) => b'l', diff --git a/crates/whitaker_clones_core/src/ast/kani.rs b/crates/whitaker_clones_core/src/ast/kani.rs index bda926fb..4c8c2538 100644 --- a/crates/whitaker_clones_core/src/ast/kani.rs +++ b/crates/whitaker_clones_core/src/ast/kani.rs @@ -6,7 +6,13 @@ use std::ops::Range; use super::{ - ByteSpan, Depth, KindId, LeafClass, NormalizedNode, NormalizedTree, kind_counts, + ByteSpan, + Depth, + KindId, + LeafClass, + NormalizedNode, + NormalizedTree, + kind_counts, select_smallest_covering, }; @@ -16,9 +22,7 @@ const KANI_AST_UNWIND: usize = 5; const _: () = assert!(KANI_AST_MAX_CHILDREN == 2); const _: () = assert!(KANI_AST_UNWIND == KANI_AST_MAX_DEPTH + 2); -fn symbolic_kind() -> KindId { - KindId::new(kani::any()) -} +fn symbolic_kind() -> KindId { KindId::new(kani::any()) } fn ast_span() -> ByteSpan { match ByteSpan::new("abcd", 0, 1) { diff --git a/crates/whitaker_clones_core/src/ast/lowering.rs b/crates/whitaker_clones_core/src/ast/lowering.rs index 96f0b38f..d74b8b6d 100644 --- a/crates/whitaker_clones_core/src/ast/lowering.rs +++ b/crates/whitaker_clones_core/src/ast/lowering.rs @@ -1,16 +1,21 @@ //! Adapter from parser syntax trees into the parser-agnostic AST domain. -use std::cell::Cell; -use std::ops::Range; +use std::{cell::Cell, ops::Range}; use ra_ap_syntax::{ - AstNode, Edition, NodeOrToken, SourceFile, SyntaxKind, SyntaxNode, SyntaxToken, TextRange, + AstNode, + Edition, + NodeOrToken, + SourceFile, + SyntaxKind, + SyntaxNode, + SyntaxToken, + TextRange, TextSize, }; use tracing::{debug, error, warn}; use super::{AstError, AstResult, ByteSpan, KindId, LeafClass, NormalizedNode, NormalizedTree}; - pub use crate::hashing::PARSER_SCHEMA_VERSION; const MAX_AST_NODES: usize = 10_000; @@ -47,6 +52,13 @@ fn trace_ast_error( /// Latency metrics and feature-vector emission metrics are deferred to 7.3.2, /// where scoring and SARIF emission consume those observations. /// +/// # Errors +/// +/// Returns an [`AstError`] when the span fails re-validation against +/// `file_text`, when no syntax node covers the requested span, when the +/// selected subtree contains parser error elements, or when lowering exceeds +/// the depth or node budgets. +/// /// # Examples /// /// ``` @@ -60,7 +72,9 @@ fn trace_ast_error( /// ``` #[tracing::instrument(skip(file_text), fields(start = span.start(), end = span.end()))] pub fn lower_span(file_text: &str, span: ByteSpan) -> AstResult { - let span = ByteSpan::new(file_text, span.start(), span.end()).map_err(|error| { + // Re-validation returns a span with identical offsets, so the original + // binding can keep serving later uses without a shadowing rebind. + ByteSpan::new(file_text, span.start(), span.end()).map_err(|error| { trace_ast_error( error, "AST span lies outside the supplied source text", @@ -234,11 +248,9 @@ struct LoweringLimits { } impl LoweringLimits { - fn new(span: ByteSpan) -> Self { - Self::with_depth_limit(MAX_AST_DEPTH, span) - } + const fn new(span: ByteSpan) -> Self { Self::with_depth_limit(MAX_AST_DEPTH, span) } - fn with_depth_limit(maximum_depth: usize, span: ByteSpan) -> Self { + const fn with_depth_limit(maximum_depth: usize, span: ByteSpan) -> Self { Self { maximum_depth, maximum_nodes: MAX_AST_NODES, @@ -301,7 +313,7 @@ impl LoweringLimits { Ok(NormalizedNode::new(kind_id(node.kind()), None, children)) } - fn unparsable_span(&self) -> AstError { + const fn unparsable_span(&self) -> AstError { AstError::UnparsableSpan { start: self.span.start(), end: self.span.end(), @@ -331,17 +343,13 @@ fn is_identifier_like(kind: SyntaxKind) -> bool { kind == SyntaxKind::LIFETIME_IDENT || kind.is_any_identifier() } -fn kind_id(kind: SyntaxKind) -> KindId { - KindId::new(u16::from(kind)) -} +fn kind_id(kind: SyntaxKind) -> KindId { KindId::new(u16::from(kind)) } fn text_range(span: ByteSpan) -> TextRange { TextRange::new(TextSize::from(span.start()), TextSize::from(span.end())) } -fn range_to_u32(range: TextRange) -> Range { - u32::from(range.start())..u32::from(range.end()) -} +fn range_to_u32(range: TextRange) -> Range { u32::from(range.start())..u32::from(range.end()) } #[cfg(test)] #[path = "lowering_tests.rs"] diff --git a/crates/whitaker_clones_core/src/ast/lowering_tests.rs b/crates/whitaker_clones_core/src/ast/lowering_tests.rs index 28f6878a..c779c150 100644 --- a/crates/whitaker_clones_core/src/ast/lowering_tests.rs +++ b/crates/whitaker_clones_core/src/ast/lowering_tests.rs @@ -1,19 +1,28 @@ //! Adapter-scoped tests for Rust syntax lowering. use insta::assert_json_snapshot; +use ra_ap_syntax::{AstNode, Edition, SourceFile}; use rstest::rstest; use serde_json::json; -use ra_ap_syntax::{AstNode, Edition, SourceFile}; - use super::{ - LoweringLimits, MAX_AST_DEPTH, MAX_AST_NODES, kind_id, leaf_class, + LoweringLimits, + MAX_AST_DEPTH, + MAX_AST_NODES, + kind_id, + leaf_class, validate_covering_node_budget, }; use crate::{ - AstError, ByteSpan, Production, + AstError, + AstResult, + ByteSpan, + Production, ast::{KindId, LeafClass, NormalizedNode, NormalizedTree, PARSER_SCHEMA_VERSION}, - canonical_hash, kind_counts, lower_span, production_multiset, + canonical_hash, + kind_counts, + lower_span, + production_multiset, }; fn kind_name(kind: KindId) -> String { @@ -21,6 +30,14 @@ fn kind_name(kind: KindId) -> String { format!("{parser_kind:?}") } +fn offset_u32(offset: usize) -> AstResult { + u32::try_from(offset).map_err(|_| AstError::OffsetTooLarge(offset)) +} + +fn whole_span(source: &str) -> AstResult { + ByteSpan::new(source, 0, offset_u32(source.len())?) +} + #[rstest] fn pinned_parser_snapshot_parses_current_edition_source() { let tree = lower_span_for("fn f() {}", "fn f").expect("source should lower"); @@ -49,35 +66,33 @@ fn two_sibling_span_selects_common_expression_ancestor() { } #[rstest] -fn whole_file_span_selects_source_file() -> Result<(), AstError> { +fn whole_file_span_selects_source_file() { let source = "fn f() {}"; - let span = ByteSpan::new(source, 0, source.len() as u32)?; - let tree = lower_span(source, span)?; + let span = whole_span(source).expect("span should validate"); + let tree = lower_span(source, span).expect("source should lower"); assert_eq!(kind_name(tree.root().kind()), "SOURCE_FILE"); - Ok(()) } #[rstest] -fn large_synthetic_source_still_lowers() -> Result<(), AstError> { +fn large_synthetic_source_still_lowers() { let statements = (0..600) .map(|index| format!("let value_{index} = {index};")) .collect::>() .join(" "); let source = format!("fn generated() {{ {statements} }}"); - let span = ByteSpan::new(&source, 0, source.len() as u32)?; - let tree = lower_span(&source, span)?; + let span = whole_span(&source).expect("span should validate"); + let tree = lower_span(&source, span).expect("source should lower"); assert_eq!(kind_name(tree.root().kind()), "SOURCE_FILE"); - Ok(()) } #[rstest] -fn oversized_source_is_rejected_by_the_node_budget() -> Result<(), AstError> { +fn oversized_source_is_rejected_by_the_node_budget() { let statements = (0..=MAX_AST_NODES) .map(|index| format!("let value_{index} = {index};")) .collect::>() .join(" "); let source = format!("fn generated() {{ {statements} }}"); - let span = ByteSpan::new(&source, 0, source.len() as u32)?; + let span = whole_span(&source).expect("span should validate"); assert_eq!( lower_span(&source, span), @@ -85,30 +100,28 @@ fn oversized_source_is_rejected_by_the_node_budget() -> Result<(), AstError> { limit: MAX_AST_NODES }) ); - Ok(()) } #[rstest] -fn deeply_nested_syntax_obeys_the_lowering_depth_budget() -> Result<(), AstError> { +fn deeply_nested_syntax_obeys_the_lowering_depth_budget() { let source = "fn f() { if true { if true { if true { if true { 0; } } } } }"; let root = SourceFile::parse(source, Edition::CURRENT) .tree() .syntax() .clone(); - let span = ByteSpan::new(source, 0, source.len() as u32)?; + let span = whole_span(source).expect("span should validate"); assert_eq!( LoweringLimits::with_depth_limit(2, span).lower(&root, 0), Err(AstError::TreeTooDeep { limit: 2 }) ); - Ok(()) } #[rstest] -fn covering_node_selection_budget_surfaces_typed_errors() -> Result<(), AstError> { +fn covering_node_selection_budget_surfaces_typed_errors() { // The selection budget guards the covering-node walk independently of the // lowering budget, and both breaches must surface as the same typed errors. - let span = ByteSpan::new("fn f() {}", 0, 2)?; + let span = ByteSpan::new("fn f() {}", 0, 2).expect("span should validate"); assert_eq!( validate_covering_node_budget(span, MAX_AST_DEPTH + 1, 0), Err(AstError::TreeTooDeep { @@ -121,11 +134,10 @@ fn covering_node_selection_budget_surfaces_typed_errors() -> Result<(), AstError limit: MAX_AST_NODES }) ); - Ok(()) } #[rstest] -fn small_candidate_amid_unrelated_nodes_is_not_rejected_by_the_budget() -> Result<(), AstError> { +fn small_candidate_amid_unrelated_nodes_is_not_rejected_by_the_budget() { // A tiny valid candidate (`a + b`) buried in a function whose remaining // statements far exceed the node budget. Pruned covering-node selection must // descend only the ancestor chain, so the unrelated statements neither count @@ -138,7 +150,6 @@ fn small_candidate_amid_unrelated_nodes_is_not_rejected_by_the_budget() -> Resul let tree = lower_span_for(&source, "a + b").expect("small candidate should lower"); assert_eq!(kind_name(tree.root().kind()), "BIN_EXPR"); - Ok(()) } #[rstest] @@ -165,18 +176,17 @@ fn span_validation_reports_specific_errors( } #[rstest] -fn source_mismatch_non_char_boundary_is_reported_by_lowering() -> Result<(), AstError> { - let span = ByteSpan::new("ab", 0, 1)?; +fn source_mismatch_non_char_boundary_is_reported_by_lowering() { + let span = ByteSpan::new("ab", 0, 1).expect("span should validate"); assert_eq!( lower_span("é", span), Err(AstError::NonCharBoundary { offset: 1 }) ); - Ok(()) } #[rstest] -fn source_mismatch_out_of_bounds_is_reported_by_lowering() -> Result<(), AstError> { - let span = ByteSpan::new("longer", 0, 6)?; +fn source_mismatch_out_of_bounds_is_reported_by_lowering() { + let span = ByteSpan::new("longer", 0, 6).expect("span should validate"); assert_eq!( lower_span("short", span), Err(AstError::SpanOutOfBounds { @@ -185,21 +195,17 @@ fn source_mismatch_out_of_bounds_is_reported_by_lowering() -> Result<(), AstErro len: 5 }) ); - Ok(()) } #[rstest] -fn error_subtree_is_rejected() -> Result<(), AstError> { +fn error_subtree_is_rejected() { let source = "@error@"; - let span = ByteSpan::new(source, 0, source.len() as u32)?; + let end = offset_u32(source.len()).expect("source length should fit in u32"); + let span = ByteSpan::new(source, 0, end).expect("span should validate"); assert_eq!( lower_span(source, span), - Err(AstError::UnparsableSpan { - start: 0, - end: source.len() as u32 - }) + Err(AstError::UnparsableSpan { start: 0, end }) ); - Ok(()) } fn lower_span_for(source: &str, needle: &str) -> Result { @@ -207,7 +213,10 @@ fn lower_span_for(source: &str, needle: &str) -> Result bool { @@ -236,7 +245,7 @@ fn kind_names_are_available_for_adapter_snapshots() { #[rstest] fn ast_feature_vector_snapshot() -> Result<(), AstError> { let source = "fn add(a: i32, b: i32) -> i32 { a + b }"; - let span = ByteSpan::new(source, 0, source.len() as u32)?; + let span = whole_span(source)?; let tree = lower_span(source, span)?; let counts = kind_counts(&tree) .iter() diff --git a/crates/whitaker_clones_core/src/ast/mod.rs b/crates/whitaker_clones_core/src/ast/mod.rs index 1458a54b..59f83891 100644 --- a/crates/whitaker_clones_core/src/ast/mod.rs +++ b/crates/whitaker_clones_core/src/ast/mod.rs @@ -37,8 +37,15 @@ mod tree; pub use cover::select_smallest_covering; pub use error::{AstError, AstResult}; pub use features::{ - KindCounts, KindHistogram, KindWeight, Production, ProductionMultiset, kind_counts, - kind_histogram, production_multiset, weighted_histogram, + KindCounts, + KindHistogram, + KindWeight, + Production, + ProductionMultiset, + kind_counts, + kind_histogram, + production_multiset, + weighted_histogram, }; pub use hash::{AstHash, canonical_hash}; pub use lowering::{PARSER_SCHEMA_VERSION, lower_span}; diff --git a/crates/whitaker_clones_core/src/ast/tests.rs b/crates/whitaker_clones_core/src/ast/tests.rs index 5560ed1f..c8fbd24d 100644 --- a/crates/whitaker_clones_core/src/ast/tests.rs +++ b/crates/whitaker_clones_core/src/ast/tests.rs @@ -1,13 +1,26 @@ //! Tests for parser-independent AST feature extraction. -use super::{ - AstResult, ByteSpan, Depth, KindId, KindWeight, LeafClass, NormalizedNode, NormalizedTree, - Production, canonical_hash, kind_counts, kind_histogram, production_multiset, - select_smallest_covering, weighted_histogram, -}; use proptest::prelude::*; use rstest::rstest; +use super::{ + AstResult, + ByteSpan, + Depth, + KindId, + KindWeight, + LeafClass, + NormalizedNode, + NormalizedTree, + Production, + canonical_hash, + kind_counts, + kind_histogram, + production_multiset, + select_smallest_covering, + weighted_histogram, +}; + #[cfg(not(feature = "parser"))] #[rstest] fn parser_free_lowering_reports_parser_unavailable() -> AstResult<()> { @@ -57,18 +70,19 @@ fn equal_width_covering_candidates_select_the_first() { } #[rstest] -fn deeply_nested_trees_extract_features_and_hash_without_recursion() -> AstResult<()> { - let tree = tree_with_root(deep_chain(2_048))?; +fn deeply_nested_trees_extract_features_and_hash_without_recursion() { + let tree = tree_with_root(deep_chain(2_048)).expect("static test span should be valid"); assert_eq!(kind_counts(&tree).iter().count(), 2_049); assert_eq!(production_multiset(&tree).bigrams().count(), 1); assert_eq!(canonical_hash(&tree).to_hex().len(), 16); - Ok(()) } #[rstest] -fn kind_counts_record_depth_resolved_counts() -> AstResult<()> { - let counts = kind_counts(&feature_tree()?); +fn kind_counts_record_depth_resolved_counts() { + let tree = feature_tree().expect("static test span should be valid"); + + let counts = kind_counts(&tree); let expected = [ (KindId::new(1), Depth::root(), 1), @@ -79,13 +93,13 @@ fn kind_counts_record_depth_resolved_counts() -> AstResult<()> { ]; assert_eq!(counts.iter().collect::>(), expected); - - Ok(()) } #[rstest] -fn weighted_histogram_applies_dyadic_depth_weights() -> AstResult<()> { - let histogram = kind_histogram(&feature_tree()?); +fn weighted_histogram_applies_dyadic_depth_weights() { + let tree = feature_tree().expect("static test span should be valid"); + + let histogram = kind_histogram(&tree); assert_eq!( histogram.get(KindId::new(1)).map(KindWeight::get), @@ -99,29 +113,29 @@ fn weighted_histogram_applies_dyadic_depth_weights() -> AstResult<()> { histogram.get(KindId::new(4)).map(KindWeight::get), Some(KindWeight::SCALE >> 2) ); - - Ok(()) } #[rstest] -fn weighted_histogram_accumulates_four_equal_depth_one_kinds() -> AstResult<()> { +fn weighted_histogram_accumulates_four_equal_depth_one_kinds() { let kind = KindId::new(9); let tree = tree_with_root(NormalizedNode::new( KindId::new(1), None, (0..4).map(|_| ident(kind)).collect(), - ))?; + )) + .expect("static test span should be valid"); assert_eq!( kind_histogram(&tree).get(kind).map(KindWeight::get), Some(4 * (KindWeight::SCALE >> 1)) ); - Ok(()) } #[rstest] -fn production_multiset_records_bigrams_and_trigrams() -> AstResult<()> { - let productions = production_multiset(&feature_tree()?); +fn production_multiset_records_bigrams_and_trigrams() { + let tree = feature_tree().expect("static test span should be valid"); + + let productions = production_multiset(&tree); assert_eq!( productions.count(Production::Bigram(KindId::new(1), KindId::new(2))), @@ -147,38 +161,30 @@ fn production_multiset_records_bigrams_and_trigrams() -> AstResult<()> { )), 0 ); - - Ok(()) } #[rstest] -fn canonical_hash_is_stable_for_equivalent_trees() -> AstResult<()> { - assert_eq!( - canonical_hash(&feature_tree()?), - canonical_hash(&feature_tree()?) - ); +fn canonical_hash_is_stable_for_equivalent_trees() { + let first = feature_tree().expect("static test span should be valid"); + let second = feature_tree().expect("static test span should be valid"); - Ok(()) + assert_eq!(canonical_hash(&first), canonical_hash(&second)); } #[rstest] -fn canonical_hash_is_sensitive_to_child_order() -> AstResult<()> { - assert_ne!( - canonical_hash(&feature_tree()?), - canonical_hash(&reordered_tree()?) - ); +fn canonical_hash_is_sensitive_to_child_order() { + let tree = feature_tree().expect("static test span should be valid"); + let reordered = reordered_tree().expect("static test span should be valid"); - Ok(()) + assert_ne!(canonical_hash(&tree), canonical_hash(&reordered)); } #[rstest] -fn canonical_hash_is_sensitive_to_leaf_class() -> AstResult<()> { - assert_ne!( - canonical_hash(&feature_tree()?), - canonical_hash(&different_leaf_tree()?) - ); +fn canonical_hash_is_sensitive_to_leaf_class() { + let tree = feature_tree().expect("static test span should be valid"); + let different = different_leaf_tree().expect("static test span should be valid"); - Ok(()) + assert_ne!(canonical_hash(&tree), canonical_hash(&different)); } fn feature_tree() -> AstResult { @@ -233,13 +239,14 @@ fn literal(kind: KindId) -> NormalizedNode { } #[rstest] -fn feature_functions_reflect_tree_contents() -> AstResult<()> { - let expected = feature_tree()?; +fn feature_functions_reflect_tree_contents() { + let expected = feature_tree().expect("static test span should be valid"); let distinct = tree_with_root(NormalizedNode::new( KindId::new(9), None, vec![literal(KindId::new(8))], - ))?; + )) + .expect("static test span should be valid"); assert_ne!(kind_counts(&expected), kind_counts(&distinct)); assert_ne!(kind_histogram(&expected), kind_histogram(&distinct)); @@ -248,7 +255,6 @@ fn feature_functions_reflect_tree_contents() -> AstResult<()> { production_multiset(&distinct) ); assert_ne!(canonical_hash(&expected), canonical_hash(&distinct)); - Ok(()) } proptest! { diff --git a/crates/whitaker_clones_core/src/ast/tree.rs b/crates/whitaker_clones_core/src/ast/tree.rs index 38ad82c2..8275e368 100644 --- a/crates/whitaker_clones_core/src/ast/tree.rs +++ b/crates/whitaker_clones_core/src/ast/tree.rs @@ -20,15 +20,11 @@ pub struct KindId(u16); impl KindId { /// Creates an opaque syntax-kind identifier. #[must_use] - pub const fn new(value: u16) -> Self { - Self(value) - } + pub const fn new(value: u16) -> Self { Self(value) } /// Returns the opaque numeric value. #[must_use] - pub const fn get(self) -> u16 { - self.0 - } + pub const fn get(self) -> u16 { self.0 } } /// Tree depth relative to the lowered subtree root. @@ -46,21 +42,15 @@ pub struct Depth(u16); impl Depth { /// Returns the root depth. #[must_use] - pub const fn root() -> Self { - Self(0) - } + pub const fn root() -> Self { Self(0) } /// Creates a depth value. #[must_use] - pub const fn new(value: u16) -> Self { - Self(value) - } + pub const fn new(value: u16) -> Self { Self(value) } /// Returns the underlying depth. #[must_use] - pub const fn get(self) -> u16 { - self.0 - } + pub const fn get(self) -> u16 { self.0 } } /// Normalized leaf token class for Type-2-style leaf erasure. @@ -97,7 +87,7 @@ pub enum LeafClass { pub struct NormalizedNode { kind: KindId, leaf: Option, - children: Vec, + children: Vec, } impl NormalizedNode { @@ -111,7 +101,7 @@ impl NormalizedNode { /// leaf-erasure feature extraction rely on that invariant to stay /// unambiguous. #[must_use] - pub fn new(kind: KindId, leaf: Option, children: Vec) -> Self { + pub fn new(kind: KindId, leaf: Option, children: Vec) -> Self { debug_assert!( leaf.is_none() || children.is_empty(), "a leaf-tagged NormalizedNode must have no children" @@ -125,21 +115,15 @@ impl NormalizedNode { /// Returns the node kind. #[must_use] - pub const fn kind(&self) -> KindId { - self.kind - } + pub const fn kind(&self) -> KindId { self.kind } /// Returns the optional leaf class. #[must_use] - pub const fn leaf(&self) -> Option { - self.leaf - } + pub const fn leaf(&self) -> Option { self.leaf } /// Returns the ordered child nodes. #[must_use] - pub fn children(&self) -> &[NormalizedNode] { - &self.children - } + pub fn children(&self) -> &[Self] { &self.children } } /// Lowered candidate subtree plus its source span. @@ -163,21 +147,15 @@ pub struct NormalizedTree { impl NormalizedTree { /// Creates a lowered tree. #[must_use] - pub const fn new(root: NormalizedNode, span: ByteSpan) -> Self { - Self { root, span } - } + pub const fn new(root: NormalizedNode, span: ByteSpan) -> Self { Self { root, span } } /// Returns the lowered root node. #[must_use] - pub const fn root(&self) -> &NormalizedNode { - &self.root - } + pub const fn root(&self) -> &NormalizedNode { &self.root } /// Returns the source span represented by this tree. #[must_use] - pub const fn span(&self) -> ByteSpan { - self.span - } + pub const fn span(&self) -> ByteSpan { self.span } } /// Half-open byte span over source text. @@ -199,7 +177,15 @@ pub struct ByteSpan { impl ByteSpan { /// Validates and creates a half-open byte span. - pub fn new(source_text: &str, start: u32, end: u32) -> AstResult { + /// + /// # Errors + /// + /// Returns [`AstError::InvalidSpan`] when `end` precedes `start`, + /// [`AstError::EmptySpan`] when the span is zero width, + /// [`AstError::SpanOutOfBounds`] when the span exceeds `source_text`, and + /// [`AstError::NonCharBoundary`] when either offset splits a UTF-8 + /// character. + pub const fn new(source_text: &str, start: u32, end: u32) -> AstResult { if end < start { return Err(AstError::InvalidSpan { start, end }); } @@ -225,13 +211,9 @@ impl ByteSpan { /// Returns the start offset. #[must_use] - pub const fn start(self) -> u32 { - self.start - } + pub const fn start(self) -> u32 { self.start } /// Returns the exclusive end offset. #[must_use] - pub const fn end(self) -> u32 { - self.end - } + pub const fn end(self) -> u32 { self.end } } diff --git a/crates/whitaker_clones_core/src/hashing.rs b/crates/whitaker_clones_core/src/hashing.rs index 9162970a..699c176f 100644 --- a/crates/whitaker_clones_core/src/hashing.rs +++ b/crates/whitaker_clones_core/src/hashing.rs @@ -80,7 +80,12 @@ pub(crate) fn mix_bytes(mut current: u64, bytes: &[u8]) -> u64 { /// assert_eq!(mix_u16(FNV_OFFSET_BASIS, 0x1234), 0x0832_9407_b4eb_8443); /// ``` pub(crate) fn mix_u16(current: u64, value: u16) -> u64 { - mix_bytes(current, &value.to_le_bytes()) + // Decompose into little-endian bytes with explicit masks and shifts so the + // serialization order is spelled out rather than delegated to an + // endianness-specific method. + let low = (value & 0x00ff) as u8; + let high = (value >> 8) as u8; + mix_bytes(current, &[low, high]) } /// Mixes a `u64` using little-endian bytes. @@ -97,8 +102,16 @@ pub(crate) fn mix_u16(current: u64, value: u16) -> u64 { /// 0x999a_7071_7b39_65dd /// ); /// ``` -pub(crate) fn mix_u64(current: u64, value: u64) -> u64 { - mix_bytes(current, &value.to_le_bytes()) +pub(crate) fn mix_u64(mut current: u64, value: u64) -> u64 { + // Mix the eight little-endian bytes lowest first, using explicit masks and + // shifts so the serialization order is spelled out rather than delegated to + // an endianness-specific method. + let mut remaining = value; + for _ in 0..8 { + current = mix_byte(current, (remaining & 0xff) as u8); + remaining >>= 8; + } + current } #[cfg(test)] diff --git a/crates/whitaker_clones_core/src/index/error.rs b/crates/whitaker_clones_core/src/index/error.rs index 58de6ee7..63bb67b7 100644 --- a/crates/whitaker_clones_core/src/index/error.rs +++ b/crates/whitaker_clones_core/src/index/error.rs @@ -1,4 +1,4 @@ -//! Error types for MinHash sketching and LSH configuration. +//! Error types for `MinHash` sketching and LSH configuration. use thiserror::Error; @@ -16,17 +16,17 @@ pub enum IndexError { /// The number of rows per band must be greater than zero. #[error("LSH rows must be greater than zero")] ZeroRows, - /// The band and row product must equal the fixed MinHash sketch size. + /// The band and row product must equal the fixed `MinHash` sketch size. #[error("LSH bands ({bands}) multiplied by rows ({rows}) must equal {expected}")] InvalidBandRowProduct { /// Requested number of bands. bands: usize, /// Requested number of rows per band. rows: usize, - /// Required fixed MinHash sketch size. + /// Required fixed `MinHash` sketch size. expected: usize, }, - /// MinHash requires at least one retained fingerprint hash. + /// `MinHash` requires at least one retained fingerprint hash. #[error("retained fingerprints must not be empty")] EmptyFingerprintSet, } diff --git a/crates/whitaker_clones_core/src/index/fragment_id.rs b/crates/whitaker_clones_core/src/index/fragment_id.rs index 09cd683c..a16f045c 100644 --- a/crates/whitaker_clones_core/src/index/fragment_id.rs +++ b/crates/whitaker_clones_core/src/index/fragment_id.rs @@ -18,15 +18,11 @@ impl FragmentId { /// assert_eq!(id.as_str(), "src/lib.rs:10..20"); /// ``` #[must_use] - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } + pub fn new(value: impl Into) -> Self { Self(value.into()) } /// Returns the fragment identifier as a string slice. #[must_use] - pub fn as_str(&self) -> &str { - self.0.as_str() - } + pub const fn as_str(&self) -> &str { self.0.as_str() } /// Consumes the identifier and returns the owned string. /// @@ -39,27 +35,19 @@ impl FragmentId { /// assert_eq!(id.into_inner(), "fragment-a".to_owned()); /// ``` #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl From<&str> for FragmentId { - fn from(value: &str) -> Self { - Self::new(value) - } + fn from(value: &str) -> Self { Self::new(value) } } impl From for FragmentId { - fn from(value: String) -> Self { - Self::new(value) - } + fn from(value: String) -> Self { Self::new(value) } } impl AsRef for FragmentId { - fn as_ref(&self) -> &str { - self.as_str() - } + fn as_ref(&self) -> &str { self.as_str() } } impl fmt::Display for FragmentId { diff --git a/crates/whitaker_clones_core/src/index/kani.rs b/crates/whitaker_clones_core/src/index/kani.rs index 958d2c37..a90194b8 100644 --- a/crates/whitaker_clones_core/src/index/kani.rs +++ b/crates/whitaker_clones_core/src/index/kani.rs @@ -17,36 +17,39 @@ //! coverage: //! //! - `verify_lsh_config_new_smoke` checks one accepted concrete path. -//! - `verify_lsh_config_new_symbolic` exhausts the constructor across the -//! bounded `[0, 128]²` input space. -//! - `verify_lsh_config_new_overflow_product` drives the `checked_mul(None)` -//! branch with non-zero overflowing inputs. -//! - `verify_min_hasher_sketch_rejects_empty_input` checks the empty -//! retained-fingerprint boundary. -//! - `verify_min_hasher_sketch_is_deterministic` checks first, middle, and last -//! signature lanes for wide boundary-hash inputs. -//! - `verify_min_hasher_sketch_ignores_duplicate_hashes` compares a wide -//! boundary-hash set against the same set with repeated hashes. -//! - `verify_lsh_index_rejects_self_pairs` checks that repeated insertion of -//! one fragment cannot produce a self-pair. -//! - `verify_lsh_index_canonicalizes_pair_order` checks that reverse lexical -//! insertion still emits one canonical pair. -//! - `verify_lsh_index_deduplicates_repeated_band_collisions` checks that two -//! fragments colliding in two bands still emit one candidate pair. -//! - `verify_lsh_index_is_insertion_order_independent` checks that a bounded -//! three-fragment index produces the same candidates in forward and reverse -//! insertion order. - -use crate::token::Fingerprint; +//! - `verify_lsh_config_new_symbolic` exhausts the constructor across the bounded `[0, 128]²` input +//! space. +//! - `verify_lsh_config_new_overflow_product` drives the `checked_mul(None)` branch with non-zero +//! overflowing inputs. +//! - `verify_min_hasher_sketch_rejects_empty_input` checks the empty retained-fingerprint boundary. +//! - `verify_min_hasher_sketch_is_deterministic` checks first, middle, and last signature lanes for +//! wide boundary-hash inputs. +//! - `verify_min_hasher_sketch_ignores_duplicate_hashes` compares a wide boundary-hash set against +//! the same set with repeated hashes. +//! - `verify_lsh_index_rejects_self_pairs` checks that repeated insertion of one fragment cannot +//! produce a self-pair. +//! - `verify_lsh_index_canonicalizes_pair_order` checks that reverse lexical insertion still emits +//! one canonical pair. +//! - `verify_lsh_index_deduplicates_repeated_band_collisions` checks that two fragments colliding +//! in two bands still emit one candidate pair. +//! - `verify_lsh_index_is_insertion_order_independent` checks that a bounded three-fragment index +//! produces the same candidates in forward and reverse insertion order. use super::{ - CandidatePair, FragmentId, IndexError, LshConfig, LshIndex, MINHASH_SIZE, MinHashSignature, + CandidatePair, + FragmentId, + IndexError, + LshConfig, + LshIndex, + MINHASH_SIZE, + MinHashSignature, MinHasher, }; +use crate::token::Fingerprint; -const KANI_MINHASH_SEED: u64 = 0xA076_1D64_78BD_642F; -const KANI_MINHASH_MIDDLE_SEED: u64 = 0xE703_7ED1_A0B4_28DB; -const KANI_MINHASH_LAST_SEED: u64 = 0x8EBC_6AF0_9C88_C6E3; +const KANI_MINHASH_SEED: u64 = 0xa076_1d64_78bd_642f; +const KANI_MINHASH_MIDDLE_SEED: u64 = 0xe703_7ed1_a0b4_28db; +const KANI_MINHASH_LAST_SEED: u64 = 0x8ebc_6af0_9c88_c6e3; const KANI_LSH_UNWIND: usize = 7; const _: () = assert!(KANI_LSH_UNWIND == super::lsh::KANI_MAX_RECORDED_PAIRS + 1); @@ -62,12 +65,10 @@ fn checked_lane_hasher() -> MinHasher { ) } -fn fragment(id: &str) -> FragmentId { - FragmentId::from(id) -} +fn fragment(id: &str) -> FragmentId { FragmentId::from(id) } fn repeated_signature(value: u64) -> MinHashSignature { - MinHashSignature::new([value; MINHASH_SIZE]) + MinHashSignature::new(&[value; MINHASH_SIZE]) } fn two_band_config() -> LshConfig { diff --git a/crates/whitaker_clones_core/src/index/lsh.rs b/crates/whitaker_clones_core/src/index/lsh.rs index f88aaa3a..b8272293 100644 --- a/crates/whitaker_clones_core/src/index/lsh.rs +++ b/crates/whitaker_clones_core/src/index/lsh.rs @@ -1,4 +1,4 @@ -//! Locality-sensitive hashing over fixed-width MinHash signatures. +//! Locality-sensitive hashing over fixed-width `MinHash` signatures. //! //! This module contains [`LshIndex`], the token-pass index that groups //! [`MinHashSignature`] band slices into locality-sensitive hashing (LSH) @@ -49,7 +49,12 @@ impl LshIndex { /// /// ```rust /// use whitaker_clones_core::{ - /// Fingerprint, FragmentId, LshConfig, LshIndex, MinHasher, MINHASH_SIZE, + /// Fingerprint, + /// FragmentId, + /// LshConfig, + /// LshIndex, + /// MINHASH_SIZE, + /// MinHasher, /// }; /// /// let hasher = MinHasher::new(); @@ -66,7 +71,7 @@ impl LshIndex { /// # Ok::<(), whitaker_clones_core::IndexError>(()) /// ``` #[must_use] - pub fn new(config: LshConfig) -> Self { + pub const fn new(config: LshConfig) -> Self { Self { config, #[cfg(not(kani))] @@ -247,9 +252,7 @@ impl InsertedFragmentsForKani { } } - const fn len(&self) -> usize { - self.len - } + const fn len(&self) -> usize { self.len } fn get(&self, index: usize) -> Option<&InsertedFragmentForKani> { self.items.get(index).and_then(Option::as_ref) diff --git a/crates/whitaker_clones_core/src/index/minhash.rs b/crates/whitaker_clones_core/src/index/minhash.rs index 9da0e5a3..4578f525 100644 --- a/crates/whitaker_clones_core/src/index/minhash.rs +++ b/crates/whitaker_clones_core/src/index/minhash.rs @@ -1,4 +1,4 @@ -//! Deterministic MinHash sketch generation for token-pass clone candidates. +//! Deterministic `MinHash` sketch generation for token-pass clone candidates. //! //! This module turns retained token [`Fingerprint`] values into fixed-width //! [`MinHashSignature`] values for the clone-detector index API. [`MinHasher`] @@ -7,7 +7,7 @@ //! deduplicated hash values so sketching has set semantics rather than multiset //! semantics. //! -//! Internally, [`Seed`] keeps MinHash seed values distinct from raw fingerprint +//! Internally, [`Seed`] keeps `MinHash` seed values distinct from raw fingerprint //! hashes at the type level. The hashing core still accepts raw `u64` hash //! values because fingerprints and signature lanes are represented as hash //! words throughout the index API. @@ -15,7 +15,7 @@ //! The `#[cfg(kani)]` constructors and the unrolled `sketch_values` //! implementation are proof seams for bounded model checking. They keep Kani //! harnesses focused on [`MinHasher::sketch`] invariants without changing the -//! production API or the production [`array::from_fn`] implementation. +//! production API or the production array `map` implementation. //! //! [`LshIndex`](super::LshIndex) in `lsh.rs` consumes the signatures produced //! here by partitioning them into configured bands and emitting candidate @@ -25,35 +25,32 @@ use std::array; -use crate::token::Fingerprint; - use super::{IndexError, IndexResult, MINHASH_SIZE, MinHashSignature}; +use crate::token::Fingerprint; -const SEED_STREAM_START: u64 = 0x243F_6A88_85A3_08D3; -const SEED_STREAM_STEP: u64 = 0x9E37_79B9_7F4A_7C15; -const HASH_MIX: u64 = 0x94D0_49BB_1331_11EB; +const SEED_STREAM_START: u64 = 0x243f_6a88_85a3_08d3; +const SEED_STREAM_STEP: u64 = 0x9e37_79b9_7f4a_7c15; +const HASH_MIX: u64 = 0x94d0_49bb_1331_11eb; -/// A typed MinHash seed value. +/// A typed `MinHash` seed value. /// /// Keeps seed values distinct from raw hash values at the type level, /// preventing accidental argument transposition inside the hashing core. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Seed(u64); -/// Deterministic MinHash sketcher for retained token fingerprints. +/// Deterministic `MinHash` sketcher for retained token fingerprints. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MinHasher { seeds: [Seed; MINHASH_SIZE], } impl Default for MinHasher { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } impl MinHasher { - /// Creates the fixed 128-seed MinHash family used by roadmap item 7.2.2. + /// Creates the fixed 128-seed `MinHash` family used by roadmap item 7.2.2. /// /// # Examples /// @@ -72,10 +69,10 @@ impl MinHasher { Self { seeds } } - /// Builds a MinHash sketch from retained fingerprint hashes. + /// Builds a `MinHash` sketch from retained fingerprint hashes. /// /// Duplicate fingerprint hash values are collapsed first so the sketch uses - /// MinHash set semantics rather than multiset semantics. + /// `MinHash` set semantics rather than multiset semantics. /// /// # Errors /// @@ -84,7 +81,7 @@ impl MinHasher { pub fn sketch(&self, fingerprints: &[Fingerprint]) -> IndexResult { let unique_hashes = unique_hashes(fingerprints)?; let values = sketch_values(&self.seeds, &unique_hashes); - Ok(MinHashSignature::new(values)) + Ok(MinHashSignature::new(&values)) } /// Creates a deterministic proof-only fixture for Kani harnesses. @@ -127,17 +124,17 @@ impl MinHasher { } } -/// Computes the 128-lane MinHash signature in production builds. +/// Computes the 128-lane `MinHash` signature in production builds. /// /// Two `cfg`-specific implementations exist to balance production idiom /// against proof tractability. This production variant uses idiomatic -/// [`array::from_fn`] iteration. It is mechanically equivalent to the +/// array `map` iteration. It is mechanically equivalent to the /// `#[cfg(kani)]` proof-seam variant: both call [`minimum_mixed_hash`] once per -/// lane to produce the same 128-lane MinHash signature from the seeds and +/// lane to produce the same 128-lane `MinHash` signature from the seeds and /// unique hashes. #[cfg(not(kani))] fn sketch_values(seeds: &[Seed; MINHASH_SIZE], unique_hashes: &[u64]) -> [u64; MINHASH_SIZE] { - array::from_fn(|index| minimum_mixed_hash(seeds[index], unique_hashes)) + seeds.map(|seed| minimum_mixed_hash(seed, unique_hashes)) } /// Computes the 128-lane MinHash signature with explicit unrolling for Kani. @@ -146,7 +143,7 @@ fn sketch_values(seeds: &[Seed; MINHASH_SIZE], unique_hashes: &[u64]) -> [u64; M /// against proof tractability. This `#[cfg(kani)]` proof seam manually unrolls /// every [`minimum_mixed_hash`] call so Kani's bounded model checker does not /// spend proof budget on iterator or loop state expansion. The explicit array -/// literal is mechanically equivalent to the production [`array::from_fn`] +/// literal is mechanically equivalent to the production array `map` /// implementation: both compute the same 128-lane MinHash signature from the /// seeds and unique hashes. // `@codescene/suppress` Large Method proof-seam: keep manual unrolling for Kani tractability @@ -284,7 +281,7 @@ fn sketch_values(seeds: &[Seed; MINHASH_SIZE], unique_hashes: &[u64]) -> [u64; M ] } -/// Extracts sorted, deduplicated fingerprint hashes for MinHash set semantics. +/// Extracts sorted, deduplicated fingerprint hashes for `MinHash` set semantics. /// /// This `pub(super)` helper is restricted to the parent module and converts /// retained [`Fingerprint`] values into the Vec-backed set representation used @@ -312,27 +309,25 @@ fn minimum_mixed_hash(seed: Seed, hashes: &[u64]) -> u64 { }) } -fn mix_hash(seed: u64, hash: u64) -> u64 { - splitmix64(seed ^ hash.wrapping_mul(HASH_MIX)) -} +const fn mix_hash(seed: u64, hash: u64) -> u64 { splitmix64(seed ^ hash.wrapping_mul(HASH_MIX)) } /// Generates the next seed in the deterministic stream. /// /// Both `next_seed` and `splitmix64` intentionally add `SEED_STREAM_STEP` to /// create a non-overlapping, deterministic seed sequence compatible with the /// seed-streaming approach. This double-increment is deliberate, not a bug. -fn next_seed(state: &mut u64) -> Seed { +const fn next_seed(state: &mut u64) -> Seed { *state = state.wrapping_add(SEED_STREAM_STEP); Seed(splitmix64(*state)) } -/// SplitMix64 generator with deliberate `SEED_STREAM_STEP` addition. +/// `SplitMix64` generator with deliberate `SEED_STREAM_STEP` addition. /// /// This function applies `SEED_STREAM_STEP` in addition to the increment in /// `next_seed` to ensure deterministic, non-overlapping seed values. -fn splitmix64(value: u64) -> u64 { +const fn splitmix64(value: u64) -> u64 { let mut mixed = value.wrapping_add(SEED_STREAM_STEP); - mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); mixed ^ (mixed >> 31) } diff --git a/crates/whitaker_clones_core/src/index/mod.rs b/crates/whitaker_clones_core/src/index/mod.rs index daec3fc4..fcfe230d 100644 --- a/crates/whitaker_clones_core/src/index/mod.rs +++ b/crates/whitaker_clones_core/src/index/mod.rs @@ -1,4 +1,4 @@ -//! MinHash and LSH indexing for token-pass candidate generation. +//! `MinHash` and LSH indexing for token-pass candidate generation. mod error; mod fragment_id; diff --git a/crates/whitaker_clones_core/src/index/tests.rs b/crates/whitaker_clones_core/src/index/tests.rs index 2f9871c5..0029d9a9 100644 --- a/crates/whitaker_clones_core/src/index/tests.rs +++ b/crates/whitaker_clones_core/src/index/tests.rs @@ -1,13 +1,20 @@ -//! Unit tests for MinHash and LSH candidate generation. +//! Unit tests for `MinHash` and LSH candidate generation. use rstest::{fixture, rstest}; - -use crate::token::Fingerprint; +use whitaker_test_macros::allow_fixture_expansion_lints; use super::{ - CandidatePair, FragmentId, IndexError, LshConfig, LshIndex, MINHASH_SIZE, MinHashSignature, - MinHasher, minhash::unique_hashes, + CandidatePair, + FragmentId, + IndexError, + LshConfig, + LshIndex, + MINHASH_SIZE, + MinHashSignature, + MinHasher, + minhash::unique_hashes, }; +use crate::token::Fingerprint; fn fingerprints(values: &[u64]) -> Vec { values @@ -17,36 +24,29 @@ fn fingerprints(values: &[u64]) -> Vec { .collect() } -fn sketch(values: &[u64]) -> MinHashSignature { - MinHasher::new() - .sketch(&fingerprints(values)) - .expect("fingerprints should sketch successfully") +fn sketch(values: &[u64]) -> Result { + MinHasher::new().sketch(&fingerprints(values)) } +#[allow_fixture_expansion_lints] #[fixture] -fn single_band_config() -> LshConfig { - LshConfig::new(1, MINHASH_SIZE).expect("single-band config should validate") -} +fn single_band_config() -> Result { LshConfig::new(1, MINHASH_SIZE) } +#[allow_fixture_expansion_lints] #[fixture] -fn multi_band_config() -> LshConfig { - LshConfig::new(32, 4).expect("multi-band config should validate") -} +fn multi_band_config() -> Result { LshConfig::new(32, 4) } +#[allow_fixture_expansion_lints] #[fixture] -fn shared_signature() -> MinHashSignature { - sketch(&[1, 2, 3, 4]) -} +fn shared_signature() -> Result { sketch(&[1, 2, 3, 4]) } +#[allow_fixture_expansion_lints] #[fixture] -fn distinct_signature() -> MinHashSignature { - sketch(&[8, 9, 10, 11]) -} +fn distinct_signature() -> Result { sketch(&[8, 9, 10, 11]) } +#[allow_fixture_expansion_lints] #[fixture] -fn identical_signature() -> MinHashSignature { - sketch(&[5, 7, 11, 13]) -} +fn identical_signature() -> Result { sketch(&[5, 7, 11, 13]) } struct FragmentIds { alpha: FragmentId, @@ -104,8 +104,8 @@ fn config_rejects_invalid_inputs(#[case] case: ((usize, usize), IndexError)) { #[rstest] #[case((1, MINHASH_SIZE))] -#[case((2, MINHASH_SIZE / 2))] -#[case((4, MINHASH_SIZE / 4))] +#[case((2, MINHASH_SIZE.div_euclid(2)))] +#[case((4, MINHASH_SIZE.div_euclid(4)))] #[case((32, 4))] fn config_accepts_valid_inputs(#[case] case: (usize, usize)) { let (bands, rows) = case; @@ -207,20 +207,24 @@ fn identical_sets_yield_identical_signatures( #[rstest] fn insertion_order_does_not_change_candidate_output( - single_band_config: LshConfig, + single_band_config: Result, fragment_ids: FragmentIds, - shared_signature: MinHashSignature, - distinct_signature: MinHashSignature, + shared_signature: Result, + distinct_signature: Result, ) { - let mut forward = LshIndex::new(single_band_config); - forward.insert(&fragment_ids.alpha, &shared_signature); - forward.insert(&fragment_ids.beta, &shared_signature); - forward.insert(&fragment_ids.gamma, &distinct_signature); + let config = single_band_config.expect("single-band config should validate"); + let shared = shared_signature.expect("shared signature should sketch"); + let distinct = distinct_signature.expect("distinct signature should sketch"); + + let mut forward = LshIndex::new(config); + forward.insert(&fragment_ids.alpha, &shared); + forward.insert(&fragment_ids.beta, &shared); + forward.insert(&fragment_ids.gamma, &distinct); - let mut reverse = LshIndex::new(single_band_config); - reverse.insert(&fragment_ids.gamma, &distinct_signature); - reverse.insert(&fragment_ids.beta, &shared_signature); - reverse.insert(&fragment_ids.alpha, &shared_signature); + let mut reverse = LshIndex::new(config); + reverse.insert(&fragment_ids.gamma, &distinct); + reverse.insert(&fragment_ids.beta, &shared); + reverse.insert(&fragment_ids.alpha, &shared); let expected = CandidatePair::new(fragment_ids.alpha, fragment_ids.beta) .expect("distinct ids should form a pair"); @@ -231,22 +235,24 @@ fn insertion_order_does_not_change_candidate_output( #[rstest] fn canonical_ordering_across_multiple_pairs_and_bands( fragment_ids: FragmentIds, - shared_signature: MinHashSignature, - distinct_signature: MinHashSignature, + shared_signature: Result, + distinct_signature: Result, ) { - let config = LshConfig::new(4, MINHASH_SIZE / 4).expect("LSH config should validate"); + let config = LshConfig::new(4, MINHASH_SIZE.div_euclid(4)).expect("LSH config should validate"); + let shared = shared_signature.expect("shared signature should sketch"); + let distinct = distinct_signature.expect("distinct signature should sketch"); let mut forward = LshIndex::new(config); - forward.insert(&fragment_ids.alpha, &shared_signature); - forward.insert(&fragment_ids.beta, &shared_signature); - forward.insert(&fragment_ids.gamma, &shared_signature); - forward.insert(&fragment_ids.delta, &distinct_signature); + forward.insert(&fragment_ids.alpha, &shared); + forward.insert(&fragment_ids.beta, &shared); + forward.insert(&fragment_ids.gamma, &shared); + forward.insert(&fragment_ids.delta, &distinct); let mut reverse = LshIndex::new(config); - reverse.insert(&fragment_ids.delta, &distinct_signature); - reverse.insert(&fragment_ids.gamma, &shared_signature); - reverse.insert(&fragment_ids.beta, &shared_signature); - reverse.insert(&fragment_ids.alpha, &shared_signature); + reverse.insert(&fragment_ids.delta, &distinct); + reverse.insert(&fragment_ids.gamma, &shared); + reverse.insert(&fragment_ids.beta, &shared); + reverse.insert(&fragment_ids.alpha, &shared); let expected = vec![ CandidatePair::new(fragment_ids.alpha.clone(), fragment_ids.beta.clone()) @@ -263,14 +269,16 @@ fn canonical_ordering_across_multiple_pairs_and_bands( #[rstest] fn duplicate_band_collisions_emit_one_pair( - multi_band_config: LshConfig, + multi_band_config: Result, fragment_ids: FragmentIds, - identical_signature: MinHashSignature, + identical_signature: Result, ) { - let mut index = LshIndex::new(multi_band_config); + let config = multi_band_config.expect("multi-band config should validate"); + let identical = identical_signature.expect("identical signature should sketch"); + let mut index = LshIndex::new(config); - index.insert(&fragment_ids.beta, &identical_signature); - index.insert(&fragment_ids.alpha, &identical_signature); + index.insert(&fragment_ids.beta, &identical); + index.insert(&fragment_ids.alpha, &identical); assert_eq!( index.candidate_pairs(), @@ -282,9 +290,13 @@ fn duplicate_band_collisions_emit_one_pair( } #[rstest] -fn self_pairs_are_not_emitted(single_band_config: LshConfig, fragment_ids: FragmentIds) { - let signature = sketch(&[2, 4, 6, 8]); - let mut index = LshIndex::new(single_band_config); +fn self_pairs_are_not_emitted( + single_band_config: Result, + fragment_ids: FragmentIds, +) { + let config = single_band_config.expect("single-band config should validate"); + let signature = sketch(&[2, 4, 6, 8]).expect("signature should sketch"); + let mut index = LshIndex::new(config); index.insert(&fragment_ids.alpha, &signature); index.insert(&fragment_ids.alpha, &signature); diff --git a/crates/whitaker_clones_core/src/index/types.rs b/crates/whitaker_clones_core/src/index/types.rs index faeef0f9..715e0f7e 100644 --- a/crates/whitaker_clones_core/src/index/types.rs +++ b/crates/whitaker_clones_core/src/index/types.rs @@ -1,10 +1,10 @@ -//! Shared MinHash and LSH index types. +//! Shared `MinHash` and LSH index types. use std::{num::NonZeroUsize, slice::ChunksExact}; use super::{FragmentId, IndexError, IndexResult}; -/// The fixed MinHash sketch width for roadmap item 7.2.2. +/// The fixed `MinHash` sketch width for roadmap item 7.2.2. pub const MINHASH_SIZE: usize = 128; /// A canonical fragment pair emitted by the LSH candidate filter. @@ -27,7 +27,10 @@ impl CandidatePair { /// /// let pair = CandidatePair::new(FragmentId::from("beta"), FragmentId::from("alpha")); /// assert_eq!( - /// pair.map(|pair| (pair.left().as_str().to_owned(), pair.right().as_str().to_owned())), + /// pair.map(|pair| ( + /// pair.left().as_str().to_owned(), + /// pair.right().as_str().to_owned() + /// )), /// Some(("alpha".to_owned(), "beta".to_owned())) /// ); /// ``` @@ -47,18 +50,14 @@ impl CandidatePair { /// Returns the left fragment identifier. #[must_use] - pub const fn left(&self) -> &FragmentId { - &self.left - } + pub const fn left(&self) -> &FragmentId { &self.left } /// Returns the right fragment identifier. #[must_use] - pub const fn right(&self) -> &FragmentId { - &self.right - } + pub const fn right(&self) -> &FragmentId { &self.right } } -/// Validated LSH settings for the fixed-width MinHash sketch. +/// Validated LSH settings for the fixed-width `MinHash` sketch. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct LshConfig { bands: NonZeroUsize, @@ -88,30 +87,29 @@ impl LshConfig { /// # Ok::<(), whitaker_clones_core::IndexError>(()) /// ``` pub fn new(bands: usize, rows: usize) -> IndexResult { - let Some(bands) = NonZeroUsize::new(bands) else { + let Some(band_count) = NonZeroUsize::new(bands) else { return Err(IndexError::ZeroBands); }; - let Some(rows) = NonZeroUsize::new(rows) else { + let Some(row_count) = NonZeroUsize::new(rows) else { return Err(IndexError::ZeroRows); }; - validate_product(bands, rows)?; - Ok(Self { bands, rows }) + validate_product(band_count, row_count)?; + Ok(Self { + bands: band_count, + rows: row_count, + }) } /// Returns the number of LSH bands. #[must_use] - pub const fn bands(self) -> usize { - self.bands.get() - } + pub const fn bands(self) -> usize { self.bands.get() } /// Returns the number of rows in each band. #[must_use] - pub const fn rows(self) -> usize { - self.rows.get() - } + pub const fn rows(self) -> usize { self.rows.get() } } -fn validate_product(bands: NonZeroUsize, rows: NonZeroUsize) -> IndexResult<()> { +const fn validate_product(bands: NonZeroUsize, rows: NonZeroUsize) -> IndexResult<()> { match bands.get().checked_mul(rows.get()) { Some(MINHASH_SIZE) => Ok(()), Some(_) | None => Err(IndexError::invalid_band_row_product( @@ -121,15 +119,13 @@ fn validate_product(bands: NonZeroUsize, rows: NonZeroUsize) -> IndexResult<()> } } -/// A fixed-width MinHash sketch over retained fingerprint hashes. +/// A fixed-width `MinHash` sketch over retained fingerprint hashes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MinHashSignature([u64; MINHASH_SIZE]); impl MinHashSignature { #[must_use] - pub(crate) const fn new(values: [u64; MINHASH_SIZE]) -> Self { - Self(values) - } + pub(crate) const fn new(values: &[u64; MINHASH_SIZE]) -> Self { Self(*values) } /// Returns the sketch values in order. /// @@ -139,23 +135,18 @@ impl MinHashSignature { /// use whitaker_clones_core::{Fingerprint, MinHasher}; /// /// let hasher = MinHasher::new(); - /// let signature = hasher.sketch(&[ - /// Fingerprint::new(11, 0..1), - /// Fingerprint::new(22, 1..2), - /// ])?; + /// let signature = hasher.sketch(&[Fingerprint::new(11, 0..1), Fingerprint::new(22, 1..2)])?; /// assert_eq!(signature.values().len(), 128); /// # Ok::<(), whitaker_clones_core::IndexError>(()) /// ``` #[must_use] - pub const fn values(&self) -> &[u64; MINHASH_SIZE] { - &self.0 - } + pub const fn values(&self) -> &[u64; MINHASH_SIZE] { &self.0 } pub(crate) fn bands(&self, rows: usize) -> ChunksExact<'_, u64> { debug_assert!( self.0.len().is_multiple_of(rows), - "MinHashSignature length ({}) must divide evenly by rows ({}); \ - LshConfig ensures bands * rows == MINHASH_SIZE", + "MinHashSignature length ({}) must divide evenly by rows ({}); LshConfig ensures \ + bands * rows == MINHASH_SIZE", self.0.len(), rows ); diff --git a/crates/whitaker_clones_core/src/lib.rs b/crates/whitaker_clones_core/src/lib.rs index 1d3339a6..507eebb5 100644 --- a/crates/whitaker_clones_core/src/lib.rs +++ b/crates/whitaker_clones_core/src/lib.rs @@ -7,7 +7,7 @@ //! - `k`-shingling over normalized token streams. //! - 64-bit Rabin-Karp rolling hashes for shingles. //! - Winnowing to retain stable representative fingerprints. -//! - Deterministic MinHash sketches over retained fingerprints. +//! - Deterministic `MinHash` sketches over retained fingerprints. //! - Locality-sensitive hashing (LSH) candidate generation. //! - Token-pass acceptance and SARIF Run 0 emission for accepted pairs. @@ -18,20 +18,57 @@ pub mod run0; pub mod token; pub use ast::{ - AstError, AstHash, AstResult, ByteSpan, KindCounts, KindHistogram, KindWeight, NormalizedTree, - Production, ProductionMultiset, canonical_hash, kind_counts, kind_histogram, lower_span, - production_multiset, weighted_histogram, + AstError, + AstHash, + AstResult, + ByteSpan, + KindCounts, + KindHistogram, + KindWeight, + NormalizedTree, + Production, + ProductionMultiset, + canonical_hash, + kind_counts, + kind_histogram, + lower_span, + production_multiset, + weighted_histogram, }; pub use index::{ - CandidatePair, FragmentId, IndexError, IndexResult, LshConfig, LshIndex, MINHASH_SIZE, - MinHashSignature, MinHasher, + CandidatePair, + FragmentId, + IndexError, + IndexResult, + LshConfig, + LshIndex, + MINHASH_SIZE, + MinHashSignature, + MinHasher, }; pub use run0::{ - AcceptedPair, Run0Error, Run0Result, SimilarityRatio, SimilarityThreshold, TokenFragment, - TokenPassConfig, accept_candidate_pairs, emit_run0, + AcceptedPair, + Run0Error, + Run0Result, + SimilarityRatio, + SimilarityThreshold, + TokenFragment, + TokenPassConfig, + accept_candidate_pairs, + emit_run0, }; pub use token::{ - Fingerprint, IdentifierSymbol, LiteralSymbol, NormProfile, NormalizedToken, - NormalizedTokenKind, Result, ShingleSize, TokenPassError, WinnowWindow, hash_shingles, - normalize, winnow, + Fingerprint, + IdentifierSymbol, + LiteralSymbol, + NormProfile, + NormalizedToken, + NormalizedTokenKind, + Result, + ShingleSize, + TokenPassError, + WinnowWindow, + hash_shingles, + normalize, + winnow, }; diff --git a/crates/whitaker_clones_core/src/run0/emit.rs b/crates/whitaker_clones_core/src/run0/emit.rs index 9c9ece94..5642558c 100644 --- a/crates/whitaker_clones_core/src/run0/emit.rs +++ b/crates/whitaker_clones_core/src/run0/emit.rs @@ -4,18 +4,27 @@ use std::collections::BTreeMap; use sha2::{Digest, Sha256}; use whitaker_sarif::{ - Level, LocationBuilder, RelatedLocation, ResultBuilder, Run, RunBuilder, WHITAKER_FRAGMENT_KEY, - WHK001_ID, WHK002_ID, WhitakerPropertiesBuilder, all_rules, deduplicate_results, + Level, + LocationBuilder, + RelatedLocation, + ResultBuilder, + Run, + RunBuilder, + WHITAKER_FRAGMENT_KEY, + WHK001_ID, + WHK002_ID, + WhitakerPropertiesBuilder, + all_rules, + deduplicate_results, }; -use crate::{CandidatePair, NormProfile}; - use super::{ error::{Run0Error, Run0Result}, score::{SimilarityRatio, jaccard_similarity, select_rule_profile}, span::region_for_range, types::{AcceptedPair, TokenFragment, TokenPassConfig}, }; +use crate::{CandidatePair, NormProfile}; /// Accepts canonical candidate pairs for later Run 0 emission. /// @@ -160,7 +169,7 @@ fn build_result( text: format!("Peer clone fragment: {}", peer.id().as_str()), }), physical_location: whitaker_sarif::PhysicalLocation { - artifact_location: whitaker_sarif::ArtifactLocation { + artefact_location: whitaker_sarif::ArtefactLocation { uri: peer.file_uri().to_owned(), uri_base_id: None, }, @@ -197,25 +206,25 @@ fn compact_span(region: &whitaker_sarif::Region) -> String { region.end_line.unwrap_or(region.start_line), region .end_column - .unwrap_or(region.start_column.unwrap_or(1)) + .unwrap_or_else(|| region.start_column.unwrap_or(1)) ) } -fn profile_number(profile: NormProfile) -> &'static str { +const fn profile_number(profile: NormProfile) -> &'static str { match profile { NormProfile::T1 => "1", NormProfile::T2 => "2", } } -fn profile_name(profile: NormProfile) -> &'static str { +const fn profile_name(profile: NormProfile) -> &'static str { match profile { NormProfile::T1 => "T1", NormProfile::T2 => "T2", } } -fn profile_sort_key(profile: NormProfile) -> u8 { +const fn profile_sort_key(profile: NormProfile) -> u8 { match profile { NormProfile::T1 => 1, NormProfile::T2 => 2, @@ -295,11 +304,25 @@ fn token_hash(left: &TokenFragment, right: &TokenFragment) -> String { let mut hasher = Sha256::new(); for value in values { - hasher.update(value.to_be_bytes()); + hasher.update(u64_big_endian_bytes(value)); } digest_hex(hasher.finalize()) } +/// Serializes a `u64` as big-endian bytes with explicit masks and shifts. +/// +/// The digest contract fixes the byte order, so the decomposition is spelled +/// out here rather than delegated to an endianness-specific method. +fn u64_big_endian_bytes(value: u64) -> [u8; 8] { + let mut bytes = [0_u8; 8]; + let mut remaining = value; + for slot in bytes.iter_mut().rev() { + *slot = (remaining & 0xff) as u8; + remaining >>= 8; + } + bytes +} + fn digest_hex(bytes: impl AsRef<[u8]>) -> String { let mut output = String::new(); for byte in bytes.as_ref() { diff --git a/crates/whitaker_clones_core/src/run0/error.rs b/crates/whitaker_clones_core/src/run0/error.rs index 5f59fb70..927753b5 100644 --- a/crates/whitaker_clones_core/src/run0/error.rs +++ b/crates/whitaker_clones_core/src/run0/error.rs @@ -24,7 +24,8 @@ pub enum Run0Error { /// The pair resolved to fragments emitted under different normalization profiles. #[error( - "candidate pair `{left_fragment}` and `{right_fragment}` must share the same normalization profile" + "candidate pair `{left_fragment}` and `{right_fragment}` must share the same \ + normalization profile" )] MixedProfiles { /// Left fragment identifier. @@ -42,7 +43,8 @@ pub enum Run0Error { /// A retained fingerprint byte range could not be mapped back to source text. #[error( - "fingerprint range {start}..{end} for `{fragment_id}` is invalid for source length {source_len}" + "fingerprint range {start}..{end} for `{fragment_id}` is invalid for source length \ + {source_len}" )] InvalidFingerprintRange { /// Fragment identifier. diff --git a/crates/whitaker_clones_core/src/run0/score.rs b/crates/whitaker_clones_core/src/run0/score.rs index 9a17afb6..8d7f7f83 100644 --- a/crates/whitaker_clones_core/src/run0/score.rs +++ b/crates/whitaker_clones_core/src/run0/score.rs @@ -2,9 +2,8 @@ use std::collections::BTreeSet; -use crate::{Fingerprint, NormProfile}; - use super::error::{Run0Error, Run0Result}; +use crate::{Fingerprint, NormProfile}; /// Integer-backed Jaccard similarity ratio. /// @@ -38,15 +37,11 @@ impl SimilarityRatio { /// Returns the numerator of the ratio. #[must_use] - pub const fn intersection(self) -> usize { - self.intersection - } + pub const fn intersection(self) -> usize { self.intersection } /// Returns the denominator of the ratio. #[must_use] - pub const fn union(self) -> usize { - self.union - } + pub const fn union(self) -> usize { self.union } /// Formats the ratio as a six-decimal string without floating-point arithmetic. #[must_use] @@ -77,8 +72,7 @@ impl SimilarityRatio { /// ``` /// use whitaker_clones_core::run0::SimilarityThreshold; /// -/// let threshold = SimilarityThreshold::new("custom", 4, 5) -/// .expect("valid threshold"); +/// let threshold = SimilarityThreshold::new("custom", 4, 5).expect("valid threshold"); /// assert_eq!(threshold.numerator(), 4); /// assert_eq!(threshold.denominator(), 5); /// ``` @@ -117,18 +111,14 @@ impl SimilarityThreshold { /// Returns the threshold numerator. #[must_use] - pub const fn numerator(self) -> usize { - self.numerator - } + pub const fn numerator(self) -> usize { self.numerator } /// Returns the threshold denominator. #[must_use] - pub const fn denominator(self) -> usize { - self.denominator - } + pub const fn denominator(self) -> usize { self.denominator } /// Returns `true` if the threshold represents a ratio within `(0, 1]`. - fn is_valid(self) -> bool { + const fn is_valid(self) -> bool { self.numerator != 0 && self.denominator != 0 && self.numerator <= self.denominator } @@ -142,7 +132,7 @@ impl SimilarityThreshold { } } -pub(crate) fn select_rule_profile( +pub(crate) const fn select_rule_profile( profile: NormProfile, score: SimilarityRatio, type1_threshold: SimilarityThreshold, @@ -184,7 +174,7 @@ fn unique_hashes(fingerprints: &[Fingerprint]) -> BTreeSet { /// to avoid panics on pathological inputs; clamped results only occur in /// extreme/unrealistic cases. Callers should validate counts if they may /// approach `usize::MAX`. -fn meets_threshold(score: SimilarityRatio, threshold: SimilarityThreshold) -> bool { +const fn meets_threshold(score: SimilarityRatio, threshold: SimilarityThreshold) -> bool { score.intersection.saturating_mul(threshold.denominator) >= score.union.saturating_mul(threshold.numerator) } @@ -195,8 +185,10 @@ fn repeated_division(numerator: usize, denominator: usize) -> Option<(usize, usi return None; } - let quotient = numerator / denominator; - let remainder = numerator % denominator; + // For unsigned integers Euclidean division matches truncating division + // exactly, so the quotient and remainder are unchanged. + let quotient = numerator.div_euclid(denominator); + let remainder = numerator.rem_euclid(denominator); Some((quotient, remainder)) } diff --git a/crates/whitaker_clones_core/src/run0/span.rs b/crates/whitaker_clones_core/src/run0/span.rs index 0023a23b..bd22fa62 100644 --- a/crates/whitaker_clones_core/src/run0/span.rs +++ b/crates/whitaker_clones_core/src/run0/span.rs @@ -12,11 +12,17 @@ pub(crate) fn region_for_range( validate_range(fragment_id, source_text, &range)?; let starts = line_starts(source_text); let (start_line, start_column) = line_and_column(source_text, &starts, range.start); - let end_position = source_text[..range.end] + // `validate_range` has already proved `range.end` is an in-bounds char + // boundary, so the fallible slice only fails defensively. + let Some(prefix) = source_text.get(..range.end) else { + return Err(Run0Error::InvalidUtf8Boundary { + fragment_id: fragment_id.to_owned(), + }); + }; + let end_position = prefix .char_indices() .next_back() - .map(|(i, _)| i) - .unwrap_or(range.start); + .map_or(range.start, |(i, _)| i); let (end_line, end_column) = line_and_column(source_text, &starts, end_position); RegionBuilder::new(start_line) @@ -64,9 +70,11 @@ fn line_and_column(source_text: &str, starts: &[usize], offset: usize) -> (usize let line_index = starts .partition_point(|start| *start <= offset) .saturating_sub(1); - let line_start = starts[line_index]; + // `starts` always begins with 0, so both fallbacks preserve the + // start-of-text behaviour if the partition point ever degenerated. + let line_start = starts.get(line_index).copied().unwrap_or(0); let clamped_offset = offset.min(source_text.len()); - let line_slice = &source_text[line_start..clamped_offset]; + let line_slice = source_text.get(line_start..clamped_offset).unwrap_or(""); let utf16_count = line_slice.encode_utf16().count(); (line_index.saturating_add(1), utf16_count.saturating_add(1)) } diff --git a/crates/whitaker_clones_core/src/run0/test_helpers.rs b/crates/whitaker_clones_core/src/run0/test_helpers.rs index 43b90ed3..6261360c 100644 --- a/crates/whitaker_clones_core/src/run0/test_helpers.rs +++ b/crates/whitaker_clones_core/src/run0/test_helpers.rs @@ -1,8 +1,7 @@ //! Shared test helpers for Run 0 acceptance and emission tests. -use crate::{CandidatePair, Fingerprint, FragmentId, NormProfile}; - use super::{TokenFragment, TokenPassConfig}; +use crate::{CandidatePair, Fingerprint, FragmentId, NormProfile}; pub(super) struct FragmentInput<'a> { pub(super) id: &'a str, @@ -16,7 +15,7 @@ pub(super) fn fingerprint(hash: u64, range: std::ops::Range) -> Fingerpri Fingerprint::new(hash, range) } -pub(super) fn fragment(input: FragmentInput<'_>) -> TokenFragment { +pub(super) fn fragment(input: &FragmentInput<'_>) -> TokenFragment { TokenFragment::new( FragmentId::from(input.id), input.profile, @@ -32,9 +31,9 @@ pub(super) fn fragment(input: FragmentInput<'_>) -> TokenFragment { ) } -pub(super) fn pair(left: &str, right: &str) -> CandidatePair { +/// Builds a canonical candidate pair, yielding `None` for a degenerate self-pair. +pub(super) fn pair(left: &str, right: &str) -> Option { CandidatePair::new(FragmentId::from(left), FragmentId::from(right)) - .unwrap_or_else(|| panic!("pair `{left}` and `{right}` must be distinct")) } pub(super) fn config() -> TokenPassConfig { diff --git a/crates/whitaker_clones_core/src/run0/tests.rs b/crates/whitaker_clones_core/src/run0/tests.rs index f0c8d3df..9e36ed88 100644 --- a/crates/whitaker_clones_core/src/run0/tests.rs +++ b/crates/whitaker_clones_core/src/run0/tests.rs @@ -2,23 +2,27 @@ use whitaker_sarif::{Region, WHK001_ID, WHK002_ID}; -use crate::NormProfile; - use super::{ - AcceptedPair, Run0Error, SimilarityThreshold, TokenPassConfig, accept_candidate_pairs, + AcceptedPair, + Run0Error, + SimilarityThreshold, + TokenPassConfig, + accept_candidate_pairs, emit_run0, score::SimilarityRatio, span::region_for_range, test_helpers::{FragmentInput, config, fragment, pair}, }; +use crate::{CandidatePair, NormProfile}; fn build_pair_and_accept( - left: FragmentInput<'_>, - right: FragmentInput<'_>, + left: &FragmentInput<'_>, + right: &FragmentInput<'_>, cfg: &TokenPassConfig, ) -> Result, Run0Error> { let fragments = vec![fragment(left), fragment(right)]; - accept_candidate_pairs(&fragments, &[pair("alpha", "beta")], cfg) + let candidates: Vec = pair("alpha", "beta").into_iter().collect(); + accept_candidate_pairs(&fragments, &candidates, cfg) } fn assert_single_accepted( @@ -26,28 +30,31 @@ fn assert_single_accepted( expected_profile: NormProfile, expected_score: SimilarityRatio, ) { - assert_eq!(accepted.len(), 1); - assert_eq!(accepted[0].profile(), expected_profile); - assert_eq!(accepted[0].score(), expected_score); + let [only] = accepted else { + panic!("exactly one accepted pair should be present"); + }; + assert_eq!(only.profile(), expected_profile); + assert_eq!(only.score(), expected_score); } -fn assert_region(id: &str, source: &str, range: std::ops::Range, expected: Region) { - let region = region_for_range(id, source, range) - .unwrap_or_else(|error| panic!("unexpected region error: {error}")); - assert_eq!(region, expected); +fn assert_region(id: &str, source: &str, range: std::ops::Range, expected: &Region) { + match region_for_range(id, source, range) { + Ok(region) => assert_eq!(®ion, expected), + Err(error) => panic!("unexpected region error: {error}"), + } } #[test] fn boundary_threshold_accepts_type1_pair() { let accepted = build_pair_and_accept( - FragmentInput { + &FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(1, 0..8), (2, 0..8)], }, - FragmentInput { + &FragmentInput { id: "beta", profile: NormProfile::T1, file_uri: "src/b.rs", @@ -56,7 +63,7 @@ fn boundary_threshold_accepts_type1_pair() { }, &config(), ) - .unwrap_or_else(|error| panic!("unexpected acceptance error: {error}")); + .expect("candidate acceptance should succeed"); assert_single_accepted(&accepted, NormProfile::T1, SimilarityRatio::new(2, 2)); } @@ -64,18 +71,17 @@ fn boundary_threshold_accepts_type1_pair() { #[test] fn boundary_threshold_accepts_type2_pair() { let config = config().with_type2_threshold( - SimilarityThreshold::new("type2", 1, 3) - .unwrap_or_else(|error| panic!("unexpected threshold error: {error}")), + SimilarityThreshold::new("type2", 1, 3).expect("type2 threshold should validate"), ); let accepted = build_pair_and_accept( - FragmentInput { + &FragmentInput { id: "alpha", profile: NormProfile::T2, file_uri: "src/a.rs", source_text: "fn a(x: i32) {}\n", hashes: &[(1, 0..15), (2, 0..15)], }, - FragmentInput { + &FragmentInput { id: "beta", profile: NormProfile::T2, file_uri: "src/b.rs", @@ -84,7 +90,7 @@ fn boundary_threshold_accepts_type2_pair() { }, &config, ) - .unwrap_or_else(|error| panic!("unexpected acceptance error: {error}")); + .expect("candidate acceptance should succeed"); assert_single_accepted(&accepted, NormProfile::T2, SimilarityRatio::new(1, 3)); } @@ -92,14 +98,14 @@ fn boundary_threshold_accepts_type2_pair() { #[test] fn just_below_threshold_is_rejected() { let accepted = build_pair_and_accept( - FragmentInput { + &FragmentInput { id: "alpha", profile: NormProfile::T2, file_uri: "src/a.rs", source_text: "fn a(x: i32) {}\n", hashes: &[(1, 0..15), (2, 0..15)], }, - FragmentInput { + &FragmentInput { id: "beta", profile: NormProfile::T2, file_uri: "src/b.rs", @@ -108,7 +114,7 @@ fn just_below_threshold_is_rejected() { }, &config(), ) - .unwrap_or_else(|error| panic!("unexpected acceptance error: {error}")); + .expect("candidate acceptance should succeed"); assert!(accepted.is_empty()); } @@ -119,7 +125,7 @@ fn single_line_region_uses_one_based_columns() { "alpha", "fn a() {}\n", 0..8, - Region { + &Region { start_line: 1, start_column: Some(1), end_line: Some(1), @@ -136,7 +142,7 @@ fn multi_line_region_tracks_trailing_newline() { "alpha", "fn alpha() {\n value();\n}\n", 13..27, - Region { + &Region { start_line: 2, start_column: Some(1), end_line: Some(3), @@ -150,14 +156,14 @@ fn multi_line_region_tracks_trailing_newline() { #[test] fn emit_run0_uses_primary_and_related_locations() { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(11, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T1, file_uri: "src/b.rs", @@ -166,13 +172,12 @@ fn emit_run0_uses_primary_and_related_locations() { }), ]; let accepted = vec![AcceptedPair::new( - pair("alpha", "beta"), + pair("alpha", "beta").expect("alpha and beta are distinct"), NormProfile::T1, SimilarityRatio::new(1, 1), )]; - let run = emit_run0(&fragments, &accepted, &config()) - .unwrap_or_else(|error| panic!("unexpected emit error: {error}")); + let run = emit_run0(&fragments, &accepted, &config()).expect("Run 0 emission should succeed"); let [result] = run.results.as_slice() else { panic!("expected exactly one result"); @@ -180,44 +185,43 @@ fn emit_run0_uses_primary_and_related_locations() { assert_eq!(result.rule_id, WHK001_ID); assert_eq!(result.locations.len(), 1); assert_eq!(result.related_locations.len(), 1); - assert_eq!( - result.locations[0].physical_location.artifact_location.uri, - "src/a.rs" - ); - assert_eq!( - result.related_locations[0] - .physical_location - .artifact_location - .uri, - "src/b.rs" - ); + let location = result + .locations + .first() + .expect("primary location should be present"); + assert_eq!(location.physical_location.artefact_location.uri, "src/a.rs"); + let related = result + .related_locations + .first() + .expect("related location should be present"); + assert_eq!(related.physical_location.artefact_location.uri, "src/b.rs"); } #[test] fn emit_run0_sorts_and_deduplicates_results() { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(11, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T1, file_uri: "src/b.rs", source_text: "fn b() {}\n", hashes: &[(11, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "gamma", profile: NormProfile::T2, file_uri: "src/c.rs", source_text: "fn c(x: i32) {}\n", hashes: &[(1, 0..15), (2, 0..15)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "delta", profile: NormProfile::T2, file_uri: "src/d.rs", @@ -227,35 +231,36 @@ fn emit_run0_sorts_and_deduplicates_results() { ]; let accepted = vec![ AcceptedPair::new( - pair("gamma", "delta"), + pair("gamma", "delta").expect("gamma and delta are distinct"), NormProfile::T2, SimilarityRatio::new(2, 2), ), AcceptedPair::new( - pair("beta", "alpha"), + pair("beta", "alpha").expect("beta and alpha are distinct"), NormProfile::T1, SimilarityRatio::new(1, 1), ), AcceptedPair::new( - pair("alpha", "beta"), + pair("alpha", "beta").expect("alpha and beta are distinct"), NormProfile::T1, SimilarityRatio::new(1, 1), ), ]; - let run = emit_run0(&fragments, &accepted, &config()) - .unwrap_or_else(|error| panic!("unexpected emit error: {error}")); + let run = emit_run0(&fragments, &accepted, &config()).expect("Run 0 emission should succeed"); assert_eq!(run.results.len(), 2); - assert_eq!(run.results[0].rule_id, WHK001_ID); - assert_eq!(run.results[1].rule_id, WHK002_ID); + let [first, second] = run.results.as_slice() else { + panic!("expected exactly two results"); + }; + assert_eq!(first.rule_id, WHK001_ID); + assert_eq!(second.rule_id, WHK002_ID); } #[test] fn invalid_range_produces_typed_error() { - let error = region_for_range("alpha", "fn a() {}\n", 9..12) - .err() - .unwrap_or_else(|| panic!("invalid range must error")); + let error = + region_for_range("alpha", "fn a() {}\n", 9..12).expect_err("invalid range must error"); match error { Run0Error::InvalidFingerprintRange { @@ -278,9 +283,8 @@ fn invalid_utf8_boundary_produces_typed_error() { // "á" = 2 bytes in UTF-8; index 2 is in the middle of that code point let source = "aáb"; let mid = 2; // inside "á" byte sequence - let error = region_for_range("alpha", source, 0..mid) - .err() - .unwrap_or_else(|| panic!("invalid utf-8 boundary must error")); + let error = + region_for_range("alpha", source, 0..mid).expect_err("invalid utf-8 boundary must error"); match error { Run0Error::InvalidUtf8Boundary { fragment_id } => { @@ -292,18 +296,17 @@ fn invalid_utf8_boundary_produces_typed_error() { #[test] fn missing_fragment_produces_typed_error() { - let fragments = vec![fragment(FragmentInput { + let fragments = vec![fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(1, 0..8), (2, 0..8)], })]; - let candidates = vec![pair("alpha", "beta")]; + let candidates = vec![pair("alpha", "beta").expect("alpha and beta are distinct")]; let error = accept_candidate_pairs(&fragments, &candidates, &config()) - .err() - .unwrap_or_else(|| panic!("missing fragment must error")); + .expect_err("missing fragment must error"); match error { Run0Error::MissingFragment { fragment_id } => { @@ -316,14 +319,14 @@ fn missing_fragment_produces_typed_error() { #[test] fn mixed_profiles_produces_typed_error() { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(1, 0..8), (2, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T2, file_uri: "src/b.rs", @@ -331,11 +334,10 @@ fn mixed_profiles_produces_typed_error() { hashes: &[(1, 0..8), (2, 0..8)], }), ]; - let candidates = vec![pair("alpha", "beta")]; + let candidates = vec![pair("alpha", "beta").expect("alpha and beta are distinct")]; let error = accept_candidate_pairs(&fragments, &candidates, &config()) - .err() - .unwrap_or_else(|| panic!("mixed profiles must error")); + .expect_err("mixed profiles must error"); match error { Run0Error::MixedProfiles { diff --git a/crates/whitaker_clones_core/src/run0/tests_emit.rs b/crates/whitaker_clones_core/src/run0/tests_emit.rs index 310d07cf..1a191e7f 100644 --- a/crates/whitaker_clones_core/src/run0/tests_emit.rs +++ b/crates/whitaker_clones_core/src/run0/tests_emit.rs @@ -2,13 +2,15 @@ use whitaker_sarif::{WHITAKER_FRAGMENT_KEY, WHK002_ID, WhitakerProperties}; -use crate::{Fingerprint, NormProfile}; - use super::{ - AcceptedPair, SimilarityRatio, emit_run0, + AcceptedPair, + Run0Error, + SimilarityRatio, + emit_run0, score::jaccard_similarity, test_helpers::{FragmentInput, config, fingerprint, fragment, pair}, }; +use crate::{Fingerprint, NormProfile}; #[test] fn duplicate_hashes_do_not_inflate_jaccard_score() { @@ -19,8 +21,8 @@ fn duplicate_hashes_do_not_inflate_jaccard_score() { ]; let right = [fingerprint(1, 0..3), fingerprint(2, 3..6)]; - let score = jaccard_similarity(&left, &right) - .unwrap_or_else(|| panic!("score should be present for non-empty fragments")); + let score = + jaccard_similarity(&left, &right).expect("score should be present for non-empty fragments"); assert_eq!(score, SimilarityRatio::new(2, 2)); } @@ -34,16 +36,16 @@ fn jaccard_returns_none_for_empty_fragments() { assert!(jaccard_similarity(&non_empty, &empty).is_none()); } -fn make_t2_emission_run() -> whitaker_sarif::Run { +fn make_t2_emission_run() -> Result { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T2, file_uri: "src/a.rs", source_text: "fn a(x: i32) {}\n", hashes: &[(1, 0..15), (2, 0..15)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T2, file_uri: "src/b.rs", @@ -51,19 +53,17 @@ fn make_t2_emission_run() -> whitaker_sarif::Run { hashes: &[(1, 0..15), (2, 0..15)], }), ]; - let accepted = vec![AcceptedPair::new( - pair("alpha", "beta"), - NormProfile::T2, - SimilarityRatio::new(2, 2), - )]; + let accepted: Vec = pair("alpha", "beta") + .map(|candidate| AcceptedPair::new(candidate, NormProfile::T2, SimilarityRatio::new(2, 2))) + .into_iter() + .collect(); emit_run0(&fragments, &accepted, &config()) - .unwrap_or_else(|error| panic!("unexpected emit error: {error}")) } #[test] fn emitted_t2_result_has_correct_rule_id() { - let run = make_t2_emission_run(); + let run = make_t2_emission_run().expect("Run 0 emission should succeed"); let [result] = run.results.as_slice() else { panic!("expected one result"); }; @@ -73,7 +73,7 @@ fn emitted_t2_result_has_correct_rule_id() { #[test] fn emitted_t2_result_contains_required_fingerprint_keys() { - let run = make_t2_emission_run(); + let run = make_t2_emission_run().expect("Run 0 emission should succeed"); let [result] = run.results.as_slice() else { panic!("expected one result"); }; @@ -88,16 +88,16 @@ fn emitted_t2_result_contains_required_fingerprint_keys() { #[test] fn emitted_t2_result_properties_match_config() { - let run = make_t2_emission_run(); + let run = make_t2_emission_run().expect("Run 0 emission should succeed"); let [result] = run.results.as_slice() else { panic!("expected one result"); }; let properties = result .properties .as_ref() - .unwrap_or_else(|| panic!("Whitaker properties must be present")); - let extracted = WhitakerProperties::try_from(properties) - .unwrap_or_else(|error| panic!("unexpected property extraction error: {error}")); + .expect("Whitaker properties must be present"); + let extracted = + WhitakerProperties::try_from(properties).expect("Whitaker properties should extract"); assert_eq!( ( diff --git a/crates/whitaker_clones_core/src/run0/types.rs b/crates/whitaker_clones_core/src/run0/types.rs index e11af4c5..1776f802 100644 --- a/crates/whitaker_clones_core/src/run0/types.rs +++ b/crates/whitaker_clones_core/src/run0/types.rs @@ -1,8 +1,7 @@ //! Public input and output types for token-pass Run 0 emission. -use crate::{CandidatePair, Fingerprint, FragmentId, NormProfile}; - use super::score::{SimilarityRatio, SimilarityThreshold}; +use crate::{CandidatePair, Fingerprint, FragmentId, NormProfile}; const DEFAULT_SHINGLE_SIZE: usize = 25; const DEFAULT_WINNOW_WINDOW: usize = 16; @@ -61,31 +60,23 @@ impl TokenFragment { /// Returns the stable fragment identifier. #[must_use] - pub const fn id(&self) -> &FragmentId { - &self.id - } + pub const fn id(&self) -> &FragmentId { &self.id } /// Returns the normalization profile used to produce this fragment. #[must_use] - pub const fn profile(&self) -> NormProfile { - self.profile - } + pub const fn profile(&self) -> NormProfile { self.profile } - /// Returns the source artifact URI used in SARIF output. + /// Returns the source artefact URI used in SARIF output. #[must_use] - pub fn file_uri(&self) -> &str { - self.file_uri.as_str() - } + pub const fn file_uri(&self) -> &str { self.file_uri.as_str() } /// Returns the original source text used for byte-range mapping. #[must_use] - pub fn source_text(&self) -> &str { - self.source_text.as_str() - } + pub const fn source_text(&self) -> &str { self.source_text.as_str() } /// Returns the retained token fingerprints for this fragment. #[must_use] - pub fn retained_fingerprints(&self) -> &[Fingerprint] { + pub const fn retained_fingerprints(&self) -> &[Fingerprint] { self.retained_fingerprints.as_slice() } } @@ -160,39 +151,27 @@ impl TokenPassConfig { /// Returns the SARIF producer name. #[must_use] - pub fn tool_name(&self) -> &str { - self.tool_name.as_str() - } + pub const fn tool_name(&self) -> &str { self.tool_name.as_str() } /// Returns the SARIF producer version. #[must_use] - pub fn tool_version(&self) -> &str { - self.tool_version.as_str() - } + pub const fn tool_version(&self) -> &str { self.tool_version.as_str() } /// Returns the configured shingle size. #[must_use] - pub const fn shingle_size(&self) -> usize { - self.shingle_size - } + pub const fn shingle_size(&self) -> usize { self.shingle_size } /// Returns the configured winnow window. #[must_use] - pub const fn winnow_window(&self) -> usize { - self.winnow_window - } + pub const fn winnow_window(&self) -> usize { self.winnow_window } /// Returns the Type-1 acceptance threshold. #[must_use] - pub const fn type1_threshold(&self) -> SimilarityThreshold { - self.type1_threshold - } + pub const fn type1_threshold(&self) -> SimilarityThreshold { self.type1_threshold } /// Returns the Type-2 acceptance threshold. #[must_use] - pub const fn type2_threshold(&self) -> SimilarityThreshold { - self.type2_threshold - } + pub const fn type2_threshold(&self) -> SimilarityThreshold { self.type2_threshold } } /// An accepted token-pass candidate pair and its final rule classification. @@ -201,16 +180,16 @@ impl TokenPassConfig { /// /// ``` /// use whitaker_clones_core::{ -/// AcceptedPair, CandidatePair, FragmentId, NormProfile, SimilarityRatio, +/// AcceptedPair, +/// CandidatePair, +/// FragmentId, +/// NormProfile, +/// SimilarityRatio, /// }; /// /// let pair = CandidatePair::new(FragmentId::from("alpha"), FragmentId::from("beta")) /// .expect("distinct fragments"); -/// let accepted = AcceptedPair::new( -/// pair, -/// NormProfile::T1, -/// SimilarityRatio::new(4, 4), -/// ); +/// let accepted = AcceptedPair::new(pair, NormProfile::T1, SimilarityRatio::new(4, 4)); /// /// assert_eq!(accepted.profile(), NormProfile::T1); /// ``` @@ -234,19 +213,13 @@ impl AcceptedPair { /// Returns the canonical fragment pair. #[must_use] - pub const fn pair(&self) -> &CandidatePair { - &self.pair - } + pub const fn pair(&self) -> &CandidatePair { &self.pair } /// Returns the rule profile to emit for this pair. #[must_use] - pub const fn profile(&self) -> NormProfile { - self.profile - } + pub const fn profile(&self) -> NormProfile { self.profile } /// Returns the accepted Jaccard score. #[must_use] - pub const fn score(&self) -> SimilarityRatio { - self.score - } + pub const fn score(&self) -> SimilarityRatio { self.score } } diff --git a/crates/whitaker_clones_core/src/token/fingerprint.rs b/crates/whitaker_clones_core/src/token/fingerprint.rs index dee87470..3fe30715 100644 --- a/crates/whitaker_clones_core/src/token/fingerprint.rs +++ b/crates/whitaker_clones_core/src/token/fingerprint.rs @@ -1,11 +1,19 @@ //! Shingling, Rabin-Karp rolling hashing, and winnowing helpers. use super::types::{ - Fingerprint, IdentifierSymbol, LiteralSymbol, NormalizedToken, NormalizedTokenKind, - ShingleSize, WinnowWindow, + Fingerprint, + IdentifierSymbol, + LiteralSymbol, + NormalizedToken, + NormalizedTokenKind, + ShingleSize, + WinnowWindow, }; use crate::hashing::{ - FNV_OFFSET_BASIS, RABIN_KARP_BASE, mix_byte as hash_byte, mix_bytes as hash_bytes, + FNV_OFFSET_BASIS, + RABIN_KARP_BASE, + mix_byte as hash_byte, + mix_bytes as hash_bytes, }; /// Builds Rabin-Karp fingerprints for all `k`-sized normalized token windows. @@ -182,32 +190,33 @@ fn hash_canonical_identifier_bytes(mut hash: u64, index: usize) -> u64 { hash_byte(hash, b'>') } -fn hash_usize_bytes(mut hash: u64, value: usize) -> u64 { +fn hash_usize_bytes(mut hash: u64, mut value: usize) -> u64 { + const ASCII_DIGITS: [u8; 10] = *b"0123456789"; let mut buffer = [0_u8; 20]; - let mut value = value; - let mut remaining = buffer.as_mut_slice(); if value == 0 { return hash_byte(hash, b'0'); } - while value > 0 { - let (slot, rest) = match remaining.split_last_mut() { - Some(parts) => parts, - None => unreachable!("usize decimal digits always fit within the buffer"), - }; - #[expect( - clippy::cast_possible_truncation, - reason = "a decimal digit always fits in u8" - )] - { - *slot = b'0' + (value % 10) as u8; + // Write digits least-significant first from the end of the buffer. A + // 64-bit `usize` has at most 20 decimal digits, so the reverse iterator + // never runs out before `value` reaches zero. + let mut digits = 0_usize; + for slot in buffer.iter_mut().rev() { + if value == 0 { + break; } - remaining = rest; - value /= 10; + // `rem_euclid(10)` is always below 10, so the lookup never falls + // through to the fallback digit. + *slot = ASCII_DIGITS + .get(value.rem_euclid(10)) + .copied() + .unwrap_or(b'0'); + value = value.div_euclid(10); + digits = digits.saturating_add(1); } - let start = remaining.len(); + let start = buffer.len().saturating_sub(digits); for byte in buffer.iter().skip(start) { hash = hash_byte(hash, *byte); } @@ -215,7 +224,7 @@ fn hash_usize_bytes(mut hash: u64, value: usize) -> u64 { hash } -fn token_kind_tag(token: &NormalizedToken) -> u8 { +const fn token_kind_tag(token: &NormalizedToken) -> u8 { match token.kind { super::types::NormalizedTokenKind::Atom(_) => b'a', super::types::NormalizedTokenKind::Identifier(_) => b'i', diff --git a/crates/whitaker_clones_core/src/token/mod.rs b/crates/whitaker_clones_core/src/token/mod.rs index 0aeef83e..95c77101 100644 --- a/crates/whitaker_clones_core/src/token/mod.rs +++ b/crates/whitaker_clones_core/src/token/mod.rs @@ -9,8 +9,14 @@ pub use error::{Result, TokenPassError}; pub use fingerprint::{hash_shingles, winnow}; pub use normalize::normalize; pub use types::{ - Fingerprint, IdentifierSymbol, LiteralSymbol, NormProfile, NormalizedToken, - NormalizedTokenKind, ShingleSize, WinnowWindow, + Fingerprint, + IdentifierSymbol, + LiteralSymbol, + NormProfile, + NormalizedToken, + NormalizedTokenKind, + ShingleSize, + WinnowWindow, }; #[cfg(test)] diff --git a/crates/whitaker_clones_core/src/token/normalize.rs b/crates/whitaker_clones_core/src/token/normalize.rs index c9a82b8b..bbc7725e 100644 --- a/crates/whitaker_clones_core/src/token/normalize.rs +++ b/crates/whitaker_clones_core/src/token/normalize.rs @@ -121,10 +121,12 @@ fn process_token( start: range.start, end: range.end, }), - TokenKind::Literal { kind, .. } => { - ensure_literal_is_terminated(kind, &range)?; + TokenKind::Literal { + kind: literal_kind, .. + } => { + ensure_literal_is_terminated(literal_kind, &range)?; Ok(Some(NormalizedToken::new( - normalize_literal(text, kind, profile), + normalize_literal(text, literal_kind, profile), range, ))) } @@ -180,7 +182,9 @@ fn process_token( /// /// assert_eq!( /// labels, -/// vec!["fn", "", "(", "", ":", "", ")", "{", "", "+", "", "}"] +/// vec![ +/// "fn", "", "(", "", ":", "", ")", "{", "", "+", "", "}" +/// ] /// ); /// # Ok::<(), whitaker_clones_core::TokenPassError>(()) /// ``` @@ -217,7 +221,7 @@ pub fn normalize(source: &str, profile: NormProfile) -> Result) -> Result<()> { +const fn ensure_literal_is_terminated(kind: LiteralKind, range: &Range) -> Result<()> { let terminated = match kind { LiteralKind::Int { .. } | LiteralKind::Float { .. } => true, LiteralKind::Char { terminated } @@ -245,15 +249,17 @@ fn normalize_ident( profile: NormProfile, state: &mut CanonicalState, ) -> NormalizedTokenKind { - match keyword_label(text) { - Some(keyword) => NormalizedTokenKind::Atom(keyword), - None => normalize_symbolic_text( - text, - profile, - || state.identifier_index(text), - NormalizedTokenKind::Identifier, - ), - } + keyword_label(text).map_or_else( + || { + normalize_symbolic_text( + text, + profile, + || state.identifier_index(text), + NormalizedTokenKind::Identifier, + ) + }, + NormalizedTokenKind::Atom, + ) } fn normalize_literal(text: &str, kind: LiteralKind, profile: NormProfile) -> NormalizedTokenKind { @@ -276,7 +282,7 @@ struct LiteralLabels { kind: &'static str, } -fn literal_labels(kind: LiteralKind) -> LiteralLabels { +const fn literal_labels(kind: LiteralKind) -> LiteralLabels { match kind { LiteralKind::Int { .. } => LiteralLabels { canonical: "", @@ -318,9 +324,7 @@ fn normalize_symbolic_text( wrap(symbol) } -fn raw_identifier_text(text: &str) -> &str { - text.strip_prefix("r#").unwrap_or(text) -} +fn raw_identifier_text(text: &str) -> &str { text.strip_prefix("r#").unwrap_or(text) } fn atom_label(kind: TokenKind) -> &'static str { match kind { @@ -361,8 +365,7 @@ fn atom_label(kind: TokenKind) -> &'static str { | TokenKind::Unknown => { debug_assert!( false, - "Token kind {:?} should be handled before atom_label", - kind + "Token kind {kind:?} should be handled before atom_label" ); "" } diff --git a/crates/whitaker_clones_core/src/token/tests.rs b/crates/whitaker_clones_core/src/token/tests.rs index 007ffe13..ba8852a0 100644 --- a/crates/whitaker_clones_core/src/token/tests.rs +++ b/crates/whitaker_clones_core/src/token/tests.rs @@ -2,12 +2,20 @@ use rstest::rstest; -use crate::hashing::{FNV_OFFSET_BASIS, FNV_PRIME, RABIN_KARP_BASE}; - use super::{ - Fingerprint, IdentifierSymbol, LiteralSymbol, NormProfile, NormalizedTokenKind, ShingleSize, - TokenPassError, WinnowWindow, hash_shingles, normalize, winnow, + Fingerprint, + IdentifierSymbol, + LiteralSymbol, + NormProfile, + NormalizedTokenKind, + ShingleSize, + TokenPassError, + WinnowWindow, + hash_shingles, + normalize, + winnow, }; +use crate::hashing::{FNV_OFFSET_BASIS, FNV_PRIME, RABIN_KARP_BASE}; fn labels(source: &str, profile: NormProfile) -> Result, TokenPassError> { normalize(source, profile).map(|tokens| { @@ -18,15 +26,19 @@ fn labels(source: &str, profile: NormProfile) -> Result, TokenPassEr }) } -fn literal_symbols(source: &str, profile: NormProfile) -> Vec { - normalize(source, profile) - .expect("literal normalization should succeed") - .into_iter() - .filter_map(|token| match token.kind { - NormalizedTokenKind::Literal(symbol) => Some(symbol), - _ => None, - }) - .collect() +fn literal_symbols( + source: &str, + profile: NormProfile, +) -> Result, TokenPassError> { + normalize(source, profile).map(|tokens| { + tokens + .into_iter() + .filter_map(|token| match token.kind { + NormalizedTokenKind::Literal(symbol) => Some(symbol), + _ => None, + }) + .collect() + }) } fn token_labels(tokens: &[super::NormalizedToken]) -> Vec { @@ -67,7 +79,9 @@ fn t2_canonicalizes_identifiers_literals_and_lifetimes() { fn byte_ranges_point_to_original_source() { let source = "fn demo() { value + 1 }"; let tokens = normalize(source, NormProfile::T1).expect("normalization should succeed"); - let value = &tokens[5]; + let value = tokens + .get(5) + .expect("token stream should contain the `value` identifier"); assert_eq!(value.range, 12..17); assert_eq!(source.get(value.range.clone()), Some("value")); @@ -102,7 +116,8 @@ fn exact_k_tokens_yields_one_hash() { ); assert_eq!(hashes.len(), 1); - assert_eq!(hashes[0].range, 0..12); + let only = hashes.first().expect("exactly one hash should be present"); + assert_eq!(only.range, 0..12); } #[test] @@ -232,7 +247,11 @@ fn shebang_is_stripped_equivalently_to_shebang_free_source() { "shebang should not affect the normalized token kinds" ); assert_eq!( - with_shebang[0].range.start, + with_shebang + .first() + .expect("normalized stream should not be empty") + .range + .start, source_with_shebang.find("fn").expect("fn present") ); } @@ -294,13 +313,20 @@ fn literal_variants_are_terminated_and_canonicalized( #[case] source: &str, #[case] assertion_message: &str, ) { - let literal_syms = literal_symbols(source, NormProfile::T1); + let literal_syms = + literal_symbols(source, NormProfile::T1).expect("literal normalization should succeed"); assert!( literal_syms.len() >= 2, "expected at least two literal tokens for the repeated literal pair" ); - assert_eq!(literal_syms[0], literal_syms[1], "{assertion_message}"); + let first = literal_syms + .first() + .expect("first literal symbol should be present"); + let second = literal_syms + .get(1) + .expect("second literal symbol should be present"); + assert_eq!(first, second, "{assertion_message}"); } #[test] diff --git a/crates/whitaker_clones_core/src/token/types.rs b/crates/whitaker_clones_core/src/token/types.rs index d1c70534..f7d7c786 100644 --- a/crates/whitaker_clones_core/src/token/types.rs +++ b/crates/whitaker_clones_core/src/token/types.rs @@ -99,19 +99,16 @@ pub struct ShingleSize(NonZeroUsize); impl ShingleSize { /// Returns the validated `k` as a plain `usize`. #[must_use] - pub const fn get(self) -> usize { - self.0.get() - } + pub const fn get(self) -> usize { self.0.get() } } impl TryFrom for ShingleSize { type Error = TokenPassError; fn try_from(value: usize) -> Result { - match NonZeroUsize::new(value) { - Some(value) => Ok(Self(value)), - None => Err(TokenPassError::ZeroShingleSize), - } + NonZeroUsize::new(value) + .ok_or(TokenPassError::ZeroShingleSize) + .map(Self) } } @@ -122,19 +119,16 @@ pub struct WinnowWindow(NonZeroUsize); impl WinnowWindow { /// Returns the validated window size as a plain `usize`. #[must_use] - pub const fn get(self) -> usize { - self.0.get() - } + pub const fn get(self) -> usize { self.0.get() } } impl TryFrom for WinnowWindow { type Error = TokenPassError; fn try_from(value: usize) -> Result { - match NonZeroUsize::new(value) { - Some(value) => Ok(Self(value)), - None => Err(TokenPassError::ZeroWinnowWindow), - } + NonZeroUsize::new(value) + .ok_or(TokenPassError::ZeroWinnowWindow) + .map(Self) } } @@ -150,7 +144,5 @@ pub struct Fingerprint { impl Fingerprint { /// Creates a fingerprint from a hash value and source range. #[must_use] - pub const fn new(hash: u64, range: Range) -> Self { - Self { hash, range } - } + pub const fn new(hash: u64, range: Range) -> Self { Self { hash, range } } } diff --git a/crates/whitaker_clones_core/tests/ast_boundary.rs b/crates/whitaker_clones_core/tests/ast_boundary.rs index 689179ff..11275ff5 100644 --- a/crates/whitaker_clones_core/tests/ast_boundary.rs +++ b/crates/whitaker_clones_core/tests/ast_boundary.rs @@ -12,10 +12,9 @@ const ADAPTER_OR_TEST_FILES: &[&str] = &["kani.rs", "lowering.rs", "lowering_tes #[test] fn ast_domain_files_do_not_import_parser_crates() -> Result<(), Box> { let domain_files = domain_files()?; - assert!( - !domain_files.is_empty(), - "AST domain-file discovery must not be empty" - ); + if domain_files.is_empty() { + return Err("AST domain-file discovery must not be empty".into()); + } for file in domain_files { assert_domain_boundary(&file.path, &file.contents); @@ -35,8 +34,8 @@ fn domain_files() -> Result, std::io::Error> { let mut files = Vec::new(); for entry in ast_directory.entries()? { - let entry = entry?; - let filename = entry.file_name()?; + let dir_entry = entry?; + let filename = dir_entry.file_name()?; let path = ast_path.join(&filename); let should_include = path.extension() == Some("rs") && !ADAPTER_OR_TEST_FILES.contains(&filename.as_str()); @@ -108,17 +107,19 @@ fn use_trees(contents: &str) -> Vec> { let mut imports = Vec::new(); let mut index = 0; - while index < tokens.len() { - if tokens[index] != "use" { + while let Some(&token) = tokens.get(index) { + if token != "use" { index += 1; continue; } - let end = tokens[index + 1..] + let end = tokens + .get(index + 1..) + .unwrap_or_default() .iter() - .position(|token| *token == ";") + .position(|candidate| *candidate == ";") .map_or(tokens.len(), |offset| index + 1 + offset); - imports.push(tokens[index + 1..end].to_vec()); + imports.push(tokens.get(index + 1..end).unwrap_or_default().to_vec()); index = end + 1; } @@ -135,14 +136,14 @@ fn non_comment_lexemes(contents: &str) -> Vec<&str> { tokenize(contents) .filter_map(|token| { let end = offset + token.len; - let lexeme = &contents[offset..end]; + let lexeme = contents.get(offset..end).unwrap_or(""); offset = end; is_non_comment_lexeme(token.kind).then(|| normalize_raw_identifier(token.kind, lexeme)) }) .collect() } -fn is_non_comment_lexeme(kind: TokenKind) -> bool { +const fn is_non_comment_lexeme(kind: TokenKind) -> bool { !matches!( kind, TokenKind::Whitespace @@ -170,12 +171,12 @@ fn coalesce_path_separators<'a>(tokens: &[&'a str]) -> Vec<&'a str> { let mut coalesced = Vec::new(); let mut index = 0; - while index < tokens.len() { - if tokens[index] == ":" && tokens.get(index + 1) == Some(&":") { + while let Some(&token) = tokens.get(index) { + if token == ":" && tokens.get(index + 1) == Some(&":") { coalesced.push("::"); index += 2; } else { - coalesced.push(tokens[index]); + coalesced.push(token); index += 1; } } @@ -190,7 +191,7 @@ fn imports_crate(import: &[&str], forbidden: &str) -> bool { index .checked_sub(1) .and_then(|previous| import.get(previous)), - None | Some(&"::") | Some(&"{") | Some(&",") + None | Some(&"::" | &"{" | &",") ) }) } @@ -206,9 +207,7 @@ fn contains_path(import: &[&str], path: &[&str]) -> bool { /// `[[crate, ::, ast, ::, tree, ::, ByteSpan],` /// ` [crate, ::, ast, ::, lowering, ::, lower_span]]`, so `contains_path` sees /// `ast :: lowering` regardless of the sibling ordering. -fn expand_use_tree<'a>(tokens: &[&'a str]) -> Vec> { - parse_use_tree(tokens, &[]).0 -} +fn expand_use_tree<'a>(tokens: &[&'a str]) -> Vec> { parse_use_tree(tokens, &[]).0 } /// Parses one use-tree item — a path prefix optionally followed by a `{ … }` /// group — returning the leaf paths it expands to and the unconsumed remainder @@ -217,10 +216,11 @@ fn parse_use_tree<'a>(tokens: &[&'a str], prefix: &[&'a str]) -> (Vec { - let (leaves, consumed) = parse_group(&tokens[index + 1..], &path); + let group_tokens = tokens.get(index + 1..).unwrap_or_default(); + let (leaves, consumed) = parse_group(group_tokens, &path); return (leaves, index + 1 + consumed); } "," | "}" => break, @@ -243,7 +243,8 @@ fn parse_group<'a>(tokens: &[&'a str], prefix: &[&'a str]) -> (Vec> let mut position = 0; loop { - let (sibling_leaves, consumed) = parse_use_tree(&tokens[position..], prefix); + let (sibling_leaves, consumed) = + parse_use_tree(tokens.get(position..).unwrap_or_default(), prefix); leaves.extend(sibling_leaves); position += consumed; @@ -294,7 +295,8 @@ fn non_import_text_does_not_trigger_the_boundary_guard(#[case] source: &str) { #[test] fn non_comment_tokens_discard_comments_and_strings_but_keep_paths() { let tokens = non_comment_tokens( - "// hidden_comment\nconst HIDDEN: &str = \"hidden string\";\nuse crate::ast::tree::ByteSpan;", + "// hidden_comment\nconst HIDDEN: &str = \"hidden string\";\nuse \ + crate::ast::tree::ByteSpan;", ); assert!(!tokens.iter().any(|token| token.contains("hidden_comment"))); diff --git a/crates/whitaker_clones_core/tests/ast_feature_extraction_behaviour.rs b/crates/whitaker_clones_core/tests/ast_feature_extraction_behaviour.rs index 994797f0..88d4059f 100644 --- a/crates/whitaker_clones_core/tests/ast_feature_extraction_behaviour.rs +++ b/crates/whitaker_clones_core/tests/ast_feature_extraction_behaviour.rs @@ -9,8 +9,14 @@ use ra_ap_syntax::SyntaxKind; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use whitaker_clones_core::{ - AstError, AstHash, ByteSpan, NormalizedTree, canonical_hash, lower_span, + AstError, + AstHash, + ByteSpan, + NormalizedTree, + canonical_hash, + lower_span, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Clone, Copy)] enum SnippetName { @@ -33,7 +39,7 @@ impl SnippetName { } } - fn source(self) -> &'static str { + const fn source(self) -> &'static str { match self { Self::AddFunction => "fn add(a: i32, b: i32) -> i32 { a + b }", Self::AddExpression => "a + b", @@ -61,7 +67,7 @@ impl ExpectedKind { } } - fn syntax_kind(self) -> SyntaxKind { + const fn syntax_kind(self) -> SyntaxKind { match self { Self::BinExpr => SyntaxKind::BIN_EXPR, Self::SourceFile => SyntaxKind::SOURCE_FILE, @@ -81,34 +87,44 @@ struct AstFeatureWorld { right_hash: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> AstFeatureWorld { - AstFeatureWorld::default() -} +fn world() -> AstFeatureWorld { AstFeatureWorld::default() } -fn whole_source_hash(source: &str) -> Result { - let span = ByteSpan::new(source, 0, source.len() as u32)?; - Ok(canonical_hash(&lower_span(source, span)?)) +fn whole_source_hash(source: &str) -> Result { + let length = u32::try_from(source.len()) + .map_err(|error| format!("source length must fit in u32: {error}"))?; + let span = ByteSpan::new(source, 0, length).map_err(|error| error.to_string())?; + let tree = lower_span(source, span).map_err(|error| error.to_string())?; + Ok(canonical_hash(&tree)) } #[given("the source snippet {name}")] fn given_source(world: &AstFeatureWorld, name: String) { - *world.source.borrow_mut() = SnippetName::parse(&name).source().to_owned(); + SnippetName::parse(&name) + .source() + .clone_into(&mut world.source.borrow_mut()); } #[given("the candidate span snippet {name}")] fn given_candidate_span(world: &AstFeatureWorld, name: String) { - *world.span_needle.borrow_mut() = SnippetName::parse(&name).source().to_owned(); + SnippetName::parse(&name) + .source() + .clone_into(&mut world.span_needle.borrow_mut()); } #[given("the left source snippet {name}")] fn given_left_source(world: &AstFeatureWorld, name: String) { - *world.left_source.borrow_mut() = SnippetName::parse(&name).source().to_owned(); + SnippetName::parse(&name) + .source() + .clone_into(&mut world.left_source.borrow_mut()); } #[given("the right source snippet {name}")] fn given_right_source(world: &AstFeatureWorld, name: String) { - *world.right_source.borrow_mut() = SnippetName::parse(&name).source().to_owned(); + SnippetName::parse(&name) + .source() + .clone_into(&mut world.right_source.borrow_mut()); } #[when("the candidate span is lowered")] @@ -120,10 +136,11 @@ fn when_candidate_span_is_lowered(world: &AstFeatureWorld) { return; }; let end = start + needle.len(); + let (Ok(span_start), Ok(span_end)) = (u32::try_from(start), u32::try_from(end)) else { + panic!("candidate span offsets {start}..{end} must fit in u32"); + }; - match ByteSpan::new(&source, start as u32, end as u32) - .and_then(|span| lower_span(&source, span)) - { + match ByteSpan::new(&source, span_start, span_end).and_then(|span| lower_span(&source, span)) { Ok(tree) => { *world.lowered.borrow_mut() = Some(tree); *world.lowering_error.borrow_mut() = None; @@ -136,7 +153,7 @@ fn when_candidate_span_is_lowered(world: &AstFeatureWorld) { } #[when("both whole sources are lowered and hashed")] -fn when_both_whole_sources_are_lowered_and_hashed(world: &AstFeatureWorld) -> Result<(), AstError> { +fn when_both_whole_sources_are_lowered_and_hashed(world: &AstFeatureWorld) -> Result<(), String> { *world.left_hash.borrow_mut() = Some(whole_source_hash(&world.left_source.borrow())?); *world.right_hash.borrow_mut() = Some(whole_source_hash(&world.right_source.borrow())?); Ok(()) @@ -151,22 +168,26 @@ fn then_lowered_root_kind_is(world: &AstFeatureWorld, kind: String) { ); let expected = u16::from(ExpectedKind::parse(&kind).syntax_kind()); let lowered = world.lowered.borrow(); - let tree = lowered - .as_ref() - .expect("lowered tree should be available after lowering"); + let Some(tree) = lowered.as_ref() else { + panic!("lowered tree must be available after lowering"); + }; - assert_eq!(tree.root().kind().get(), expected); + assert_eq!( + tree.root().kind().get(), + expected, + "lowered root kind must match the scenario expectation" + ); } fn ast_hash_pair(world: &AstFeatureWorld) -> (AstHash, AstHash) { let left_hash = world .left_hash .borrow() - .expect("left AST hash should be available after lowering"); + .unwrap_or_else(|| panic!("left AST hash must be available after lowering")); let right_hash = world .right_hash .borrow() - .expect("right AST hash should be available after lowering"); + .unwrap_or_else(|| panic!("right AST hash must be available after lowering")); (left_hash, right_hash) } @@ -175,14 +196,20 @@ fn ast_hash_pair(world: &AstFeatureWorld) -> (AstHash, AstHash) { fn then_ast_hashes_match(world: &AstFeatureWorld) { let (left_hash, right_hash) = ast_hash_pair(world); - assert_eq!(left_hash, right_hash); + assert_eq!( + left_hash, right_hash, + "AST hashes must match for these snippets" + ); } #[then("the AST hashes differ")] fn then_ast_hashes_differ(world: &AstFeatureWorld) { let (left_hash, right_hash) = ast_hash_pair(world); - assert_ne!(left_hash, right_hash); + assert_ne!( + left_hash, right_hash, + "AST hashes must differ for these snippets" + ); } /// The `#[scenario]` macro runs every Gherkin step for this scenario before diff --git a/crates/whitaker_clones_core/tests/build_script_integration.rs b/crates/whitaker_clones_core/tests/build_script_integration.rs index 9b063dcb..d3a01622 100644 --- a/crates/whitaker_clones_core/tests/build_script_integration.rs +++ b/crates/whitaker_clones_core/tests/build_script_integration.rs @@ -25,18 +25,29 @@ fn build_script_accepts_an_exact_workspace_parser_pin( let fixture = build_fixture?; let output = cargo_check(&fixture.manifest_path)?; - assert!( - output.status.success(), - "exact parser pin should pass the build script:\n{}", - String::from_utf8_lossy(&output.stderr) - ); + if !output.status.success() { + return Err(format!( + "exact parser pin must pass the build script:\n{}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } let run_output = cargo_run(&fixture.manifest_path)?; - assert!( - run_output.status.success(), - "parser-version fixture should run:\n{}", - String::from_utf8_lossy(&run_output.stderr) - ); - assert_eq!(String::from_utf8(run_output.stdout)?.trim(), "0.0.334"); + if !run_output.status.success() { + return Err(format!( + "parser-version fixture must run:\n{}", + String::from_utf8_lossy(&run_output.stderr) + ) + .into()); + } + let stdout = String::from_utf8(run_output.stdout)?; + if stdout.trim() != "0.0.334" { + return Err(format!( + "parser-version fixture must print 0.0.334, printed `{}`", + stdout.trim() + ) + .into()); + } Ok(()) } @@ -48,14 +59,14 @@ fn build_script_rejects_a_loose_workspace_parser_pin( let output = cargo_check(&fixture.manifest_path)?; let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !output.status.success(), - "loose parser pin should fail the build script" - ); - assert!( - stderr.contains("must be exact-pinned"), - "build-script failure should explain the parser-pin rule:\n{stderr}" - ); + if output.status.success() { + return Err("loose parser pin must fail the build script".into()); + } + if !stderr.contains("must be exact-pinned") { + return Err( + format!("build-script failure must explain the parser-pin rule:\n{stderr}").into(), + ); + } Ok(()) } @@ -67,14 +78,14 @@ fn build_script_rejects_a_missing_workspace_parser_pin( let output = cargo_check(&fixture.manifest_path)?; let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !output.status.success(), - "missing parser pin should fail the build script" - ); - assert!( - stderr.contains("workspace dependency `ra_ap_syntax` is missing"), - "build-script failure should explain the missing parser pin:\n{stderr}" - ); + if output.status.success() { + return Err("missing parser pin must fail the build script".into()); + } + if !stderr.contains("workspace dependency `ra_ap_syntax` is missing") { + return Err( + format!("build-script failure must explain the missing parser pin:\n{stderr}").into(), + ); + } Ok(()) } diff --git a/crates/whitaker_clones_core/tests/build_script_parsing.rs b/crates/whitaker_clones_core/tests/build_script_parsing.rs index 24e7b6f8..862234a0 100644 --- a/crates/whitaker_clones_core/tests/build_script_parsing.rs +++ b/crates/whitaker_clones_core/tests/build_script_parsing.rs @@ -4,7 +4,10 @@ mod build_support; use build_support::{ - exact_version, find_workspace_manifest, is_workspace_manifest, parser_dependency_requirement, + exact_version, + find_workspace_manifest, + is_workspace_manifest, + parser_dependency_requirement, read_workspace_manifest, }; use camino::{Utf8Path, Utf8PathBuf}; @@ -111,7 +114,13 @@ fn finds_nearest_workspace_manifest( let member = fixture.root.join("nested-workspace").join("member"); let workspace = fixture.root.join("nested-workspace").join("Cargo.toml"); - assert_eq!(find_workspace_manifest(&member)?, workspace); + let located = find_workspace_manifest(&member)?; + if located != workspace { + return Err(format!( + "expected the nearest workspace manifest at `{workspace}`, found `{located}`" + ) + .into()); + } Ok(()) } @@ -124,7 +133,9 @@ fn ignores_non_workspace_manifests( let manifest = fixture.root.join("Cargo.toml"); - assert!(!is_workspace_manifest(&manifest)?); + if is_workspace_manifest(&manifest)? { + return Err("a package-only manifest must not count as a workspace manifest".into()); + } Ok(()) } @@ -139,12 +150,12 @@ fn reports_missing_workspace_manifest( let error = find_workspace_manifest(&nested) .expect_err("a directory without a workspace manifest should fail"); - assert_eq!( - error - .downcast_ref::() - .map(std::io::Error::kind), - Some(std::io::ErrorKind::NotFound) - ); + let kind = error + .downcast_ref::() + .map(std::io::Error::kind); + if kind != Some(std::io::ErrorKind::NotFound) { + return Err(format!("expected a NotFound I/O error, found {kind:?}").into()); + } Ok(()) } @@ -157,7 +168,10 @@ fn reads_a_located_manifest( let manifest = fixture.root.join("Cargo.toml"); - assert_eq!(read_workspace_manifest(&manifest)?, WORKSPACE_MANIFEST); + let contents = read_workspace_manifest(&manifest)?; + if contents != WORKSPACE_MANIFEST { + return Err(format!("manifest contents must round-trip, found `{contents}`").into()); + } Ok(()) } @@ -166,23 +180,24 @@ fn read_of_absent_manifest_reports_not_found() -> Result<(), Box() - .map(std::io::Error::kind), - Some(std::io::ErrorKind::NotFound) - ); + let kind = error + .downcast_ref::() + .map(std::io::Error::kind); + if kind != Some(std::io::ErrorKind::NotFound) { + return Err(format!("expected a NotFound I/O error, found {kind:?}").into()); + } Ok(()) } proptest! { #[test] fn exact_version_accepts_only_non_empty_exact_pins( - prefix in prop_oneof![Just("=".to_owned()), Just("^".to_owned()), Just("".to_owned())], + prefix in prop_oneof![Just("=".to_owned()), Just("^".to_owned()), Just(String::new())], suffix in "[A-Za-z0-9._-]{0,32}", ) { let requirement = format!("{prefix}{suffix}"); diff --git a/crates/whitaker_clones_core/tests/candidate_pair_behaviour.rs b/crates/whitaker_clones_core/tests/candidate_pair_behaviour.rs index 832448ed..11d13f5e 100644 --- a/crates/whitaker_clones_core/tests/candidate_pair_behaviour.rs +++ b/crates/whitaker_clones_core/tests/candidate_pair_behaviour.rs @@ -7,6 +7,7 @@ use std::cell::RefCell; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use whitaker_clones_core::{CandidatePair, FragmentId}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct CandidatePairWorld { @@ -15,17 +16,16 @@ struct CandidatePairWorld { pair: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> CandidatePairWorld { - CandidatePairWorld::default() -} +fn world() -> CandidatePairWorld { CandidatePairWorld::default() } fn with_pair(world: &CandidatePairWorld, assert_fn: impl FnOnce(&CandidatePair)) { - let pair = world.pair.borrow(); - match pair.as_ref() { - Some(pair) => assert_fn(pair), - None => panic!("candidate pair must be present before running assertions"), - } + let borrowed_pair = world.pair.borrow(); + let Some(candidate_pair) = borrowed_pair.as_ref() else { + panic!("candidate pair must be present before running assertions"); + }; + assert_fn(candidate_pair); } #[given("input fragment IDs {left} and {right}")] @@ -56,32 +56,28 @@ fn then_the_canonical_pair_is(world: &CandidatePairWorld, left: String, right: S with_pair(world, |pair| { assert_eq!( (pair.left().as_str(), pair.right().as_str()), - (left.as_str(), right.as_str()) + (left.as_str(), right.as_str()), + "canonical pair ordering must match the scenario expectation" ); }); } #[then("no candidate pair is returned")] fn then_no_candidate_pair_is_returned(world: &CandidatePairWorld) { - assert!(world.pair.borrow().is_none()); + assert!( + world.pair.borrow().is_none(), + "no candidate pair must be returned for identical fragment IDs" + ); } #[scenario(path = "tests/features/candidate_pair.feature", index = 0)] -fn scenario_ordered_distinct_ids(world: CandidatePairWorld) { - let _ = world; -} +fn scenario_ordered_distinct_ids(world: CandidatePairWorld) { let _ = world; } #[scenario(path = "tests/features/candidate_pair.feature", index = 1)] -fn scenario_reversed_distinct_ids(world: CandidatePairWorld) { - let _ = world; -} +fn scenario_reversed_distinct_ids(world: CandidatePairWorld) { let _ = world; } #[scenario(path = "tests/features/candidate_pair.feature", index = 2)] -fn scenario_identical_ids(world: CandidatePairWorld) { - let _ = world; -} +fn scenario_identical_ids(world: CandidatePairWorld) { let _ = world; } #[scenario(path = "tests/features/candidate_pair.feature", index = 3)] -fn scenario_lexical_order_edge_case(world: CandidatePairWorld) { - let _ = world; -} +fn scenario_lexical_order_edge_case(world: CandidatePairWorld) { let _ = world; } diff --git a/crates/whitaker_clones_core/tests/min_hash_lsh_behaviour.rs b/crates/whitaker_clones_core/tests/min_hash_lsh_behaviour.rs index 01b013b4..5c230c33 100644 --- a/crates/whitaker_clones_core/tests/min_hash_lsh_behaviour.rs +++ b/crates/whitaker_clones_core/tests/min_hash_lsh_behaviour.rs @@ -1,4 +1,4 @@ -//! Behaviour-driven coverage for MinHash and LSH candidate generation. +//! Behaviour-driven coverage for `MinHash` and LSH candidate generation. //! //! Keep this harness in sync with `tests/features/min_hash_lsh.feature`. @@ -7,8 +7,15 @@ use std::{cell::RefCell, collections::BTreeMap}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use whitaker_clones_core::{ - CandidatePair, Fingerprint, FragmentId, IndexError, LshConfig, LshIndex, MinHasher, + CandidatePair, + Fingerprint, + FragmentId, + IndexError, + LshConfig, + LshIndex, + MinHasher, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct MinHashLshWorld { @@ -19,10 +26,9 @@ struct MinHashLshWorld { candidate_error: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> MinHashLshWorld { - MinHashLshWorld::default() -} +fn world() -> MinHashLshWorld { MinHashLshWorld::default() } fn with_candidates(world: &MinHashLshWorld, assert_fn: impl FnOnce(&[CandidatePair])) { let candidates = world.candidates.borrow(); @@ -52,15 +58,15 @@ fn expected_error(name: &str) -> Result { "invalid InvalidBandRowProduct arguments `{arguments}`" )); }; - let bands = bands + let band_count = bands .trim() .parse::() .map_err(|error| format!("invalid bands value `{bands}`: {error}"))?; - let rows = rows + let row_count = rows .trim() .parse::() .map_err(|error| format!("invalid rows value `{rows}`: {error}"))?; - return Ok(IndexError::invalid_band_row_product(bands, rows)); + return Ok(IndexError::invalid_band_row_product(band_count, row_count)); } match name { @@ -104,7 +110,7 @@ fn given_fragment_without_hashes(world: &MinHashLshWorld, id: String) { #[when("candidate pairs are generated")] fn when_candidate_pairs_are_generated(world: &MinHashLshWorld) { let Some(config) = *world.config.borrow() else { - *world.candidate_error.borrow_mut() = world.config_error.borrow().clone(); + (*world.candidate_error.borrow_mut()).clone_from(&world.config_error.borrow()); world.candidates.borrow_mut().clear(); return; }; @@ -129,7 +135,11 @@ fn when_candidate_pairs_are_generated(world: &MinHashLshWorld) { #[then("candidate pair count is {count}")] fn then_candidate_pair_count_is(world: &MinHashLshWorld, count: usize) { with_candidates(world, |candidates| { - assert_eq!(candidates.len(), count); + assert_eq!( + candidates.len(), + count, + "candidate pair count must match the scenario expectation" + ); }); } @@ -142,65 +152,64 @@ fn then_only_candidate_pair_is(world: &MinHashLshWorld, left: String, right: Str candidates.len() ); }; - let expected = CandidatePair::new(FragmentId::from(left), FragmentId::from(right)) - .expect("distinct fragment IDs should form a canonical pair"); - assert_eq!(candidate, &expected); + let Some(expected) = CandidatePair::new(FragmentId::from(left), FragmentId::from(right)) + else { + panic!("distinct fragment IDs must form a canonical pair"); + }; + assert_eq!( + candidate, &expected, + "candidate pair must match the scenario expectation" + ); }); } #[then("no candidate pairs are returned")] fn then_no_candidate_pairs_are_returned(world: &MinHashLshWorld) { - with_candidates(world, |candidates| assert!(candidates.is_empty())); + with_candidates(world, |candidates| { + assert!( + candidates.is_empty(), + "no candidate pairs must be returned for this scenario" + ); + }); } #[then("the candidate generation error is {name}")] fn then_candidate_generation_error_is(world: &MinHashLshWorld, name: String) -> Result<(), String> { let expected = expected_error(&name)?; - match world.candidate_error.borrow().clone() { - Some(actual) => { - assert_eq!(actual, expected); - Ok(()) - } - None => Err("candidate generation error must be present".to_owned()), - } + world.candidate_error.borrow().as_ref().map_or_else( + || Err("candidate generation error must be present".to_owned()), + |actual| { + if *actual == expected { + Ok(()) + } else { + Err(format!( + "expected candidate generation error `{expected:?}`, found `{actual:?}`" + )) + } + }, + ) } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 0)] -fn scenario_identical_fragments(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_identical_fragments(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 1)] -fn scenario_distinct_fragments(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_distinct_fragments(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 2)] -fn scenario_multiple_collisions_one_pair(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_multiple_collisions_one_pair(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 3)] -fn scenario_duplicate_hashes_use_set_semantics(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_duplicate_hashes_use_set_semantics(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 4)] -fn scenario_invalid_lsh_settings(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_invalid_lsh_settings(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 5)] -fn scenario_empty_fingerprints(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_empty_fingerprints(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 6)] -fn scenario_zero_rows(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_zero_rows(world: MinHashLshWorld) { let _ = world; } #[scenario(path = "tests/features/min_hash_lsh.feature", index = 7)] -fn scenario_invalid_non_zero_product(world: MinHashLshWorld) { - let _ = world; -} +fn scenario_invalid_non_zero_product(world: MinHashLshWorld) { let _ = world; } diff --git a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs index a11090df..07a08139 100644 --- a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs +++ b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs @@ -7,10 +7,16 @@ use std::{cell::RefCell, collections::BTreeMap}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use whitaker_clones_core::{ - CandidatePair, FragmentId, Run0Error, TokenFragment, TokenPassConfig, accept_candidate_pairs, + CandidatePair, + FragmentId, + Run0Error, + TokenFragment, + TokenPassConfig, + accept_candidate_pairs, emit_run0, }; use whitaker_sarif::{Run, SarifResult, WhitakerProperties}; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug)] struct Run0World { @@ -33,17 +39,16 @@ impl Default for Run0World { } } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> Run0World { - Run0World::default() -} +fn world() -> Run0World { Run0World::default() } fn with_results(world: &Run0World, assert_fn: impl FnOnce(&[SarifResult])) { - let run = world.run.borrow(); - match run.as_ref() { - Some(run) => assert_fn(&run.results), - None => panic!("run must be emitted before checking results"), - } + let borrowed_run = world.run.borrow(); + let Some(emitted_run) = borrowed_run.as_ref() else { + panic!("run must be emitted before checking results"); + }; + assert_fn(&emitted_run.results); } fn with_whitaker_properties(world: &Run0World, assert_fn: impl FnOnce(&WhitakerProperties)) { @@ -51,12 +56,13 @@ fn with_whitaker_properties(world: &Run0World, assert_fn: impl FnOnce(&WhitakerP let [result] = results else { panic!("exactly one result must exist before checking Whitaker properties"); }; - let properties = result - .properties - .as_ref() - .unwrap_or_else(|| panic!("Whitaker properties must be present")); - let extracted = WhitakerProperties::try_from(properties) - .unwrap_or_else(|error| panic!("unexpected property extraction error: {error}")); + let Some(properties) = result.properties.as_ref() else { + panic!("Whitaker properties must be present"); + }; + let extracted = match WhitakerProperties::try_from(properties) { + Ok(extracted) => extracted, + Err(error) => panic!("unexpected property extraction error: {error}"), + }; assert_fn(&extracted); }); } @@ -178,7 +184,13 @@ fn when_run_zero_is_emitted(world: &Run0World) { #[then("exactly {count} result is emitted")] fn then_exactly_one_result_is_emitted(world: &Run0World, count: usize) { - with_results(world, |results| assert_eq!(results.len(), count)); + with_results(world, |results| { + assert_eq!( + results.len(), + count, + "emitted result count must match the scenario expectation" + ); + }); } #[then("the emitted rule is {rule_id}")] @@ -187,7 +199,10 @@ fn then_emitted_rule_is(world: &Run0World, rule_id: String) { let [result] = results else { panic!("exactly one result must exist before checking the rule"); }; - assert_eq!(result.rule_id, rule_id); + assert_eq!( + result.rule_id, rule_id, + "emitted rule identifier must match the scenario expectation" + ); }); } @@ -197,40 +212,71 @@ fn then_result_has_locations(world: &Run0World, primary_count: usize, related_co let [result] = results else { panic!("exactly one result must exist before checking locations"); }; - assert_eq!(result.locations.len(), primary_count); - assert_eq!(result.related_locations.len(), related_count); + assert_eq!( + result.locations.len(), + primary_count, + "primary location count must match the scenario expectation" + ); + assert_eq!( + result.related_locations.len(), + related_count, + "related location count must match the scenario expectation" + ); }); } #[then("the Whitaker profile is {profile}")] fn then_whitaker_profile_is(world: &Run0World, profile: String) { - with_whitaker_properties(world, |props| assert_eq!(props.profile, profile)); + with_whitaker_properties(world, |props| { + assert_eq!( + props.profile, profile, + "Whitaker profile must match the scenario expectation" + ); + }); } #[then("the Whitaker k is {k}")] fn then_whitaker_k_is(world: &Run0World, k: usize) { - with_whitaker_properties(world, |props| assert_eq!(props.k, k)); + with_whitaker_properties(world, |props| { + assert_eq!(props.k, k, "Whitaker k must match the scenario expectation"); + }); } #[then("the Whitaker window is {window}")] fn then_whitaker_window_is(world: &Run0World, window: usize) { - with_whitaker_properties(world, |props| assert_eq!(props.window, window)); + with_whitaker_properties(world, |props| { + assert_eq!( + props.window, window, + "Whitaker window must match the scenario expectation" + ); + }); } #[then("no results are emitted")] fn then_no_results_are_emitted(world: &Run0World) { - with_results(world, |results| assert!(results.is_empty())); + with_results(world, |results| { + assert!( + results.is_empty(), + "no results must be emitted for this scenario" + ); + }); } #[then("the emission error is {message}")] fn then_emission_error_is(world: &Run0World, message: String) -> Result<(), String> { - match world.error.borrow().as_ref() { - Some(error) => { - assert_eq!(error.to_string(), message); - Ok(()) - } - None => Err("an emission error must be present".to_owned()), - } + world.error.borrow().as_ref().map_or_else( + || Err("an emission error must be present".to_owned()), + |error| { + let actual = error.to_string(); + if actual == message { + Ok(()) + } else { + Err(format!( + "expected emission error `{message}`, but the run reported `{actual}`" + )) + } + }, + ) } #[then("the primary region is {region}")] @@ -239,13 +285,11 @@ fn then_primary_region_is(world: &Run0World, region: String) { let [result] = results else { panic!("exactly one result must exist before checking the primary region"); }; - let location = match result.locations.first() { - Some(location) => location, - None => panic!("a primary location must be present"), + let Some(location) = result.locations.first() else { + panic!("a primary location must be present"); }; - let region_value = match location.physical_location.region.as_ref() { - Some(region_value) => region_value, - None => panic!("a primary region must be present"), + let Some(region_value) = location.physical_location.region.as_ref() else { + panic!("a primary region must be present"); }; let actual = format!( "{}:{}-{}:{}", @@ -254,9 +298,12 @@ fn then_primary_region_is(world: &Run0World, region: String) { region_value.end_line.unwrap_or(region_value.start_line), region_value .end_column - .unwrap_or(region_value.start_column.unwrap_or(1)) + .unwrap_or_else(|| region_value.start_column.unwrap_or(1)) + ); + assert_eq!( + actual, region, + "primary region must match the scenario expectation" ); - assert_eq!(actual, region); }); } @@ -266,40 +313,30 @@ fn then_primary_file_is(world: &Run0World, file_uri: String) { let [result] = results else { panic!("exactly one result must exist before checking the primary file"); }; - let location = match result.locations.first() { - Some(location) => location, - None => panic!("a primary location must be present"), + let Some(location) = result.locations.first() else { + panic!("a primary location must be present"); }; - assert_eq!(location.physical_location.artifact_location.uri, file_uri); + assert_eq!( + location.physical_location.artefact_location.uri, file_uri, + "primary file URI must match the scenario expectation" + ); }); } #[scenario(path = "tests/features/run0_sarif.feature", index = 0)] -fn scenario_type1_pair(world: Run0World) { - let _ = world; -} +fn scenario_type1_pair(world: Run0World) { let _ = world; } #[scenario(path = "tests/features/run0_sarif.feature", index = 1)] -fn scenario_type2_pair(world: Run0World) { - let _ = world; -} +fn scenario_type2_pair(world: Run0World) { let _ = world; } #[scenario(path = "tests/features/run0_sarif.feature", index = 2)] -fn scenario_below_threshold(world: Run0World) { - let _ = world; -} +fn scenario_below_threshold(world: Run0World) { let _ = world; } #[scenario(path = "tests/features/run0_sarif.feature", index = 3)] -fn scenario_empty_fingerprints(world: Run0World) { - let _ = world; -} +fn scenario_empty_fingerprints(world: Run0World) { let _ = world; } #[scenario(path = "tests/features/run0_sarif.feature", index = 4)] -fn scenario_multiline_region(world: Run0World) { - let _ = world; -} +fn scenario_multiline_region(world: Run0World) { let _ = world; } #[scenario(path = "tests/features/run0_sarif.feature", index = 5)] -fn scenario_reversed_pair(world: Run0World) { - let _ = world; -} +fn scenario_reversed_pair(world: Run0World) { let _ = world; } diff --git a/crates/whitaker_clones_core/tests/token_pass_behaviour.rs b/crates/whitaker_clones_core/tests/token_pass_behaviour.rs index 5cc459c9..b5c36395 100644 --- a/crates/whitaker_clones_core/tests/token_pass_behaviour.rs +++ b/crates/whitaker_clones_core/tests/token_pass_behaviour.rs @@ -8,9 +8,16 @@ use std::cell::RefCell; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use whitaker_clones_core::{ - Fingerprint, NormProfile, ShingleSize, TokenPassError, WinnowWindow, hash_shingles, normalize, + Fingerprint, + NormProfile, + ShingleSize, + TokenPassError, + WinnowWindow, + hash_shingles, + normalize, winnow, }; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct TokenPassWorld { @@ -28,10 +35,9 @@ struct TokenPassWorld { window_error: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> TokenPassWorld { - TokenPassWorld::default() -} +fn world() -> TokenPassWorld { TokenPassWorld::default() } fn with_fingerprints(world: &TokenPassWorld, assert_fn: impl FnOnce(&[Fingerprint])) { let fingerprints = world.fingerprints.borrow(); @@ -65,12 +71,12 @@ fn snippet_for_name(name: &str) -> &'static str { #[given("the source snippet {name}")] fn given_source(world: &TokenPassWorld, name: String) { - *world.source.borrow_mut() = snippet_for_name(&name).to_owned(); + snippet_for_name(&name).clone_into(&mut world.source.borrow_mut()); } #[given("the comparison source snippet {name}")] fn given_comparison_source(world: &TokenPassWorld, name: String) { - *world.comparison_source.borrow_mut() = snippet_for_name(&name).to_owned(); + snippet_for_name(&name).clone_into(&mut world.comparison_source.borrow_mut()); } #[given("the profile is {profile}")] @@ -85,8 +91,8 @@ fn given_profile(world: &TokenPassWorld, profile: String) { #[given("shingle size {size}")] fn given_shingle_size(world: &TokenPassWorld, size: usize) { match ShingleSize::try_from(size) { - Ok(size) => { - *world.k.borrow_mut() = Some(size); + Ok(shingle_size) => { + *world.k.borrow_mut() = Some(shingle_size); *world.k_error.borrow_mut() = None; } Err(error) => { @@ -99,8 +105,8 @@ fn given_shingle_size(world: &TokenPassWorld, size: usize) { #[given("winnow window {window}")] fn given_window(world: &TokenPassWorld, window: usize) { match WinnowWindow::try_from(window) { - Ok(window) => { - *world.window.borrow_mut() = Some(window); + Ok(winnow_window) => { + *world.window.borrow_mut() = Some(winnow_window); *world.window_error.borrow_mut() = None; } Err(error) => { @@ -199,7 +205,11 @@ fn then_normalized_labels_are(world: &TokenPassWorld, labels: String) { .split_whitespace() .map(ToOwned::to_owned) .collect::>(); - assert_eq!(*world.normalized_labels.borrow(), expected); + assert_eq!( + *world.normalized_labels.borrow(), + expected, + "normalized labels must match the scenario expectation" + ); } #[then("the normalized labels match exactly")] @@ -214,7 +224,11 @@ fn then_normalized_labels_match_exactly(world: &TokenPassWorld) { #[then("the fingerprint count is {count}")] fn then_fingerprint_count_is(world: &TokenPassWorld, count: usize) { with_fingerprints(world, |fingerprints| { - assert_eq!(fingerprints.len(), count); + assert_eq!( + fingerprints.len(), + count, + "fingerprint count must match the scenario expectation" + ); }); } @@ -224,17 +238,23 @@ fn then_first_fingerprint_spans(world: &TokenPassWorld, start: usize, end: usize let Some(first) = fingerprints.first() else { panic!("fingerprints must exist before checking the first span"); }; - assert_eq!(first.range, start..end); + assert_eq!( + first.range, + start..end, + "first fingerprint span must match the scenario expectation" + ); }); } #[then("the retained hashes are {hashes}")] fn then_retained_hashes_are(world: &TokenPassWorld, hashes: String) { - let expected = hashes + let Ok(expected) = hashes .split_whitespace() - .map(|value| value.parse::()) - .collect::, _>>() - .expect("expected hash list should be valid"); + .map(str::parse::) + .collect::, _>>() + else { + panic!("expected hash list `{hashes}` must be valid"); + }; with_retained(world, |retained| { assert_eq!( @@ -242,7 +262,8 @@ fn then_retained_hashes_are(world: &TokenPassWorld, hashes: String) { .iter() .map(|fingerprint| fingerprint.hash) .collect::>(), - expected + expected, + "retained fingerprint hashes must match the scenario expectation" ); }); } @@ -258,7 +279,11 @@ fn then_error_is(world: &TokenPassWorld, message: String) { .collect::>(); if let [error] = errors.as_slice() { - assert_eq!(error.to_string(), message); + assert_eq!( + error.to_string(), + message, + "reported error must match the scenario expectation" + ); return; } @@ -266,31 +291,19 @@ fn then_error_is(world: &TokenPassWorld, message: String) { } #[scenario(path = "tests/features/token_pass.feature", index = 0)] -fn scenario_t1_trivia_removal(world: TokenPassWorld) { - let _ = world; -} +fn scenario_t1_trivia_removal(world: TokenPassWorld) { let _ = world; } #[scenario(path = "tests/features/token_pass.feature", index = 1)] -fn scenario_t2_renamed_functions_match(world: TokenPassWorld) { - let _ = world; -} +fn scenario_t2_renamed_functions_match(world: TokenPassWorld) { let _ = world; } #[scenario(path = "tests/features/token_pass.feature", index = 2)] -fn scenario_exact_k_fingerprint(world: TokenPassWorld) { - let _ = world; -} +fn scenario_exact_k_fingerprint(world: TokenPassWorld) { let _ = world; } #[scenario(path = "tests/features/token_pass.feature", index = 3)] -fn scenario_winnowing_rightmost_minimum(world: TokenPassWorld) { - let _ = world; -} +fn scenario_winnowing_rightmost_minimum(world: TokenPassWorld) { let _ = world; } #[scenario(path = "tests/features/token_pass.feature", index = 4)] -fn scenario_invalid_size(world: TokenPassWorld) { - let _ = world; -} +fn scenario_invalid_size(world: TokenPassWorld) { let _ = world; } #[scenario(path = "tests/features/token_pass.feature", index = 5)] -fn scenario_unterminated_literal(world: TokenPassWorld) { - let _ = world; -} +fn scenario_unterminated_literal(world: TokenPassWorld) { let _ = world; } diff --git a/crates/whitaker_sarif/Cargo.toml b/crates/whitaker_sarif/Cargo.toml index 0e44258b..6542deaa 100644 --- a/crates/whitaker_sarif/Cargo.toml +++ b/crates/whitaker_sarif/Cargo.toml @@ -19,11 +19,11 @@ thiserror = { workspace = true } camino = { workspace = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } tempfile = { workspace = true } -[lints.clippy] -expect_used = "deny" -unwrap_used = "deny" +[lints] +workspace = true diff --git a/crates/whitaker_sarif/src/builders/location_builder.rs b/crates/whitaker_sarif/src/builders/location_builder.rs index e3fc7feb..76b66e5b 100644 --- a/crates/whitaker_sarif/src/builders/location_builder.rs +++ b/crates/whitaker_sarif/src/builders/location_builder.rs @@ -1,7 +1,9 @@ //! Builders for [`Location`] and [`Region`] objects. -use crate::error::SarifError; -use crate::model::location::{ArtifactLocation, Location, PhysicalLocation, Region}; +use crate::{ + error::SarifError, + model::location::{ArtefactLocation, Location, PhysicalLocation, Region}, +}; /// Fluent builder for constructing a [`Region`]. /// @@ -32,7 +34,7 @@ pub struct RegionBuilder { impl RegionBuilder { /// Creates a builder with the given 1-based start line. #[must_use] - pub fn new(start_line: usize) -> Self { + pub const fn new(start_line: usize) -> Self { Self { start_line, start_column: None, @@ -45,35 +47,35 @@ impl RegionBuilder { /// Sets the 1-based start column. #[must_use] - pub fn with_start_column(mut self, col: usize) -> Self { + pub const fn with_start_column(mut self, col: usize) -> Self { self.start_column = Some(col); self } /// Sets the 1-based end line. #[must_use] - pub fn with_end_line(mut self, line: usize) -> Self { + pub const fn with_end_line(mut self, line: usize) -> Self { self.end_line = Some(line); self } /// Sets the 1-based end column. #[must_use] - pub fn with_end_column(mut self, col: usize) -> Self { + pub const fn with_end_column(mut self, col: usize) -> Self { self.end_column = Some(col); self } - /// Sets the byte offset from the start of the artifact. + /// Sets the byte offset from the start of the artefact. #[must_use] - pub fn with_byte_offset(mut self, offset: usize) -> Self { + pub const fn with_byte_offset(mut self, offset: usize) -> Self { self.byte_offset = Some(offset); self } /// Sets the byte length. #[must_use] - pub fn with_byte_length(mut self, length: usize) -> Self { + pub const fn with_byte_length(mut self, length: usize) -> Self { self.byte_length = Some(length); self } @@ -161,9 +163,14 @@ impl RegionBuilder { /// use whitaker_sarif::{LocationBuilder, RegionBuilder}; /// /// let loc = LocationBuilder::new("src/main.rs") -/// .with_region(RegionBuilder::new(10).with_end_line(15).build().expect("valid region")) +/// .with_region( +/// RegionBuilder::new(10) +/// .with_end_line(15) +/// .build() +/// .expect("valid region"), +/// ) /// .build(); -/// assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); +/// assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone)] pub struct LocationBuilder { @@ -190,9 +197,9 @@ impl LocationBuilder { self } - /// Sets the region within the artifact. + /// Sets the region within the artefact. #[must_use] - pub fn with_region(mut self, region: Region) -> Self { + pub const fn with_region(mut self, region: Region) -> Self { self.region = Some(region); self } @@ -202,7 +209,7 @@ impl LocationBuilder { pub fn build(self) -> Location { Location { physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: self.uri, uri_base_id: self.uri_base_id, }, @@ -214,9 +221,12 @@ impl LocationBuilder { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF location builder. + + use rstest::rstest; + use super::*; use crate::error::SarifError; - use rstest::rstest; #[test] fn region_builder_minimal() { @@ -255,7 +265,7 @@ mod tests { #[test] fn location_builder_minimal() { let loc = LocationBuilder::new("src/main.rs").build(); - assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); + assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); assert!(loc.physical_location.region.is_none()); } @@ -269,7 +279,7 @@ mod tests { .with_region(region) .build(); match loc.physical_location.region.as_ref() { - Some(region) => assert_eq!(region.start_line, 42), + Some(attached) => assert_eq!(attached.start_line, 42), None => panic!("expected region to be present"), } } @@ -281,7 +291,7 @@ mod tests { .build(); assert_eq!( loc.physical_location - .artifact_location + .artefact_location .uri_base_id .as_deref(), Some("%SRCROOT%") diff --git a/crates/whitaker_sarif/src/builders/log_builder.rs b/crates/whitaker_sarif/src/builders/log_builder.rs index 76911f6a..54b6450a 100644 --- a/crates/whitaker_sarif/src/builders/log_builder.rs +++ b/crates/whitaker_sarif/src/builders/log_builder.rs @@ -1,7 +1,9 @@ //! Builder for [`SarifLog`] objects. -use crate::model::log::{SARIF_SCHEMA, SARIF_VERSION, SarifLog}; -use crate::model::run::Run; +use crate::model::{ + log::{SARIF_SCHEMA, SARIF_VERSION, SarifLog}, + run::Run, +}; /// Fluent builder for constructing a [`SarifLog`]. /// @@ -47,7 +49,7 @@ impl SarifLogBuilder { /// # Examples /// /// ``` - /// use whitaker_sarif::{SarifLogBuilder, RunBuilder}; + /// use whitaker_sarif::{RunBuilder, SarifLogBuilder}; /// /// let run = RunBuilder::new("tool", "1.0").build(); /// let log = SarifLogBuilder::new().with_run(run).build(); @@ -81,13 +83,13 @@ impl SarifLogBuilder { } impl Default for SarifLogBuilder { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF log builder. + use super::*; use crate::model::run::{Tool, ToolComponent}; @@ -111,7 +113,7 @@ mod tests { }, invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; let log = SarifLogBuilder::new().with_run(run).build(); assert_eq!(log.runs.len(), 1); diff --git a/crates/whitaker_sarif/src/builders/result_builder.rs b/crates/whitaker_sarif/src/builders/result_builder.rs index 574e413b..fc80731f 100644 --- a/crates/whitaker_sarif/src/builders/result_builder.rs +++ b/crates/whitaker_sarif/src/builders/result_builder.rs @@ -4,9 +4,13 @@ use std::collections::HashMap; use serde_json::Value; -use crate::error::{Result, SarifError}; -use crate::model::location::{Location, RelatedLocation}; -use crate::model::result::{Level, Message, SarifResult}; +use crate::{ + error::{Result, SarifError}, + model::{ + location::{Location, RelatedLocation}, + result::{Level, Message, SarifResult}, + }, +}; /// Fluent builder for constructing a [`SarifResult`]. /// @@ -16,7 +20,7 @@ use crate::model::result::{Level, Message, SarifResult}; /// # Examples /// /// ``` -/// use whitaker_sarif::{ResultBuilder, Level}; +/// use whitaker_sarif::{Level, ResultBuilder}; /// /// let result = ResultBuilder::new("WHK001") /// .with_message("Type-1 clone detected") @@ -50,7 +54,7 @@ impl ResultBuilder { /// Sets the severity level. #[must_use] - pub fn with_level(mut self, level: Level) -> Self { + pub const fn with_level(mut self, level: Level) -> Self { self.level = level; self } @@ -133,9 +137,12 @@ impl ResultBuilder { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF result builder. + + use rstest::rstest; + use super::*; use crate::merge::WHITAKER_FRAGMENT_KEY; - use rstest::rstest; #[rstest] #[case("WHK001", "msg", Level::Warning)] diff --git a/crates/whitaker_sarif/src/builders/run_builder.rs b/crates/whitaker_sarif/src/builders/run_builder.rs index e8d1b944..8ec7d244 100644 --- a/crates/whitaker_sarif/src/builders/run_builder.rs +++ b/crates/whitaker_sarif/src/builders/run_builder.rs @@ -1,8 +1,10 @@ //! Builder for [`Run`] objects. -use crate::model::descriptor::ReportingDescriptor; -use crate::model::result::SarifResult; -use crate::model::run::{Artifact, Invocation, Run, Tool, ToolComponent}; +use crate::model::{ + descriptor::ReportingDescriptor, + result::SarifResult, + run::{Artefact, Invocation, Run, Tool, ToolComponent}, +}; /// Fluent builder for constructing a [`Run`]. /// @@ -22,7 +24,7 @@ pub struct RunBuilder { rules: Vec, invocations: Vec, results: Vec, - artifacts: Vec, + artefacts: Vec, } impl RunBuilder { @@ -36,7 +38,7 @@ impl RunBuilder { rules: Vec::new(), invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), } } @@ -50,7 +52,10 @@ impl RunBuilder { /// let run = RunBuilder::new("tool", "1.0") /// .with_information_uri("https://example.com") /// .build(); - /// assert_eq!(run.tool.driver.information_uri.as_deref(), Some("https://example.com")); + /// assert_eq!( + /// run.tool.driver.information_uri.as_deref(), + /// Some("https://example.com") + /// ); /// ``` #[must_use] pub fn with_information_uri(mut self, uri: impl Into) -> Self { @@ -81,7 +86,7 @@ impl RunBuilder { /// # Examples /// /// ``` - /// use whitaker_sarif::{RunBuilder, ResultBuilder}; + /// use whitaker_sarif::{ResultBuilder, RunBuilder}; /// /// let result = ResultBuilder::new("WHK001") /// .with_message("clone") @@ -101,7 +106,7 @@ impl RunBuilder { /// # Examples /// /// ``` - /// use whitaker_sarif::{RunBuilder, Invocation}; + /// use whitaker_sarif::{Invocation, RunBuilder}; /// /// let run = RunBuilder::new("tool", "1.0") /// .with_invocation(Invocation { @@ -117,27 +122,27 @@ impl RunBuilder { self } - /// Appends an artifact reference. + /// Appends an artefact reference. /// /// # Examples /// /// ``` - /// use whitaker_sarif::{RunBuilder, Artifact, ArtifactLocation}; + /// use whitaker_sarif::{Artefact, ArtefactLocation, RunBuilder}; /// /// let run = RunBuilder::new("tool", "1.0") - /// .with_artifact(Artifact { - /// location: ArtifactLocation { + /// .with_artefact(Artefact { + /// location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, /// mime_type: Some("text/x-rust".into()), /// }) /// .build(); - /// assert_eq!(run.artifacts.len(), 1); + /// assert_eq!(run.artefacts.len(), 1); /// ``` #[must_use] - pub fn with_artifact(mut self, artifact: Artifact) -> Self { - self.artifacts.push(artifact); + pub fn with_artefact(mut self, artefact: Artefact) -> Self { + self.artefacts.push(artefact); self } @@ -166,7 +171,7 @@ impl RunBuilder { }, invocations: self.invocations, results: self.results, - artifacts: self.artifacts, + artefacts: self.artefacts, } } } @@ -175,14 +180,15 @@ impl RunBuilder { mod tests { //! Unit tests for [`RunBuilder`] construction and method chaining. + use rstest::{fixture, rstest}; + use whitaker_test_macros::allow_fixture_expansion_lints; + use super::*; use crate::rules::all_rules; - use rstest::{fixture, rstest}; + #[allow_fixture_expansion_lints] #[fixture] - fn builder() -> RunBuilder { - RunBuilder::new("tool", "1.0") - } + fn builder() -> RunBuilder { RunBuilder::new("tool", "1.0") } #[rstest] fn builds_run_with_tool(builder: RunBuilder) { diff --git a/crates/whitaker_sarif/src/error.rs b/crates/whitaker_sarif/src/error.rs index e62a3d9f..da310c90 100644 --- a/crates/whitaker_sarif/src/error.rs +++ b/crates/whitaker_sarif/src/error.rs @@ -53,6 +53,8 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { + //! Behavioural tests for SARIF error construction and display. + use super::*; #[test] diff --git a/crates/whitaker_sarif/src/lib.rs b/crates/whitaker_sarif/src/lib.rs index bf4cde4a..d4b07ccb 100644 --- a/crates/whitaker_sarif/src/lib.rs +++ b/crates/whitaker_sarif/src/lib.rs @@ -4,8 +4,8 @@ //! Analysis Results Interchange Format (SARIF) 2.1.0 documents used by the //! Whitaker clone detection pipeline. It includes: //! -//! - **Model types** representing the SARIF 2.1.0 schema subset used by -//! Whitaker (logs, runs, results, locations, regions, rules). +//! - **Model types** representing the SARIF 2.1.0 schema subset used by Whitaker (logs, runs, +//! results, locations, regions, rules). //! - **Fluent builders** for ergonomic construction of SARIF objects. //! - **Rule definitions** for the three clone types (WHK001–WHK003). //! - **Whitaker properties** extension for attaching similarity metadata. @@ -23,31 +23,50 @@ pub mod test_support; pub mod whitaker_properties; // Error types +// Builders +pub use builders::{LocationBuilder, RegionBuilder, ResultBuilder, RunBuilder, SarifLogBuilder}; pub use error::{Result, SarifError}; - +// Merge logic +pub use merge::{WHITAKER_FRAGMENT_KEY, deduplicate_results, merge_runs}; // Model types pub use model::{ - Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString, - PhysicalLocation, Region, RelatedLocation, ReportingDescriptor, Run, SarifLog, SarifResult, - Tool, ToolComponent, + Artefact, + ArtefactLocation, + Invocation, + Level, + Location, + Message, + MultiformatMessageString, + PhysicalLocation, + Region, + RelatedLocation, + ReportingDescriptor, + Run, + SarifLog, + SarifResult, + Tool, + ToolComponent, +}; +// Path helpers +pub use paths::{ + AST_PASS_FILENAME, + REFINED_FILENAME, + TOKEN_PASS_FILENAME, + WHITAKER_DIR, + ast_pass_path, + refined_path, + token_pass_path, + whitaker_dir, }; - -// Builders -pub use builders::{LocationBuilder, RegionBuilder, ResultBuilder, RunBuilder, SarifLogBuilder}; - // Rules pub use rules::{ - WHK001_ID, WHK002_ID, WHK003_ID, all_rules, whk001_rule, whk002_rule, whk003_rule, + WHK001_ID, + WHK002_ID, + WHK003_ID, + all_rules, + whk001_rule, + whk002_rule, + whk003_rule, }; - // Whitaker properties extension pub use whitaker_properties::{WhitakerProperties, WhitakerPropertiesBuilder}; - -// Merge logic -pub use merge::{WHITAKER_FRAGMENT_KEY, deduplicate_results, merge_runs}; - -// Path helpers -pub use paths::{ - AST_PASS_FILENAME, REFINED_FILENAME, TOKEN_PASS_FILENAME, WHITAKER_DIR, ast_pass_path, - refined_path, token_pass_path, whitaker_dir, -}; diff --git a/crates/whitaker_sarif/src/merge.rs b/crates/whitaker_sarif/src/merge.rs index 3e76a011..0b2f3e64 100644 --- a/crates/whitaker_sarif/src/merge.rs +++ b/crates/whitaker_sarif/src/merge.rs @@ -7,11 +7,10 @@ use std::collections::HashSet; -use crate::error::{Result, SarifError}; -use crate::model::descriptor::ReportingDescriptor; -use crate::model::location::Region; -use crate::model::result::SarifResult; -use crate::model::run::Run; +use crate::{ + error::{Result, SarifError}, + model::{descriptor::ReportingDescriptor, location::Region, result::SarifResult, run::Run}, +}; /// Fingerprint key used by the Whitaker clone detector for result deduplication. /// @@ -43,7 +42,7 @@ struct RegionKey { } impl RegionKey { - fn from_region(region: &Region) -> Self { + const fn from_region(region: &Region) -> Self { Self { start_line: region.start_line, start_column: region.start_column, @@ -66,7 +65,7 @@ fn extract_key(result: &SarifResult) -> Option { .clone(); let location = result.locations.first()?; - let file = location.physical_location.artifact_location.uri.clone(); + let file = location.physical_location.artefact_location.uri.clone(); let region = location.physical_location.region.as_ref()?; Some(ResultKey { @@ -84,20 +83,20 @@ fn extract_key(result: &SarifResult) -> Option { /// # Examples /// /// ``` -/// use whitaker_sarif::{SarifResult, Level, Message, deduplicate_results}; +/// use whitaker_sarif::{Level, Message, SarifResult, deduplicate_results}; /// -/// let results = vec![ -/// SarifResult { -/// rule_id: "WHK001".into(), -/// level: Level::Warning, -/// message: Message { text: "clone".into() }, -/// locations: Vec::new(), -/// related_locations: Vec::new(), -/// partial_fingerprints: Default::default(), -/// properties: None, -/// baseline_state: None, +/// let results = vec![SarifResult { +/// rule_id: "WHK001".into(), +/// level: Level::Warning, +/// message: Message { +/// text: "clone".into(), /// }, -/// ]; +/// locations: Vec::new(), +/// related_locations: Vec::new(), +/// partial_fingerprints: Default::default(), +/// properties: None, +/// baseline_state: None, +/// }]; /// let deduped = deduplicate_results(&results); /// assert_eq!(deduped.len(), 1); /// ``` @@ -127,7 +126,7 @@ pub fn deduplicate_results(results: &[SarifResult]) -> Vec { /// /// The tool metadata is taken from the first run. Rules are unioned across all /// runs by `id` (first occurrence wins). Results are collected from all runs -/// and deduplicated. Artifacts and invocations are concatenated. +/// and deduplicated. Artefacts and invocations are concatenated. /// /// # Errors /// @@ -152,12 +151,12 @@ pub fn merge_runs(runs: &[Run]) -> Result { tool.driver.rules = union_rules(runs); let mut all_results = Vec::new(); - let mut all_artifacts = Vec::new(); + let mut all_artefacts = Vec::new(); let mut all_invocations = Vec::new(); for run in runs { all_results.extend(run.results.clone()); - all_artifacts.extend(run.artifacts.clone()); + all_artefacts.extend(run.artefacts.clone()); all_invocations.extend(run.invocations.clone()); } @@ -167,7 +166,7 @@ pub fn merge_runs(runs: &[Run]) -> Result { tool, invocations: all_invocations, results, - artifacts: all_artifacts, + artefacts: all_artefacts, }) } @@ -190,9 +189,13 @@ fn union_rules(runs: &[Run]) -> Vec { #[cfg(test)] mod tests { + //! Behavioural tests for merging SARIF logs and fragments. + use super::*; - use crate::builders::{ResultBuilder, RunBuilder}; - use crate::test_support::make_keyed_result; + use crate::{ + builders::{ResultBuilder, RunBuilder}, + test_support::make_keyed_result, + }; fn merged_result_count(r1: SarifResult, r2: SarifResult) -> usize { let run_a = RunBuilder::new("tool", "1.0").with_result(r1).build(); diff --git a/crates/whitaker_sarif/src/model/descriptor.rs b/crates/whitaker_sarif/src/model/descriptor.rs index 1f64460f..6a14904f 100644 --- a/crates/whitaker_sarif/src/model/descriptor.rs +++ b/crates/whitaker_sarif/src/model/descriptor.rs @@ -48,7 +48,9 @@ pub struct ReportingDescriptor { /// ``` /// use whitaker_sarif::MultiformatMessageString; /// -/// let msg = MultiformatMessageString { text: "hello".into() }; +/// let msg = MultiformatMessageString { +/// text: "hello".into(), +/// }; /// assert_eq!(msg.text, "hello"); /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -60,6 +62,8 @@ pub struct MultiformatMessageString { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF reporting descriptor model. + use super::*; #[test] diff --git a/crates/whitaker_sarif/src/model/location.rs b/crates/whitaker_sarif/src/model/location.rs index c3cd50d4..4da8934b 100644 --- a/crates/whitaker_sarif/src/model/location.rs +++ b/crates/whitaker_sarif/src/model/location.rs @@ -2,21 +2,21 @@ //! //! These types describe where a result was found in source code. A //! [`Location`] wraps a [`PhysicalLocation`] which combines an -//! [`ArtifactLocation`] (file URI) with an optional [`Region`] (line and +//! [`ArtefactLocation`] (file URI) with an optional [`Region`] (line and //! column spans). use serde::{Deserialize, Serialize}; -/// A location within an artifact (source file). +/// A location within an artefact (source file). /// /// # Examples /// /// ``` -/// use whitaker_sarif::{Location, PhysicalLocation, ArtifactLocation, Region}; +/// use whitaker_sarif::{ArtefactLocation, Location, PhysicalLocation, Region}; /// /// let loc = Location { /// physical_location: PhysicalLocation { -/// artifact_location: ArtifactLocation { +/// artefact_location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; /// }), /// }, /// }; -/// assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); +/// assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -43,19 +43,23 @@ pub struct Location { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PhysicalLocation { - /// Identifies the artifact (file). - pub artifact_location: ArtifactLocation, - - /// Optional region within the artifact. + /// Identifies the artefact (file). + /// + /// The SARIF 2.1.0 schema spells this property `artifactLocation`, so the + /// wire name is pinned here rather than derived from the field name. + #[serde(rename = "artifactLocation")] + pub artefact_location: ArtefactLocation, + + /// Optional region within the artefact. #[serde(default, skip_serializing_if = "Option::is_none")] pub region: Option, } -/// A reference to an artifact by URI. +/// A reference to an artefact by URI. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ArtifactLocation { - /// Relative or absolute URI of the artifact. +pub struct ArtefactLocation { + /// Relative or absolute URI of the artefact. pub uri: String, /// Base identifier for resolving relative URIs. @@ -63,7 +67,7 @@ pub struct ArtifactLocation { pub uri_base_id: Option, } -/// A region within an artifact, identified by line and column numbers. +/// A region within an artefact, identified by line and column numbers. /// /// `start_line` is always required (1-based). All other fields are optional. /// @@ -100,7 +104,7 @@ pub struct Region { #[serde(default, skip_serializing_if = "Option::is_none")] pub end_column: Option, - /// Optional byte offset from the start of the artifact. + /// Optional byte offset from the start of the artefact. #[serde(default, skip_serializing_if = "Option::is_none")] pub byte_offset: Option, @@ -128,6 +132,8 @@ pub struct RelatedLocation { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF location model. + use super::*; #[test] @@ -172,7 +178,7 @@ mod tests { fn location_round_trip() { let loc = Location { physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: "src/lib.rs".into(), uri_base_id: Some("%SRCROOT%".into()), }, @@ -203,7 +209,7 @@ mod tests { text: "peer fragment".into(), }), physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: "src/other.rs".into(), uri_base_id: None, }, diff --git a/crates/whitaker_sarif/src/model/log.rs b/crates/whitaker_sarif/src/model/log.rs index 1df6dcbd..baf1bd96 100644 --- a/crates/whitaker_sarif/src/model/log.rs +++ b/crates/whitaker_sarif/src/model/log.rs @@ -54,6 +54,8 @@ impl Default for SarifLog { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF log model. + use super::*; #[test] diff --git a/crates/whitaker_sarif/src/model/mod.rs b/crates/whitaker_sarif/src/model/mod.rs index a38b1493..89c00df5 100644 --- a/crates/whitaker_sarif/src/model/mod.rs +++ b/crates/whitaker_sarif/src/model/mod.rs @@ -4,13 +4,11 @@ //! specification subset used by Whitaker. Types are organized by concept: //! //! - [`log`] — top-level [`SarifLog`] container. -//! - [`run`] — [`Run`], [`Tool`], [`ToolComponent`], [`Invocation`], and -//! [`Artifact`]. +//! - [`run`] — [`Run`], [`Tool`], [`ToolComponent`], [`Invocation`], and [`Artefact`]. //! - [`result`] — [`SarifResult`], [`Level`], and [`Message`]. -//! - [`location`] — [`Location`], [`PhysicalLocation`], -//! [`ArtifactLocation`], [`Region`], and [`RelatedLocation`]. -//! - [`descriptor`] — [`ReportingDescriptor`] and -//! [`MultiformatMessageString`]. +//! - [`location`] — [`Location`], [`PhysicalLocation`], [`ArtefactLocation`], [`Region`], and +//! [`RelatedLocation`]. +//! - [`descriptor`] — [`ReportingDescriptor`] and [`MultiformatMessageString`]. //! //! All types implement `Serialize` and `Deserialize` with `camelCase` field //! naming to match the SARIF JSON schema. @@ -22,7 +20,7 @@ pub mod result; pub mod run; pub use descriptor::{MultiformatMessageString, ReportingDescriptor}; -pub use location::{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation}; +pub use location::{ArtefactLocation, Location, PhysicalLocation, Region, RelatedLocation}; pub use log::SarifLog; pub use result::{Level, Message, SarifResult}; -pub use run::{Artifact, Invocation, Run, Tool, ToolComponent}; +pub use run::{Artefact, Invocation, Run, Tool, ToolComponent}; diff --git a/crates/whitaker_sarif/src/model/result.rs b/crates/whitaker_sarif/src/model/result.rs index 3d07164d..fbef59e6 100644 --- a/crates/whitaker_sarif/src/model/result.rs +++ b/crates/whitaker_sarif/src/model/result.rs @@ -47,7 +47,9 @@ pub enum Level { /// ``` /// use whitaker_sarif::Message; /// -/// let msg = Message { text: "clone detected".into() }; +/// let msg = Message { +/// text: "clone detected".into(), +/// }; /// assert_eq!(msg.text, "clone detected"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -64,12 +66,14 @@ pub struct Message { /// # Examples /// /// ``` -/// use whitaker_sarif::{SarifResult, Level, Message}; +/// use whitaker_sarif::{Level, Message, SarifResult}; /// /// let result = SarifResult { /// rule_id: "WHK001".into(), /// level: Level::Warning, -/// message: Message { text: "clone detected".into() }, +/// message: Message { +/// text: "clone detected".into(), +/// }, /// locations: Vec::new(), /// related_locations: Vec::new(), /// partial_fingerprints: Default::default(), @@ -114,6 +118,8 @@ pub struct SarifResult { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF result model. + use super::*; #[test] diff --git a/crates/whitaker_sarif/src/model/run.rs b/crates/whitaker_sarif/src/model/run.rs index 48a7f9ec..78fd19b7 100644 --- a/crates/whitaker_sarif/src/model/run.rs +++ b/crates/whitaker_sarif/src/model/run.rs @@ -1,14 +1,13 @@ -//! SARIF run, tool, invocation, and artifact types. +//! SARIF run, tool, invocation, and artefact types. //! //! A [`Run`] represents a single execution of an analysis tool. It contains //! the [`Tool`] that produced the results, optional [`Invocation`] metadata, //! the [`SarifResult`] findings, and any referenced -//! [`Artifact`]s. +//! [`Artefact`]s. use serde::{Deserialize, Serialize}; -use super::descriptor::ReportingDescriptor; -use super::result::SarifResult; +use super::{descriptor::ReportingDescriptor, result::SarifResult}; /// A single analysis tool execution. /// @@ -28,7 +27,7 @@ use super::result::SarifResult; /// }, /// invocations: Vec::new(), /// results: Vec::new(), -/// artifacts: Vec::new(), +/// artefacts: Vec::new(), /// }; /// assert_eq!(run.tool.driver.name, "whitaker_clones_cli"); /// ``` @@ -46,9 +45,12 @@ pub struct Run { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub results: Vec, - /// Referenced source artifacts. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifacts: Vec, + /// Referenced source artefacts. + /// + /// The SARIF 2.1.0 schema spells this property `artifacts`, so the wire + /// name is pinned here rather than derived from the field name. + #[serde(default, rename = "artifacts", skip_serializing_if = "Vec::is_empty")] + pub artefacts: Vec, } /// The analysis tool that produced a run. @@ -117,27 +119,27 @@ pub struct Invocation { pub command_line: Option, } -/// A source artifact referenced by results. +/// A source artefact referenced by results. /// /// # Examples /// /// ``` -/// use whitaker_sarif::{Artifact, ArtifactLocation}; +/// use whitaker_sarif::{Artefact, ArtefactLocation}; /// -/// let artifact = Artifact { -/// location: ArtifactLocation { +/// let artefact = Artefact { +/// location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, /// mime_type: Some("text/x-rust".into()), /// }; -/// assert_eq!(artifact.location.uri, "src/main.rs"); +/// assert_eq!(artefact.location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Artifact { - /// Location of the artifact. - pub location: super::location::ArtifactLocation, +pub struct Artefact { + /// Location of the artefact. + pub location: super::location::ArtefactLocation, /// Optional MIME type. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -146,8 +148,10 @@ pub struct Artifact { #[cfg(test)] mod tests { + //! Behavioural tests for the SARIF run model. + use super::*; - use crate::model::location::ArtifactLocation; + use crate::model::location::ArtefactLocation; #[test] fn run_round_trip() { @@ -165,7 +169,7 @@ mod tests { command_line: None, }], results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; match serde_json::to_string(&run) { Ok(json) => match serde_json::from_str::(&json) { @@ -189,13 +193,13 @@ mod tests { }, invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; match serde_json::to_string(&run) { Ok(json) => { assert!(!json.contains("\"invocations\"")); assert!(!json.contains("\"results\"")); - assert!(!json.contains("\"artifacts\"")); + assert!(!json.contains("\"artefacts\"")); assert!(!json.contains("\"rules\"")); } Err(e) => panic!("failed to serialize: {e}"), @@ -203,17 +207,17 @@ mod tests { } #[test] - fn artifact_round_trip() { - let artifact = Artifact { - location: ArtifactLocation { + fn artefact_round_trip() { + let artefact = Artefact { + location: ArtefactLocation { uri: "src/lib.rs".into(), uri_base_id: Some("%SRCROOT%".into()), }, mime_type: Some("text/x-rust".into()), }; - match serde_json::to_string(&artifact) { - Ok(json) => match serde_json::from_str::(&json) { - Ok(parsed) => assert_eq!(artifact, parsed), + match serde_json::to_string(&artefact) { + Ok(json) => match serde_json::from_str::(&json) { + Ok(parsed) => assert_eq!(artefact, parsed), Err(e) => panic!("failed to deserialize: {e}"), }, Err(e) => panic!("failed to serialize: {e}"), diff --git a/crates/whitaker_sarif/src/paths.rs b/crates/whitaker_sarif/src/paths.rs index 1101ba15..5c4229b4 100644 --- a/crates/whitaker_sarif/src/paths.rs +++ b/crates/whitaker_sarif/src/paths.rs @@ -94,6 +94,8 @@ pub fn refined_path(target_dir: &Utf8Path) -> Utf8PathBuf { #[cfg(test)] mod tests { + //! Behavioural tests for SARIF artefact path normalization. + use super::*; #[test] diff --git a/crates/whitaker_sarif/src/rules.rs b/crates/whitaker_sarif/src/rules.rs index 12096a0e..38611105 100644 --- a/crates/whitaker_sarif/src/rules.rs +++ b/crates/whitaker_sarif/src/rules.rs @@ -117,12 +117,12 @@ pub fn whk003_rule() -> ReportingDescriptor { /// assert_eq!(rules.len(), 3); /// ``` #[must_use] -pub fn all_rules() -> Vec { - vec![whk001_rule(), whk002_rule(), whk003_rule()] -} +pub fn all_rules() -> Vec { vec![whk001_rule(), whk002_rule(), whk003_rule()] } #[cfg(test)] mod tests { + //! Behavioural tests for SARIF rule registration and lookup. + use super::*; #[test] diff --git a/crates/whitaker_sarif/src/test_support.rs b/crates/whitaker_sarif/src/test_support.rs index 311d631f..11b72079 100644 --- a/crates/whitaker_sarif/src/test_support.rs +++ b/crates/whitaker_sarif/src/test_support.rs @@ -4,13 +4,21 @@ //! It exists solely to avoid duplicating test helper logic between the //! `merge::tests` unit tests and the `tests/` integration tests. -use crate::builders::{LocationBuilder, RegionBuilder, ResultBuilder}; -use crate::merge::WHITAKER_FRAGMENT_KEY; -use crate::model::result::{Level, SarifResult}; +use crate::{ + builders::{LocationBuilder, RegionBuilder, ResultBuilder}, + merge::WHITAKER_FRAGMENT_KEY, + model::result::{Level, SarifResult}, +}; /// Builds a [`SarifResult`] with a fingerprint, location, and region. /// -/// Panics on builder failure; intended only for test code. +/// Intended only for test code. +/// +/// # Panics +/// +/// Panics if the region or result builder rejects its inputs, which indicates +/// a defect in the test fixture rather than a recoverable condition. +#[must_use] pub fn make_keyed_result(rule: &str, file: &str, line: usize, fp: &str) -> SarifResult { let region = match RegionBuilder::new(line).with_end_line(line + 5).build() { Ok(r) => r, diff --git a/crates/whitaker_sarif/src/whitaker_properties.rs b/crates/whitaker_sarif/src/whitaker_properties.rs index 90260386..de539ba0 100644 --- a/crates/whitaker_sarif/src/whitaker_properties.rs +++ b/crates/whitaker_sarif/src/whitaker_properties.rs @@ -88,8 +88,8 @@ impl WhitakerProperties { /// # Examples /// /// ``` -/// use whitaker_sarif::{WhitakerProperties, WhitakerPropertiesBuilder}; /// use serde_json::Value; +/// use whitaker_sarif::{WhitakerProperties, WhitakerPropertiesBuilder}; /// /// let props = WhitakerPropertiesBuilder::new("T1") /// .with_k(25) @@ -152,42 +152,42 @@ impl WhitakerPropertiesBuilder { /// Sets the k-shingle size. #[must_use] - pub fn with_k(mut self, k: usize) -> Self { + pub const fn with_k(mut self, k: usize) -> Self { self.k = k; self } /// Sets the winnowing window size. #[must_use] - pub fn with_window(mut self, window: usize) -> Self { + pub const fn with_window(mut self, window: usize) -> Self { self.window = window; self } /// Sets the Jaccard similarity score. #[must_use] - pub fn with_jaccard(mut self, jaccard: f64) -> Self { + pub const fn with_jaccard(mut self, jaccard: f64) -> Self { self.jaccard = jaccard; self } /// Sets the cosine similarity score. #[must_use] - pub fn with_cosine(mut self, cosine: f64) -> Self { + pub const fn with_cosine(mut self, cosine: f64) -> Self { self.cosine = cosine; self } /// Sets the clone group identifier. #[must_use] - pub fn with_group_id(mut self, group_id: usize) -> Self { + pub const fn with_group_id(mut self, group_id: usize) -> Self { self.group_id = group_id; self } /// Sets the clone class size. #[must_use] - pub fn with_class_size(mut self, class_size: usize) -> Self { + pub const fn with_class_size(mut self, class_size: usize) -> Self { self.class_size = class_size; self } @@ -219,6 +219,8 @@ impl WhitakerPropertiesBuilder { #[cfg(test)] mod tests { + //! Behavioural tests for Whitaker SARIF property bags. + use super::*; #[test] @@ -232,12 +234,12 @@ mod tests { .with_class_size(4) .build(); match props { - Ok(props) => { - assert_eq!(props.profile, "T1"); - assert_eq!(props.k, 25); - assert_eq!(props.window, 16); - assert_eq!(props.group_id, 174); - assert_eq!(props.class_size, 4); + Ok(built) => { + assert_eq!(built.profile, "T1"); + assert_eq!(built.k, 25); + assert_eq!(built.window, 16); + assert_eq!(built.group_id, 174); + assert_eq!(built.class_size, 4); } Err(e) => panic!("unexpected error: {e}"), } @@ -247,10 +249,10 @@ mod tests { fn into_value_wraps_under_whitaker_key() { let props = WhitakerPropertiesBuilder::new("T2").build(); match props { - Ok(props) => { - let value = props.try_to_value(); + Ok(built) => { + let value = built.try_to_value(); match value { - Ok(value) => assert!(value.get("whitaker").is_some()), + Ok(wrapped) => assert!(wrapped.get("whitaker").is_some()), Err(e) => panic!("unexpected serialization error: {e}"), } } @@ -262,9 +264,9 @@ mod tests { fn try_from_value_extracts_properties() { let props = WhitakerPropertiesBuilder::new("T1").with_k(10).build(); match props { - Ok(props) => match props.try_to_value() { + Ok(built) => match built.try_to_value() { Ok(value) => match WhitakerProperties::try_from(&value) { - Ok(extracted) => assert_eq!(extracted, props), + Ok(extracted) => assert_eq!(extracted, built), Err(e) => panic!("unexpected extraction error: {e}"), }, Err(e) => panic!("unexpected serialization error: {e}"), @@ -287,11 +289,12 @@ mod tests { .with_cosine(0.90) .build(); match props { - Ok(props) => { - let json = serde_json::to_string(&props); + Ok(built) => { + let json = serde_json::to_string(&built); match json { - Ok(json) => match serde_json::from_str::(&json) { - Ok(parsed) => assert_eq!(props, parsed), + Ok(serialized) => match serde_json::from_str::(&serialized) + { + Ok(parsed) => assert_eq!(built, parsed), Err(e) => panic!("deserialization failed: {e}"), }, Err(e) => panic!("serialization failed: {e}"), diff --git a/crates/whitaker_sarif/tests/sarif_behaviour.rs b/crates/whitaker_sarif/tests/sarif_behaviour.rs index ec135526..90cbf6ca 100644 --- a/crates/whitaker_sarif/tests/sarif_behaviour.rs +++ b/crates/whitaker_sarif/tests/sarif_behaviour.rs @@ -8,13 +8,22 @@ use camino::Utf8PathBuf; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use whitaker_sarif::{ - Level, LocationBuilder, RegionBuilder, ResultBuilder, RunBuilder, SarifLog, SarifLogBuilder, - SarifResult, WhitakerProperties, WhitakerPropertiesBuilder, all_rules, merge_runs, + Level, + LocationBuilder, + RegionBuilder, + ResultBuilder, + RunBuilder, + SarifLog, + SarifLogBuilder, + SarifResult, + WhitakerProperties, + WhitakerPropertiesBuilder, + all_rules, + merge_runs, + model::{descriptor::ReportingDescriptor, run::Run}, token_pass_path, }; - -use whitaker_sarif::model::descriptor::ReportingDescriptor; -use whitaker_sarif::model::run::Run; +use whitaker_test_macros::allow_fixture_expansion_lints; #[derive(Debug, Default)] struct SarifWorld { @@ -33,56 +42,55 @@ struct SarifWorld { computed_path: RefCell>, } +#[allow_fixture_expansion_lints] #[fixture] -fn world() -> SarifWorld { - SarifWorld::default() -} +fn world() -> SarifWorld { SarifWorld::default() } // -- Helper functions (match-based to avoid expect/unwrap) -- fn with_log(world: &SarifWorld, assert_fn: impl FnOnce(&SarifLog)) { - let log = world.built_log.borrow(); - match log.as_ref() { + let guard = world.built_log.borrow(); + match guard.as_ref() { Some(log) => assert_fn(log), None => panic!("log must be built before running assertions"), } } fn with_result(world: &SarifWorld, assert_fn: impl FnOnce(&SarifResult)) { - let result = world.built_result.borrow(); - match result.as_ref() { + let guard = world.built_result.borrow(); + match guard.as_ref() { Some(result) => assert_fn(result), None => panic!("result must be built before running assertions"), } } fn with_props_json(world: &SarifWorld, assert_fn: impl FnOnce(&serde_json::Value)) { - let json = world.props_json.borrow(); - match json.as_ref() { + let guard = world.props_json.borrow(); + match guard.as_ref() { Some(json) => assert_fn(json), None => panic!("properties JSON must exist before running assertions"), } } fn with_merged_run(world: &SarifWorld, assert_fn: impl FnOnce(&Run)) { - let merged = world.merged_run.borrow(); - match merged.as_ref() { + let guard = world.merged_run.borrow(); + match guard.as_ref() { Some(merged) => assert_fn(merged), None => panic!("merged run must exist before running assertions"), } } fn with_serialized_json(world: &SarifWorld, assert_fn: impl FnOnce(&str)) { - let json = world.serialized_json.borrow(); - match json.as_ref() { + let guard = world.serialized_json.borrow(); + match guard.as_ref() { Some(json) => assert_fn(json), None => panic!("serialized JSON must exist before running assertions"), } } fn with_computed_path(world: &SarifWorld, assert_fn: impl FnOnce(&Utf8PathBuf)) { - let path = world.computed_path.borrow(); - match path.as_ref() { + let guard = world.computed_path.borrow(); + match guard.as_ref() { Some(path) => assert_fn(path), None => panic!("computed path must exist before running assertions"), } @@ -179,8 +187,8 @@ fn given_target_dir(world: &SarifWorld, path: String) { #[when("the SARIF log is built with that run")] fn when_log_built_with_run(world: &SarifWorld) { - let run = world.pending_run.borrow_mut().take(); - if let Some(run) = run { + let pending = world.pending_run.borrow_mut().take(); + if let Some(run) = pending { let log = SarifLogBuilder::new().with_run(run).build(); *world.built_log.borrow_mut() = Some(log); } @@ -188,8 +196,8 @@ fn when_log_built_with_run(world: &SarifWorld) { #[when("the result is built")] fn when_result_built(world: &SarifWorld) { - let builder = world.result_builder.borrow_mut().take(); - if let Some(builder) = builder { + let taken = world.result_builder.borrow_mut().take(); + if let Some(builder) = taken { match builder.build() { Ok(result) => *world.built_result.borrow_mut() = Some(result), Err(e) => panic!("failed to build result: {e}"), @@ -199,8 +207,8 @@ fn when_result_built(world: &SarifWorld) { #[when("properties are converted to JSON")] fn when_properties_to_json(world: &SarifWorld) { - let builder = world.props_builder.borrow_mut().take(); - if let Some(builder) = builder { + let taken = world.props_builder.borrow_mut().take(); + if let Some(builder) = taken { match builder.build() { Ok(props) => match props.try_to_value() { Ok(value) => *world.props_json.borrow_mut() = Some(value), @@ -222,8 +230,8 @@ fn when_runs_merged(world: &SarifWorld) { #[when("the log is serialized to JSON")] fn when_log_serialized(world: &SarifWorld) { - let log = world.built_log.borrow(); - if let Some(log) = log.as_ref() { + let guard = world.built_log.borrow(); + if let Some(log) = guard.as_ref() { match serde_json::to_string_pretty(log) { Ok(json) => *world.serialized_json.borrow_mut() = Some(json), Err(e) => panic!("failed to serialize log: {e}"), @@ -233,8 +241,8 @@ fn when_log_serialized(world: &SarifWorld) { #[when("the JSON is deserialized back")] fn when_json_deserialized(world: &SarifWorld) { - let json = world.serialized_json.borrow(); - if let Some(json) = json.as_ref() { + let guard = world.serialized_json.borrow(); + if let Some(json) = guard.as_ref() { match serde_json::from_str::(json) { Ok(log) => *world.deserialized_log.borrow_mut() = Some(log), Err(e) => panic!("failed to deserialize log: {e}"), @@ -243,14 +251,12 @@ fn when_json_deserialized(world: &SarifWorld) { } #[when("all Whitaker rules are retrieved")] -fn when_rules_retrieved(world: &SarifWorld) { - *world.rules.borrow_mut() = all_rules(); -} +fn when_rules_retrieved(world: &SarifWorld) { *world.rules.borrow_mut() = all_rules(); } #[when("the token pass path is requested")] fn when_token_path_requested(world: &SarifWorld) { - let dir = world.target_dir.borrow(); - if let Some(dir) = dir.as_ref() { + let guard = world.target_dir.borrow(); + if let Some(dir) = guard.as_ref() { let path = token_pass_path(dir); *world.computed_path.borrow_mut() = Some(path); } @@ -260,41 +266,55 @@ fn when_token_path_requested(world: &SarifWorld) { #[then("the log version is {version}")] fn then_log_version(world: &SarifWorld, version: String) { - with_log(world, |log| assert_eq!(log.version, version)); + with_log(world, |log| { + assert_eq!(log.version, version, "log version should match"); + }); } #[then("the log has {count} run")] fn then_log_has_runs(world: &SarifWorld, count: usize) { - with_log(world, |log| assert_eq!(log.runs.len(), count)); + with_log(world, |log| { + assert_eq!(log.runs.len(), count, "log run count should match"); + }); } #[then("the run tool name is {name}")] fn then_run_tool_name(world: &SarifWorld, name: String) { with_log(world, |log| match log.runs.first() { - Some(run) => assert_eq!(run.tool.driver.name, name), + Some(run) => assert_eq!(run.tool.driver.name, name, "tool name should match"), None => panic!("log must have at least one run"), }); } #[then("the result rule ID is {rule_id}")] fn then_result_rule_id(world: &SarifWorld, rule_id: String) { - with_result(world, |result| assert_eq!(result.rule_id, rule_id)); + with_result(world, |result| { + assert_eq!(result.rule_id, rule_id, "result rule ID should match"); + }); } #[then("the result level is warning")] fn then_result_level_warning(world: &SarifWorld) { - with_result(world, |result| assert_eq!(result.level, Level::Warning)); + with_result(world, |result| { + assert_eq!( + result.level, + Level::Warning, + "result level should be warning" + ); + }); } #[then("the result has {count} location")] fn then_result_location_count(world: &SarifWorld, count: usize) { - with_result(world, |result| assert_eq!(result.locations.len(), count)); + with_result(world, |result| { + assert_eq!(result.locations.len(), count, "location count should match"); + }); } #[then("the JSON contains whitaker profile {profile}")] fn then_json_has_profile(world: &SarifWorld, profile: String) { with_props_json(world, |json| match WhitakerProperties::try_from(json) { - Ok(extracted) => assert_eq!(extracted.profile, profile), + Ok(extracted) => assert_eq!(extracted.profile, profile, "profile should match"), Err(e) => panic!("failed to extract WhitakerProperties: {e}"), }); } @@ -302,14 +322,20 @@ fn then_json_has_profile(world: &SarifWorld, profile: String) { #[then("the JSON contains whitaker k {k}")] fn then_json_has_k(world: &SarifWorld, k: usize) { with_props_json(world, |json| match WhitakerProperties::try_from(json) { - Ok(extracted) => assert_eq!(extracted.k, k), + Ok(extracted) => assert_eq!(extracted.k, k, "k value should match"), Err(e) => panic!("failed to extract WhitakerProperties: {e}"), }); } #[then("the merged run has {count} results")] fn then_merged_run_results(world: &SarifWorld, count: usize) { - with_merged_run(world, |merged| assert_eq!(merged.results.len(), count)); + with_merged_run(world, |merged| { + assert_eq!( + merged.results.len(), + count, + "merged result count should match" + ); + }); } #[then("the deserialized log equals the original")] @@ -317,7 +343,9 @@ fn then_deserialized_equals_original(world: &SarifWorld) { let original = world.built_log.borrow(); let deserialized = world.deserialized_log.borrow(); match (original.as_ref(), deserialized.as_ref()) { - (Some(orig), Some(deser)) => assert_eq!(orig, deser), + (Some(orig), Some(deser)) => { + assert_eq!(orig, deser, "deserialized log should equal the original"); + } _ => panic!("both original and deserialized logs must exist"), } } @@ -325,7 +353,10 @@ fn then_deserialized_equals_original(world: &SarifWorld) { #[then("the JSON contains version {version}")] fn then_json_has_version(world: &SarifWorld, version: String) { with_serialized_json(world, |json| { - assert!(json.contains(&format!("\"version\": \"{version}\""))); + assert!( + json.contains(&format!("\"version\": \"{version}\"")), + "serialized JSON should contain version {version}" + ); }); } @@ -353,41 +384,25 @@ fn then_path_ends_with(world: &SarifWorld, suffix: String) { // -- Scenario bindings (indices match feature file order) -- #[scenario(path = "tests/features/sarif.feature", index = 0)] -fn scenario_minimal_log(world: SarifWorld) { - let _ = world; -} +fn scenario_minimal_log(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 1)] -fn scenario_result_with_rule(world: SarifWorld) { - let _ = world; -} +fn scenario_result_with_rule(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 2)] -fn scenario_whitaker_properties(world: SarifWorld) { - let _ = world; -} +fn scenario_whitaker_properties(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 3)] -fn scenario_merge_deduplicates(world: SarifWorld) { - let _ = world; -} +fn scenario_merge_deduplicates(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 4)] -fn scenario_round_trip(world: SarifWorld) { - let _ = world; -} +fn scenario_round_trip(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 5)] -fn scenario_empty_log(world: SarifWorld) { - let _ = world; -} +fn scenario_empty_log(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 6)] -fn scenario_all_rules(world: SarifWorld) { - let _ = world; -} +fn scenario_all_rules(world: SarifWorld) { let _ = world; } #[scenario(path = "tests/features/sarif.feature", index = 7)] -fn scenario_path_helpers(world: SarifWorld) { - let _ = world; -} +fn scenario_path_helpers(world: SarifWorld) { let _ = world; } diff --git a/crates/whitaker_test_macros/Cargo.toml b/crates/whitaker_test_macros/Cargo.toml new file mode 100644 index 00000000..79f0d3be --- /dev/null +++ b/crates/whitaker_test_macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "whitaker_test_macros" +version = "0.2.7" +edition = "2024" +publish = false +description = "Proc-macros for test fixtures that suppress lints triggered by macro expansion" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/whitaker_test_macros/src/lib.rs b/crates/whitaker_test_macros/src/lib.rs new file mode 100644 index 00000000..eb2ce157 --- /dev/null +++ b/crates/whitaker_test_macros/src/lib.rs @@ -0,0 +1,32 @@ +//! Proc-macros for test fixtures that suppress lints triggered by macro expansion. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Item, parse_macro_input}; + +/// Allows `unused_braces` lint for fixture functions. +/// +/// This attribute is used on rstest fixture functions that expand to single-expression +/// bodies. When combined with `fn_single_line = true` in rustfmt.toml, the generated +/// code triggers the `unused_braces` lint. This attribute suppresses that lint +/// specifically for fixture expansions. +#[proc_macro_attribute] +pub fn allow_fixture_expansion_lints(_attr: TokenStream, item: TokenStream) -> TokenStream { + let parsed_item = parse_macro_input!(item as Item); + + quote! { + #[allow( + unused_braces, + reason = "fixture macro expansion triggers unused-braces on expression bodies" + )] + #[cfg_attr( + clippy, + expect( + clippy::allow_attributes, + reason = "needed to allow unused_braces for fixture macro expansion" + ) + )] + #parsed_item + } + .into() +} diff --git a/docs/adr-002-dylint-expect-attribute-macro.md b/docs/adr-002-dylint-expect-attribute-macro.md index 0d042ca8..20eaf58a 100644 --- a/docs/adr-002-dylint-expect-attribute-macro.md +++ b/docs/adr-002-dylint-expect-attribute-macro.md @@ -2,11 +2,21 @@ ## Status -Proposed. +Accepted, 2026-08-21. + +Whitaker adopts Option C, a procedural attribute macro, but with a materially +smaller expansion than this record originally proposed. Empirical testing +during the roadmap 1.3.1 planning phase established that the originally +specified four-attribute expansion does not achieve its own stated goal, and +that two of its attributes address diagnostics that never fire while a third +suppresses the only signal that catches a misspelt lint name. The expansion is +now the `cfg_attr` gate alone, paired with a one-line `check-cfg` entry in the +consuming manifest. See §Decision outcome / proposed direction and +§Known risks and limitations for the evidence. ## Date -2026-02-23. +2026-02-23. Amended and accepted 2026-08-21. ## Context and problem statement @@ -43,7 +53,8 @@ annotating items with conditional Dylint `expect` semantics that: later refactoring. - Minimize boilerplate and avoid copy-paste divergence across the codebase. - Avoid requiring workspace-wide configuration changes for every downstream - crate consuming Whitaker. + crate consuming Whitaker. _Not achieved; see the amendment to §Options + considered, Option D. One `check-cfg` manifest entry is unavoidable._ - Prefer `expect` over `allow` where appropriate, to detect stale suppressions. - Preserve compatibility with Clippy configurations that lint `#[allow]` usage. @@ -63,6 +74,12 @@ annotating items with conditional Dylint `expect` semantics that: - `unknown_lints` for Dylint lint names, - `unexpected_cfgs` for `dylint_lib`, and - `clippy::allow_attributes` where enabled. + + > **Amendment, 2026-08-21.** Of these three, only `unexpected_cfgs` actually + > occurs for the gated form, and the macro cannot suppress it — a manifest + > `check-cfg` entry is required. The other two arise only if the expansion + > itself emits `allow` attributes, which it no longer does. This requirement + > is met by the macro plus that one manifest line, not by the macro alone. - Keep the macro’s expansion explicit and reviewable. - Maintain a clear separation between proc-macro code and lint implementation code. @@ -79,7 +96,7 @@ Document and enforce a convention such as: ```rust,no_run #[allow(unknown_lints)] #[allow(unexpected_cfgs)] -#[cfg_attr(dylint_lib = "whitaker_lints", expect(whitaker::some_lint))] +#[cfg_attr(dylint_lib = "whitaker_suite", expect(no_std_fs_operations))] fn f() {} ``` @@ -99,24 +116,42 @@ Add a proc-macro attribute usable as: ```rust,no_run #[whitaker_support::dylint_expect( - lib = "whitaker_lints", - lints(whitaker::some_lint), + lib = "whitaker_suite", + lints(no_std_fs_operations), reason = "legacy exception; remove after refactor" )] fn f() {} ``` -The macro expands to a standard set of `allow(...)` and `cfg_attr(...)` -attributes, enabling `expect(...)` only when Dylint runs the relevant library. +The macro expands to a `cfg_attr(...)` gate, enabling `expect(...)` only when +Dylint runs the relevant library. It originally also emitted three `allow(...)` +attributes; see §Decision outcome / proposed direction for why they were +removed. ### Option D: Rely on workspace `check-cfg` allowlists -Add `cfg(dylint_lib, values(any()))` to a workspace `check-cfg` allowlist, -reduce `unexpected_cfgs` warnings, and keep only `allow(unknown_lints)`. - -This option improves signal-to-noise, but it requires configuration changes in -consuming workspaces and does not address boilerplate or Clippy’s -`allow_attributes` lint. +Add `cfg(dylint_lib, values(any()))` to a workspace `check-cfg` allowlist and +write the gated attribute by hand. + +> **Amendment, 2026-08-21.** This option was originally rejected on the grounds +> that it "keeps only `allow(unknown_lints)`" and "does not address Clippy's +> `allow_attributes` lint". **Both premises were wrong**, and the correction +> matters because it changes what Option C must do. +> +> Measured against Whitaker's own lint policy, a bare +> `#[cfg_attr(dylint_lib = "…", expect(…))]` with no `allow` attributes emits +> exactly one diagnostic: `unexpected_cfgs`. It does not emit `unknown_lints`, +> because a false `cfg_attr` predicate is stripped before lint-attribute +> processing, so `rustc` never sees the gated lint name. It does not emit +> `clippy::allow_attributes`, because there are no `allow` attributes present +> to lint. Adding the `check-cfg` entry reduces the diagnostic count to zero. +> +> Option D is therefore both necessary and sufficient for warning-free +> compilation. It remains true that it requires a manifest line in each +> consuming workspace and does nothing about boilerplate, which is why Option C +> is still adopted — but Option C is adopted for **ergonomics and a validation +> point**, not because it can avoid the `check-cfg` entry. It cannot: see +> §Known risks and limitations. | Topic | Option A | Option B | Option C | Option D | | ----------------------------------------------- | -------- | -------- | -------- | -------- | @@ -124,14 +159,24 @@ consuming workspaces and does not address boilerplate or Clippy’s | Boilerplate at call-site | High | Low | Low | Medium | | Dependency footprint | Low | Low | Medium | Low | | Review clarity | Medium | Medium | High | Medium | -| Works in downstream crates without extra config | High | High | High | Low | -| Risk of masking cfg issues on an item | Medium | Medium | Medium | Low | +| Works in downstream crates without extra config | Low | Low | Low | Low | +| Risk of masking cfg issues on an item | Medium | Medium | Low | Low | _Table 1: Trade-offs between approaches for conditional Dylint suppression._ +> **Amendment, 2026-08-21.** Two rows were corrected. "Works in downstream +> crates without extra config" was scored High for Options A, B and C and Low +> for Option D; every option is in fact Low, because none of them can suppress +> `unexpected_cfgs` without the consuming manifest carrying a `check-cfg` entry. +> That row was the main reason Option C was preferred over Option D, so its +> correction removes the original decisive argument — Option C is retained on +> ergonomics, review clarity, and having one place to add validation. "Risk of +> masking cfg issues on an item" drops to Low for Option C now that the +> expansion emits no `allow(unexpected_cfgs)`. + ## Decision outcome / proposed direction -Adopt Option C. +Adopt Option C, with the expansion amended as set out below. Whitaker will add a small support layer that provides an attribute macro `dylint_expect` following the procedural approach: @@ -142,11 +187,48 @@ Whitaker will add a small support layer that provides an attribute macro - `lib = "..."` (string literal), - `lints(path, ...)` (one or more lint paths), and - optional `reason = "..."`. -- Expand the attribute to include the following: - - `#[allow(clippy::allow_attributes)]`, - - `#[allow(unknown_lints)]`, - - `#[allow(unexpected_cfgs)]`, and - - `#[cfg_attr(dylint_lib = "...", expect(...))]`. +- Expand the attribute to the cfg-gated expectation and nothing else: + + ```rust,no_run + #[cfg_attr( + dylint_lib = "whitaker_suite", + expect(no_std_fs_operations, reason = "legacy call-site") + )] + fn read_legacy_config() {} + ``` + +- Require consuming workspaces to carry one manifest entry, which is what + actually makes the gated form warning-free: + + ```toml + [workspace.lints.rust] + unexpected_cfgs = { level = "warn", check-cfg = ['cfg(dylint_lib, values(any()))'] } + ``` + +### Amendment, 2026-08-21: why the expansion shrank + +This record originally specified an expansion carrying +`#[allow(clippy::allow_attributes)]`, `#[allow(unknown_lints)]`, and +`#[allow(unexpected_cfgs)]` alongside the gate. Testing during roadmap 1.3.1 +planning, including a spike that built a real proc-macro crate emitting exactly +those four attributes, established three things: + +1. **The expansion did not achieve its purpose.** A _sibling_ + `#[allow(unexpected_cfgs)]` does not suppress `unexpected_cfgs` arising from + a `cfg_attr` on the same item, because that diagnostic is resolved during + cfg-expansion, before the annotated item's own lint levels are in scope. The + suppression works only from an enclosing module, a crate-level inner + attribute, or the manifest. An attribute macro cannot reach any of those for + an arbitrary item without wrapping it and changing its semantics. +2. **Two of the three `allow` attributes were inert.** Neither `unknown_lints` + nor `clippy::allow_attributes` fires on the gated form; see §Options + considered, Option D. +3. **`#[allow(unknown_lints)]` was actively harmful.** Inside a Dylint run, a + misspelt lint name is caught by `unknown_lints`. Suppressing it converts + every typo into a suppression that compiles cleanly and silences nothing. + +Omitting the `allow` attributes therefore makes the macro both simpler and +safer. The `check-cfg` entry is not an optional extra; it is the mechanism. Whitaker will document the intended usage and limitations, including known misbehaviour for pre-expansion lints where `cfg_attr` gating may not apply in @@ -177,7 +259,7 @@ time. - Add a small compile-test fixture crate that: - builds without Dylint configured, - runs under Clippy, - - runs under Dylint with `dylint_lib = "whitaker_lints"` set. + - runs under Dylint with `dylint_lib = "whitaker_suite"` set. - Validate that the macro emits no warnings under expected configurations. ### Phase 3: Adopt the attribute in Whitaker-managed code @@ -188,27 +270,69 @@ time. ## Known risks and limitations -- `#[allow(unexpected_cfgs)]` can mask unrelated cfg mistakes inside the - annotated item. Reviewers should keep annotations narrowly scoped. +- **The aggregated suite does not honour lint-level attributes.** This is the + most serious limitation, and it blocks the macro from being useful in its + target configuration. A controlled experiment — identical fixture, identical + source revision, identical toolchain, only the loaded library differing — + showed that `libno_std_fs_operations` honours `#[allow]` and `#[expect]` + correctly, while `libwhitaker_suite` ignores both and additionally emits a + spurious `unfulfilled_lint_expectations` warning for every `expect`. Since + `whitaker_suite` is what `whitaker --all`, `make lint-whitaker`, and every + installed consumer load, no attribute-based suppression currently works in + practice. This must be fixed before ADR migration phase 3. It is tracked + separately from the 1.3.x macro work, which does not depend on it for + delivery. +- **`docs/users-guide.md` currently overstates attribute support.** It states + that `#[allow(no_std_fs_operations)]` "works … since the lint honours Rust's + lint-level attributes". That is true of an individual lint library and false + of the aggregated suite. Correct it alongside the fix above. +- **One `lib` value cannot cover both deployment modes.** Whitaker ships both + an aggregated `whitaker_suite` cdylib and per-lint libraries. Which + `dylint_lib` cfg is set depends on what the consumer installed, so a + suppression naming one is inert for a consumer who loaded the other. Stacking + two attributes is the interim workaround. See §Outstanding decisions for the + reserved extension. +- **A misspelt lint name or a hyphenated `lib` produces a silent no-op.** The + amended expansion preserves `unknown_lints` as the safety net for the first, + once the suite bug above is fixed. The macro rejects an empty or hyphenated + `lib` at expansion time. A wrong-but-well-formed `lib` value cannot be caught + by the macro at all; only a lint with access to the loaded library set can. - Proc-macro dependencies (`syn`, `quote`) increase compile-time for crates that depend on the macro. The impact should remain modest given the small surface - area. + area, and `syn` is pinned without its `full` feature. - Pre-expansion lints can bypass `cfg_attr` gating. For example, a `#[derive(...)]` macro can raise lint diagnostics on generated code before `dylint_expect` expansion is applied. The macro cannot correct toolchain ordering constraints. -- The `lib` value must match the identifier Dylint injects via `dylint_lib`. - Mismatches silently disable the `expect` and can lead to missed enforcement. +- The `check-cfg` entry is a prerequisite, not a convenience. Without it every + annotated item emits `unexpected_cfgs`, and the macro cannot suppress that on + the consumer's behalf. ## Outstanding decisions +Resolved on 2026-08-21: + +- **Publishing.** Both `whitaker_support_macros` and `whitaker_support` are + published to crates.io. Both names were confirmed available. The macro crate + publishes last in the release sequence so a failure cannot strand + `whitaker-common` or `whitaker-installer`. +- **`check-cfg` allowlists.** No longer a "secondary mitigation" — the + `cfg(dylint_lib, values(any()))` entry is the primary and only mechanism that + suppresses `unexpected_cfgs`, and is a documented prerequisite. See §Options + considered, Option D. + +Still open: + +- Whether to also provide `dylint_allow` for cases where `expect` is not + appropriate. If added, it shares the same argument grammar, so the grammar + should live where both can reach it. +- **Reserved extension for multiple libraries.** To cover both the aggregated + and per-lint deployment modes in one annotation, the `lib` key may later + accept either `lib = "x"` or `lib("a", "b")`. Reserving the payload shape on + the existing key, rather than adding a fourth `libs` key, keeps the + argument-key grammar and its validation unchanged. Decide before the argument + surface is frozen by adoption. - Confirm the final ADR sequence number for the Whitaker repository. -- Decide whether to also provide `dylint_allow` for cases where `expect` is not - appropriate. -- Decide whether to publish `whitaker_support` to crates.io or keep it as a - workspace-only utility. -- Decide whether to recommend workspace `check-cfg` allowlists as a secondary, - non-macro mitigation. ## Architectural rationale diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 92b1938d..fab9517e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1541,7 +1541,7 @@ context by combining a HIR ancestry walk with attribute-shape matching. `ContextEntry` items for modules, functions, impls, and blocks, and carries a boolean `has_test_context_ancestry` alongside that list. On each step, `has_test_ancestry` updates that boolean so the test-only decision can -propagate from outer ancestors into nested helper code. `summarise_context` +propagate from outer ancestors into nested helper code. `summarize_context` then combines the accumulated entries, the propagated boolean, and `in_test_like_context_with(additional_test_attributes)` to produce the final `ContextSummary.is_test` result. This pattern matters because user-configured @@ -1555,7 +1555,7 @@ not just the immediately enclosing function. `is_cfg_test_attribute` - the current ancestor is a function item whose attributes match Whitaker's built-in test list or `additional_test_attributes` -- `summarise_context` merges that ancestry flag with the collected +- `summarize_context` merges that ancestry flag with the collected `ContextEntry` values to derive the final `ContextSummary.is_test` decision. Real `rstest` case expansion adds a second `--test` harness shape that the @@ -1747,23 +1747,23 @@ the regression suite: `temp-env` provides scoped environment overrides, `tempfile` provides isolated target directories, and `rstest` powers the fixture-based test setup used by the staged-suite coverage. -#### Shared UI harness environment guards +#### Shared UI harness environment overrides -Workspace-level UI harness tests that mutate process-wide environment variables -must use `whitaker_common::test_support::EnvVarGuard`. Use `EnvVarGuard::set` -to install a temporary value and `EnvVarGuard::remove` to make a variable -absent for the duration of a test. The guard acquires `env_test_guard()` only -while it captures, mutates, or restores the variable; it must not hold that -mutex while a runner callback executes, because the callback may need its own -guarded environment setup. +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` applies a specialized guard before -invoking the Dylint UI runner. On every platform it clears `RUSTC_WRAPPER` only -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. -Restoration uses the same shared environment mutex, but the runner callback -itself executes without holding that mutex to avoid nested-lock deadlocks. +`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 +and the variable is otherwise absent. Example-based UI tests in `rstest_helper_should_be_fixture` also use a cross-process directory lock under the system temporary directory. `nextest` @@ -2088,6 +2088,17 @@ It can be promoted to standard by: `suite/Cargo.toml` 3. Updating documentation to reflect the change +### 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 +`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. + ## Creating a New Lint ### Generating from the template diff --git a/docs/execplans/1-3-1-add-whitaker-support-macros-proc-macro-crate.md b/docs/execplans/1-3-1-add-whitaker-support-macros-proc-macro-crate.md new file mode 100644 index 00000000..477b4c31 --- /dev/null +++ b/docs/execplans/1-3-1-add-whitaker-support-macros-proc-macro-crate.md @@ -0,0 +1,1480 @@ +# Add the `whitaker_support_macros` proc-macro crate (roadmap 1.3.1) + +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: APPROVED + +This document must be maintained in accordance with `AGENTS.md`. The canonical +plan file is +`docs/execplans/1-3-1-add-whitaker-support-macros-proc-macro-crate.md`. + +## Deviation D-9: accepted as option (b) + +The prototyping milestone EP-M0 ran during planning, ahead of any production +code, and falsified a premise of the governing decision record. **ADR 002's +mandated expansion did not achieve ADR 002's own primary technical +requirement**: the four attributes it specified do not suppress +`unexpected_cfgs`, because that diagnostic is resolved during cfg-expansion, +before the annotated item's own lint levels are in scope. A _sibling_ +`#[allow(unexpected_cfgs)]` arrives too late, and an attribute macro cannot +reach an enclosing scope without wrapping the item and changing its semantics. +Two of the remaining three attributes suppressed diagnostics the gated form +never emits, and the third removed the only safety net catching a misspelt lint +name. + +The repository owner accepted option D-9(b) on 2026-08-21: amend ADR 002 and +ship a minimal macro whose expansion is the `cfg_attr` gate alone, paired with +one `check-cfg` manifest entry. ADR 002 has been amended accordingly and moved +to `Accepted`. This plan is now approved for implementation. + +## Prerequisite discovered by the R-1 spike + +A spike implementation, since discarded, answered the one empirical question +this plan left open — and found a second, larger problem. + +**Good news.** `#[expect(...)]` does work for Dylint-registered Whitaker lints. +Against a `no_std_fs_operations` library built from current source, item-level +`#[allow]`, item-level `#[expect]`, module-level `#![allow]`, and the +`cfg_attr`-gated `#[expect]` all suppressed correctly, and expectations were +fulfilled with no spurious `unfulfilled_lint_expectations`. Axiom A-4 is +discharged and R-1 is closed. + +**Bad news.** The aggregated `whitaker_suite` library ignores lint-level +attributes entirely. A controlled experiment — identical fixture, identical +source revision, identical toolchain, only the loaded library differing — +produced one diagnostic under the individual library (the unannotated control) +and three under the suite, plus a spurious unfulfilled-expectation warning for +every `expect`. + +Since `whitaker_suite` is what `whitaker --all`, `make lint-whitaker`, and every +installed consumer load, **no attribute-based suppression currently works in +the configuration this macro targets**. The evidence is in `Artefacts and +notes` §R-1 spike. + +This does not block delivery of 1.3.1. The macro's obligations are all +token-level: what it parses, what it rejects, and what tokens it emits. None of +them depend on suite behaviour. It does block the macro from being _useful_, +and therefore blocks ADR 002 migration phase 3. It is recorded in ADR 002 +§Known risks and tracked as separate work; see `Decision log` D-13. + +## Purpose / big picture + +Whitaker enforces project conventions through Dylint lint libraries. Dylint +lints are unknown to `rustc` during ordinary compilation, so a deliberate, +narrowly-scoped exception cannot simply be written as +`#[expect(some_whitaker_lint)]`. + +Dylint's answer is conditional compilation: for each lint library it loads it +passes `--cfg=dylint_lib="LIBRARY_NAME"`, so an exception can be written as + +```rust,no_run +#[cfg_attr(dylint_lib = "whitaker_suite", expect(no_std_fs_operations, reason = "legacy"))] +fn read_legacy_config() {} +``` + +That form works, and — given one `check-cfg` entry in the consuming manifest — +is completely warning-free. It is also verbose, easy to misspell, and drifts +between call-sites. + +After this change a maintainer writes one attribute: + +```rust,no_run +#[whitaker_support_macros::dylint_expect( + lib = "whitaker_suite", + lints(no_std_fs_operations), + reason = "legacy call-site; remove once the cap-std migration lands" +)] +fn read_legacy_config() {} +``` + +Because `expect` rather than `allow` is used, the suppression announces itself +the moment it becomes stale. + +Concretely, once this plan is complete: + +1. `crates/whitaker_support_macros` exists as a `proc-macro = true` crate + exporting one attribute macro, `dylint_expect`, accepting `lib = "..."`, + `lints(path, ...)`, and an optional `reason = "..."`. +2. The expansion is the `cfg_attr` gate and nothing else, so a misspelt lint + name still trips `unknown_lints` inside a Dylint run. +3. The workspace manifest carries `cfg(dylint_lib, values(any()))` in its + `check-cfg` list, and that one line is documented as the prerequisite for + warning-free use, in this workspace and downstream. +4. Malformed invocations produce precise, span-anchored errors, proven by + `trybuild` compile-fail fixtures with reviewed `.stderr` snapshots. +5. The crate is publish-ready and wired into the release pipeline, published + last so it cannot strand the crates users actually consume. +6. ADR 002 is amended and moved to `Accepted`; `docs/repository-layout.md` and + `docs/whitaker-dylint-suite-design.md` are updated; `docs/roadmap.md` 1.3.1 + is marked done. +7. `make check-fmt`, `make typecheck`, `make lint`, and `make test` all pass. + +Note the attribute path. ADR 002 specifies the eventual spelling as +`#[whitaker_support::dylint_expect(...)]`, but the `whitaker_support` facade is +roadmap item 1.3.2. Within 1.3.1 the macro is reached at its own real path, +`#[whitaker_support_macros::dylint_expect(...)]`. That is not a compatibility +shim; 1.3.2 adds the facade re-export without changing anything delivered here. + +## Context and orientation + +Assume no prior knowledge of this repository. + +### The four diagnostics, and which actually fire + +ADR 002 §Context names three diagnostics that get in the way. EP-M0 measured +all of them against this workspace's real lint policy. The results are the +foundation of this plan, so they are stated here rather than buried: + +| Diagnostic | Level here | Fires on the bare `cfg_attr` gate? | +| ----------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `unexpected_cfgs` | `warn` in `[workspace.lints.rust]`, promoted by `-D warnings` | **Yes** — and only a manifest `check-cfg` entry or an enclosing-scope `allow` suppresses it | +| `unknown_lints` | `deny` in `[workspace.lints.rust]` | **No** outwith Dylint; **yes** inside a Dylint run if the lint name is wrong | +| `clippy::allow_attributes` | `deny` in `[workspace.lints.clippy]` | **No** — there are no `allow` attributes to lint | +| `clippy::allow_attributes_without_reason` | `deny` in `[workspace.lints.clippy]` | **No** — same reason | + +_Table 1: Which diagnostics the gated form actually produces._ + +Two consequences follow, and they drive the whole design. First, only +`unexpected_cfgs` is a real problem, and a macro cannot solve it. Second, +`unknown_lints` firing inside a Dylint run is not an obstacle — it is the only +mechanism that catches a misspelt lint name, and suppressing it would make +every typo a silent no-op. + +### Key files a newcomer needs + +- `docs/adr-002-dylint-expect-attribute-macro.md` — the governing decision. + Read §Decision outcome / proposed direction, §Functional requirements, and + §Known risks. Note that D-9 proposes amending it. +- `docs/roadmap.md` lines 28–47 — the 1.3.x group. 1.3.1 is this plan; 1.3.2 + adds the `whitaker_support` facade; 1.3.3 adds cross-configuration + compatibility coverage; 1.3.4 completes the narrative documentation. +- `crates/whitaker_test_macros/` — the only other `proc-macro = true` crate in + the workspace, and the manifest precedent. Thirty-two lines; ignores its + attribute arguments entirely, so no precedent for parsing or diagnostics. + See `Surprises & discoveries` S-3: its emitted prelude now trips a Clippy + lint that did not exist when it was written. +- `Cargo.toml` (root) — `members = ["common", "crates/*", "installer", + "suite"]`, so a new directory under `crates/` joins the workspace + automatically. `[workspace.lints.rust]` line 180 holds the `check-cfg` array + this plan extends. +- `Makefile` — `WHITAKER_PACKAGES` (line 81) lists crates the Whitaker suite + lints. `typecheck` (223–224), `lint-clippy` (179–181), and `test` (92–139) + are workspace-wide and need no per-crate edit. `publish-check` (316–348) is + **not** a quick packaging check — see `Risks` R-7. +- `.config/nextest.toml` line 46 — the `serial-dylint-ui` override matches + `binary(ui) & test(=ui)`, which the obvious naming for a trybuild harness + would collide with. See R-8. +- `.github/workflows/ci.yml` line 160 and `.github/workflows/release.yml` + lines 334–341 — the two places crates are enumerated for packaging and + publishing. The publish step runs under `set -euxo pipefail` with no + per-crate guard. +- `rust-toolchain.toml` — pinned to `nightly-2026-05-28`. + +### Terms defined + +- **Attribute macro**: a procedural macro invoked as `#[name(args)] item`. It + receives the argument tokens and the item tokens and returns replacement + tokens. +- **Pre-expansion lint**: a lint running before macro expansion, which + `cfg_attr` gating cannot help. ADR 002 §Known risks accepts this limitation. +- **`expect` versus `allow`**: `#[allow(L)]` silences `L` forever; + `#[expect(L)]` silences it but emits `unfulfilled_lint_expectations` if `L` + never fires, so stale suppressions surface. +- **Silent no-op**: a `dylint_expect` attribute that compiles cleanly and + suppresses nothing, because the `lib` value or a lint name is wrong. Three + independent routes to this exist; see R-2. + +## Conformance basis + +- Governing decision record: `docs/adr-002-dylint-expect-attribute-macro.md`, + as at commit `02e6c1c`, status `Proposed`. **D-9 proposes amending §Options + considered, §Decision outcome, and §Known risks before implementation.** +- Roadmap: `docs/roadmap.md` §1.3, item 1.3.1. +- Governing standards: `AGENTS.md`, `docs/documentation-style-guide.md`, + `docs/rust-testing-with-rstest-fixtures.md`, + `docs/rust-doctest-dry-guide.md`, + `docs/complexity-antipatterns-and-refactoring-strategies.md`. +- There is no Terms of Reference artefact. ADR 002 is the sole upstream + requirements source; none has been invented. + +ADR 002's requirements are unnumbered in the source, so this plan assigns local +identifiers and quotes the source sentence for each, keeping the mapping +auditable. + +| Identifier | ADR 002 source | +| ------------ | --------------------------------------------------------------------------------------------------- | +| ADR002-FR-1 | "Provide an item attribute usable on functions, impl blocks, modules, and other Rust items." | +| ADR002-FR-2 | "Support one or multiple lint names per annotation." | +| ADR002-FR-3 | "Support an optional human-readable reason." | +| ADR002-FR-4 | "Enable `#[expect(...)]` only when Dylint runs the specified lint library." | +| ADR002-TR-1 | "Avoid warnings in non-Dylint builds." | +| ADR002-TR-2 | "Keep the macro's expansion explicit and reviewable." | +| ADR002-TR-3 | "Maintain a clear separation between proc-macro code and lint implementation code." | +| ADR002-TR-4 | "Document limitations for 'pre-expansion' lints." | +| ADR002-MIG-1 | §Migration plan phase 1: "Add `crates/whitaker_support_macros` with the proc-macro implementation." | + +_Table 2: Local identifiers assigned to ADR 002's unnumbered requirements._ + +Trace chains: + +```plaintext +ADR002-FR-1 -> EP-M3 -> crates/whitaker_support_macros/tests/applies_to_items.rs +ADR002-FR-2 -> EP-M2 -> expand::tests::preserves_lint_order_and_multiplicity +ADR002-FR-3 -> EP-M2 -> expand::tests::reason_is_propagated_into_expect +ADR002-FR-4 -> EP-M2 -> src/expand/snapshots/*cfg_attr_gate*.snap +ADR002-TR-1 -> EP-M3 -> make lint, contrasted against the EP-M0 transcript +ADR002-TR-2 -> EP-M2 -> insta snapshots of every expansion variant +ADR002-TR-3 -> EP-M1 -> only src/lib.rs names `proc_macro` +ADR002-TR-4 -> 1.3.4 -> deferred by D-10 +ADR002-MIG-1 -> EP-M1 -> cargo metadata lists whitaker_support_macros +INV-SHAPE -> EP-M2 -> keys::tests::exhaustive_key_sequences_to_length_four +``` + +## Constraints + +Hard invariants. Violation requires escalation, not a workaround. + +- The macro's argument surface is fixed by ADR 002: `lib = "..."` (string + literal), `lints(path, ...)` (one or more), optional `reason = "..."`. Do not + add, rename, or reorder these. +- The expansion emits **only** the `cfg_attr(dylint_lib = "...", expect(...))` + gate. It must not emit `allow(unknown_lints)`, because that converts every + misspelt lint name into a silent no-op. _This constraint is contingent on + D-9(b) being accepted; under D-9(a) it is replaced by ADR 002's four-attribute + set._ +- Only `crates/whitaker_support_macros/src/lib.rs` may name the `proc_macro` + crate. Every other module operates on `proc_macro2` and `syn` types so it is + callable from a plain unit test. This is the ADR002-TR-3 boundary. +- No file may exceed 400 lines (`AGENTS.md`). +- `clippy::unwrap_used`, `clippy::expect_used`, `clippy::indexing_slicing`, and + `clippy::panic_in_result_fn` are denied. The macro returns `syn::Result` and + converts to `compile_error!` at the single adapter boundary. +- `unsafe_code` is forbidden; `missing_docs` and + `rustdoc::missing_crate_level_docs` are denied. +- Do not modify any existing lint crate, `common/`, `suite/`, `installer/`, or + `src/`. This plan adds one crate and edits manifests, workflows, and + documentation only. The single exception is `crates/whitaker_test_macros`, + per D-8. +- Comments and documentation use en-GB-oxendict spelling. Markdown prose wraps + at 80 columns; code blocks at 120. +- Do not mutate the parent process environment in tests. +- Use the shared default Cargo cache; do not create an isolated `CARGO_HOME`. + +## Tolerances (exception triggers) + +- Scope: more than 20 files or 900 net lines — stop and escalate. +- Interface: any change to the ADR 002 argument surface — stop and escalate. +- Dependencies: the plan adds `syn`, `quote`, and `proc-macro2` only. Any + further dependency — including `googletest` and `pretty_assertions`, which + D-11 removes from the earlier draft — requires escalation. +- Iterations: if any gate still fails after 3 targeted fix attempts on one + milestone, stop and escalate with the `tee`'d log path. +- Ambiguity: if two readings of the amended ADR 002 give materially different + expansions, present both with trade-offs rather than choosing silently. +- Deviation: if implementation evidence contradicts the amended ADR 002 again, + set status `BLOCKED` and escalate. Do not amend the implementation around it. + +## Risks + +- **R-1 — CLOSED, 2026-08-21.** `#[expect(L)]` does work for Dylint-registered + Whitaker lints. Discharged by the spike recorded in `Artefacts and notes` + §R-1 spike. Axiom A-4 is established. + +- **R-1b — the aggregated suite ignores lint-level attributes.** Superseding + R-1 as the material risk. `libwhitaker_suite` honours neither `#[allow]` nor + `#[expect]`, and emits a spurious `unfulfilled_lint_expectations` warning for + every `expect`, while the individual lint libraries built from the same + source behave correctly. + Severity: high. Likelihood: certain — reproduced under a controlled + experiment. + Mitigation: none available within 1.3.1, and none needed for delivery, since + every obligation in this plan is token-level. Tracked as separate work (D-13) + and recorded in ADR 002 §Known risks. It must be fixed before ADR 002 + migration phase 3 begins adopting the attribute across the estate, or the + estate will fill with annotations that suppress nothing. Do **not** attempt + the fix inside this plan: it touches suite wiring that `Constraints` places + out of scope. + +- **R-2 — three independent routes to a silent no-op.** A misspelt lint name, a + `lib` value naming a library the consumer did not load, or a `lib` written + with hyphens rather than underscores all produce an attribute that compiles + cleanly and suppresses nothing. + Severity: high. Likelihood: high once adoption begins. + Mitigation: not emitting `allow(unknown_lints)` closes the first route inside + a Dylint run — that is the main reason D-9(b) is recommended. The other two + cannot be closed by a macro: they need a lint that inspects call-sites + against loaded libraries and registered lint names. Three of the six + reviewers converged independently on this. Book it as roadmap 1.3.5 before + ADR 002 phase 3 adoption makes the attribute widespread. Meanwhile the wrong + `lib` case fails closed — the underlying lint fires and CI goes red. + +- **R-3 — Whitaker ships two deployment modes, and one `lib` cannot name + both.** The suite is published both as an aggregated `whitaker_suite` cdylib + and as per-lint libraries. Which `dylint_lib` cfg is set depends on what the + consumer installed, so a suppression written for one is inert for the other. + Severity: medium. Likelihood: high. + Mitigation: implement the scalar `lib = "..."` form only, but reserve the + additive escape hatch now by specifying that the `Lib` key may later carry + either `lib = "x"` or `lib("a", "b")`. Because the key-shape policy quantifies + over keys and not payloads, that extension costs nothing later, whereas a + fourth `libs` key would perturb the "exactly one `Lib`" rule. Record the + reservation in ADR 002 §Outstanding decisions at EP-M5. + +- **R-4 — publishing a new, unproven crate first in an all-or-nothing step.** + `release.yml` runs its publish block under `set -euxo pipefail` with no + per-crate guard. A failure on the new crate aborts the step, so + `whitaker-common` and `whitaker-installer` never publish, and crates.io + publishes cannot be undone. + Severity: high. Likelihood: medium. + Mitigation: publish `whitaker_support_macros` **last**, and guard each + publish so an already-uploaded version is skipped rather than fatal. Both + names are currently free on crates.io — `whitaker_support_macros` and + `whitaker_support` return 404 against the registry API, with `whitaker-common` + returning 200 as a control. + +- **R-5 — version drift across ~20 hardcoded manifests.** There is no bump + tooling in `scripts/`, and `release.yml` verifies the tag against + `whitaker-installer` only. Missing the new crate in a bump triggers R-4 on a + release that cannot be retried cleanly. + Severity: medium. Likelihood: medium. + Mitigation: use `version.workspace = true` in the new manifest so there is + one fewer place to drift, and extend the tag-version check to every + publishable crate. + +- **R-6 — the crate is published before it has ever been compiled under + Dylint.** D-10 defers the Dylint-run configuration to roadmap 1.3.3, so every + expansion obligation in 1.3.1 is self-referential: the snapshots assert that + the macro emits what the macro emits. + Severity: medium. Likelihood: high. + Mitigation: R-1's probe is the minimum. Prefer completing roadmap 1.3.3 + before the first release tag that would publish this crate; the release + wiring is inert until then, so this costs nothing to honour. + +- **R-7 — `make publish-check` is not a packaging check.** It adds rustup + components, builds the workspace, runs the entire nextest suite, installs + cargo-dylint, clones the repository, and builds all ten lint crates in + release into a cold target directory. Budget 20–40 minutes. + Severity: low. Likelihood: high. + Mitigation: use `cargo package -p whitaker_support_macros --allow-dirty` + (10–20 s) for the EP-M4 loop. The `ci.yml` addition itself is cheap. + +- **R-8 — the trybuild harness would silently join a serial test group.** + `.config/nextest.toml` line 46 matches `binary(ui) & test(=ui)`, which the + obvious `tests/ui.rs` with `fn ui()` satisfies. It would inherit + `max-threads = 1` and two exponential retries, so a legitimately failing + `.stderr` costs three full trybuild runs before reporting. + Severity: low. Likelihood: high. + Mitigation: name the harness function something other than `ui`, or add an + explicit override with `retries = 0`. Decide deliberately, do not inherit. + +- **R-9 — snapshot brittleness.** Raw `TokenStream::to_string()` output is + sensitive to `quote` spacing. + Severity: low. Likelihood: low. + Mitigation: snapshot a normalized rendering through one helper, so a + formatting change is a one-line re-bless. + +## Verification plan + +The earlier draft carried a Verus sidecar proof, a permutation property test, +seven BDD scenarios, and two new assertion crates. D-11 removes all of them. +The reasoning is recorded here rather than in a footnote, because the removal +is the single largest change in this revision. + +### Why the Verus obligation was cut + +The argument-key alphabet has three symbols, and a well-shaped sequence has +exactly one `Lib`, exactly one `Lints`, and at most one `Reason`. The longest +accepting sequence therefore has length 3. By the pigeonhole principle any +sequence of length 4 or more contains a repeated symbol, hence a duplicate key, +hence is rejected. + +An exhaustive enumeration to length 4 is consequently not merely "complete +within a bound" — it is a **total decision procedure over the infinite +domain**, at 1 + 3 + 9 + 27 + 81 = 121 cases and sub-millisecond cost. Order +independence follows immediately, because counting is order-independent. + +The proposed Verus lemma would have modelled the policy as a left fold and the +specification as a multiset predicate, then proved the two agree — two +notations for one decidable property. `AGENTS.md` requires proofs to be +"substantive, rigorous, and well-founded, not merely a restatement of the +assumed property", and that lemma would have been exactly the restatement. + +Two further facts confirmed the removal. `make verus` and `make kani` run in no +CI workflow — only `scripts/check-verus-fragment-id-bridge.sh` does — so the +obligation would never have been enforced. And every existing sidecar in +`verus/` has an executable partner (Kani drives the real code while Verus +models it); this one would have been the first with no runtime counterpart, +guarding the most trivial property in the repository. + +### Axioms (assumed, not verified) + +- **A-1**: `syn` 2.x parses `lib = "..."`, `lints(a, b)`, and `reason = "..."` + into the token structures its documentation describes. Third-party internals + are not verified; repository-owned logic built on this interface is verified + against the real parser. +- **A-2**: `rustc` applies item-level lint attributes in preference to manifest + `[lints]` levels — **except** for `unexpected_cfgs` arising from an + attribute on the same item, which EP-M0 showed requires an enclosing scope. +- **A-3**: Dylint passes `--cfg=dylint_lib="LIBRARY_NAME"` for each loaded + library. Discharged empirically at roadmap 1.3.3, not here. +- **A-4**: `#[expect(L)]` for a Dylint-registered lint behaves as it does for + built-in lints. **Established empirically, 2026-08-21**, against an + individual lint library built from current source. Note the scope limit: it + holds for individual libraries and **not** for the aggregated + `whitaker_suite` (R-1b), so this axiom supports the macro's design but not + yet its usefulness in the shipping configuration. + +### INV-SHAPE: argument keys are validated exactly and order-independently + +- Obligation: validation succeeds if and only if the supplied key sequence + contains exactly one `Lib`, exactly one `Lints`, and at most one `Reason`. +- Method: exhaustive enumeration over every sequence of length 0–4, plus a + stated pigeonhole argument in a comment covering all longer sequences. +- Rationale: total, cheap, and directly readable. See above. +- Domain: all of `{Lib, Lints, Reason}*`. +- Artefact: `crates/whitaker_support_macros/src/keys.rs`, test + `exhaustive_key_sequences_to_length_four`. +- Evidence: `cargo nextest run -p whitaker_support_macros -E + 'test(exhaustive_key_sequences)'`. Fails to compile before `validate_keys` + exists. +- Non-vacuity: the enumeration covers the empty sequence (rejected, + `MissingLib`), every singleton (all rejected), both accepting two-element + orders, all accepting three-element orders, and every duplicate-bearing + sequence. It asserts the **specific error variant**, so an implementation + collapsing all failures into one variant is rejected. Negative control: + swapping `MissingLints` for `MissingLib` in one branch must fail the test. + +Note that arity is deliberately **not** part of this obligation. `lints()` with +zero paths supplies the `Lints` key and passes key validation; the empty-list +rejection lives in the parser, where the span needed to report it exists. The +earlier draft called this module `grammar` and claimed it covered the grammar; +it does not, and it is now named `keys` accordingly. + +### INV-EXP-1: the annotated item is preserved verbatim + +- Obligation: the expansion ends with exactly the input item tokens, nothing + inserted, removed, or reordered. +- Method: parameterized `rstest` cases across item kinds, plus compile-level + evidence. +- Rationale: a finite partition over Rust item kinds. A property test over + arbitrary token trees would test `quote`'s interpolation, a third-party + internal (A-1). +- Domain: `fn`; `fn` with generics and a where-clause; `impl` block; inherent + method; `mod`; `struct`; `trait`; and an item that already carries doc + comments and other attributes. +- Artefact: `crates/whitaker_support_macros/src/expand.rs` tests; + `crates/whitaker_support_macros/tests/applies_to_items.rs`. +- Evidence: `cargo nextest run -p whitaker_support_macros`. +- Non-vacuity: the "already carries doc comments and attributes" case fails if + the implementation re-parses and re-emits the item instead of passing tokens + through. Negative control: make the expansion drop existing attributes and + confirm that case fails. + +### INV-EXP-2: the expansion is exactly the gate + +- Obligation: the expansion emits the `cfg_attr` gate and nothing else, with + the lint paths in source order and the reason present only when supplied. +- Method: `insta` snapshots over a normalized rendering, one per variant. +- Rationale: ADR002-TR-2 requires the expansion be "explicit and reviewable", + and this is the multivariant output-consistency case snapshots exist for. +- Domain: single lint; multiple lints; with reason; without reason; a library + name containing underscores. +- Artefact: `crates/whitaker_support_macros/src/expand.rs`, snapshots in + `crates/whitaker_support_macros/src/snapshots/` — `insta` resolves snapshot + directories relative to the test file, so this follows the test module's + location, matching `crates/whitaker_clones_core/src/ast/snapshots`. +- Evidence: `cargo nextest run -p whitaker_support_macros` with + `INSTA_UPDATE=no`; new snapshots are unreviewed until blessed. +- Non-vacuity: all five variants must differ from one another, so an + implementation ignoring `reason` or flattening the lint list produces + identical snapshots for distinct inputs. Negative control: add a stray + `allow` to the expansion and confirm all five fail. + +### INV-EXP-3: lint order and multiplicity are preserved + +- Obligation: the paths inside `expect(...)` are exactly those given to + `lints(...)`, same order, same multiplicity. +- Method: `proptest` over generated path lists. +- Rationale: an invariant over arbitrary-length lists, so a property test is + proportionate. Silently deduplicating or sorting would make the expansion + diverge from the call-site a reviewer reads. +- Domain: generated lists of length 1–8 from a pool that deliberately contains + repeats. +- Artefact: `crates/whitaker_support_macros/src/expand.rs`; regression seeds + under `crates/whitaker_support_macros/proptest-regressions/`. +- Evidence: `cargo nextest run -p whitaker_support_macros`. Honour + `PROPTEST_CASES` so the case count is tunable without editing code. +- Non-vacuity: record classification showing at least 20% of generated cases + contain a repeat; a lower rate is a generator defect, not a pass. Negative + control: insert `.dedup()` and confirm the property shrinks to a two-element + repeated list. + +### INV-DIAG-1: malformed invocations produce specific diagnostics + +- Obligation: each malformed-argument class produces a distinct, span-anchored + error naming the offending argument. +- Method: `trybuild` compile-fail fixtures with reviewed `.stderr` snapshots. +- Rationale: diagnostic text and span placement are only observable through a + real compilation. This is also the **only** compatibility net this API will + ever have: `cargo-semver-checks` inspects rustdoc and is blind to a proc + macro's argument grammar. +- Domain: missing `lib`; missing `lints`; empty `lints()`; duplicate `lib`; + duplicate `lints`; duplicate `reason`; non-string-literal `lib`; + non-string-literal `reason`; unknown argument key; `lints` given as a string + rather than a list; a non-path inside `lints(...)`; a path with generics or a + leading `::`; `lib = ""`; `lib` containing a hyphen; trailing commas in both + `lints(a,)` and the top-level list. +- Artefact: `crates/whitaker_support_macros/tests/ui.rs` (harness function + **not** named `ui`, per R-8) with fixtures under `tests/ui/`. +- Evidence: `cargo nextest run -p whitaker_support_macros -E + 'binary(ui)'`. Each fixture fails with no `.stderr` present, then passes once + a reviewed `.stderr` is blessed. +- Non-vacuity: the earlier draft's control — "all `.stderr` files must differ" + — was itself vacuous, because trybuild embeds the fixture path and line + number in every file, so they always differ. Compare **normalized message + text** with paths and line numbers stripped. Note that an empty argument list + and a missing `lib` genuinely collapse onto the same error today; either add + an `EmptyArguments` variant or record the collapse as deliberate, rather than + letting a broken control paper over it. + +### INV-WARN-1: warning-free with the check-cfg entry present + +- Obligation: applying the attribute to any supported item kind produces no + diagnostic under `cargo check` or `cargo clippy` with warnings denied and + this workspace's full lint policy in force. +- Method: compile-level evidence from the repository's own gates. The crate's + integration tests and rustdoc examples use the macro, are compiled under + `RUSTFLAGS="-D warnings"` by `make test` and under `cargo clippy -- -D + warnings` by `make lint-clippy`, and inherit `[lints] workspace = true`. No + bespoke harness is needed. +- Rationale: this is ADR002-TR-1, and the honest check is to compile real + usages under the exact policy the workspace enforces. +- Domain: the non-Dylint configuration only. The Clippy-run and Dylint-run + matrix is roadmap 1.3.3's scope, per D-10. +- Artefact: `crates/whitaker_support_macros/tests/applies_to_items.rs`, the + crate's rustdoc examples, and the gates. +- Evidence: `make lint` exits 0 with no warning mentioning the new crate. +- Non-vacuity: EP-M0 established that the same code **does** warn without the + `check-cfg` entry. That transcript is the negative control, and it is + recorded in `Artefacts and notes`. Without it a clean `make lint` proves + nothing. + +### Stacking + +Two `dylint_expect` attributes on one item is the only route to covering both +deployment modes until R-3's extension lands, so it must be a tested, +documented case. Under D-9(b) the expansion is a single `cfg_attr` with no +`allow` attributes, so stacking cannot produce duplicate attributes and +`clippy::duplicated_attributes` has nothing to fire on — but assert it rather +than assume it. + +## Plan of work + +### Stage A — complete EP-M0 + +Probes 2, 3, and 4 were run during planning; their transcripts are in +`Artefacts and notes`. Probe 1 (R-1) remains open and requires a real Dylint +session. Commit the probes as `scripts/probe-dylint-expect-viability.sh` with +asserted exit codes, so the gate is a re-runnable artefact rather than +self-attested prose, and so that INV-WARN-1's negative control cannot evaporate. + +Go/no-go: if probe 1 shows `expect` does not work for Dylint lints, stop and +escalate — ADR 002 needs a second amendment. + +### Stage B — red tests and the specification + +1. `crates/whitaker_support_macros/Cargo.toml` with `[lib] proc-macro = true` + and full publish metadata. +2. `src/lib.rs` containing only crate documentation, module declarations, and a + `dylint_expect` that returns `compile_error!("not yet implemented")`. +3. All unit, exhaustive, property, and snapshot tests, written against the + intended API. They will not compile — that is the red state. +4. All compile-fail fixtures with no `.stderr` files. + +Validation: `cargo nextest run -p whitaker_support_macros` fails to compile +with errors naming the missing items. Record the transcript. + +### Stage C — implementation + +1. `src/keys.rs` — `ArgKey`, `ArgShapeError`, and `validate_keys`. Turn the + exhaustive test green. +2. `src/args.rs` — the `syn::parse::Parse` implementation mapping tokens to + keyed payloads, calling `validate_keys`, validating payloads (non-empty + lint list, path shape, non-empty `lib`, underscore-only `lib`), then + assembling `DylintExpect`. +3. `src/expand.rs` — the renderer. Turn INV-EXP-1 through INV-EXP-3 green and + bless the snapshots after reading each one. +4. `src/lib.rs` — wire the adapter and convert `syn::Error` via + `to_compile_error()`. Bless the `.stderr` fixtures after reviewing each for + span placement and wording, comparing normalized message text. + +Validation: `cargo nextest run -p whitaker_support_macros` passes. + +### Stage D — wiring and documentation + +1. Root `Cargo.toml`: add `cfg(dylint_lib, values(any()))` to the + `[workspace.lints.rust]` `check-cfg` array; add `syn`, `quote`, and + `proc-macro2` to `[workspace.dependencies]`; add the + `whitaker_support_macros` entry. +2. `crates/whitaker_test_macros/Cargo.toml`: migrate to the shared pins (D-8). +3. `Makefile`: append `-p whitaker_support_macros` to `WHITAKER_PACKAGES`. +4. `.github/workflows/ci.yml` line 160: add the crate to `PUBLISH_PACKAGES`. +5. `.github/workflows/release.yml`: add `cargo publish -p + whitaker_support_macros` as the **last** publish step, with an + already-published guard on every step in the block (R-4). +6. Documentation: amend and accept ADR 002; cross-reference from + `docs/whitaker-dylint-suite-design.md`; add the crate to + `docs/repository-layout.md`; mark `docs/roadmap.md` 1.3.1 done. The users' + and developers' guide narratives are deferred to 1.3.4 per D-10. +7. Run the full gate set. + +Validation: `make check-fmt`, `make typecheck`, `make lint`, `make test`, +`make markdownlint`, and `make nixie` all pass. + +## Milestones and plateaus + +### EP-M0 — prototype findings recorded (prototyping milestone) + +- Outcome: probes 2–4 answered with transcripts; probe 1 (R-1) explicitly open; + probes committed as a script with asserted exit codes. +- Requirements and gaps: de-risks ADR002-FR-4 and ADR002-TR-1; targets A-4. +- Acceptance evidence: EV-M0 — `scripts/probe-dylint-expect-viability.sh` exits + 0, and its recorded output matches `Artefacts and notes`. +- Conformance check: **failed at planning time.** The evidence contradicts + ADR 002 §Decision outcome; D-9 records the deviation and the plan is BLOCKED + pending acceptance. +- Recovery: the probe script is additive and independently revertible. +- Remaining gaps: probe 1; everything downstream. +- Compatibility decision: none required. + +### EP-M1 — crate skeleton exists and the workspace still builds + +- Outcome: the crate is a workspace member with correct metadata and lint + inheritance; `make typecheck` passes; the macro is a stub that always errors. +- Requirements and gaps: ADR002-MIG-1, ADR002-TR-3. +- Acceptance evidence: EV-M1 — `cargo metadata --format-version 1 --no-deps` + lists the crate, and `make typecheck` exits 0. +- Conformance check: only `src/lib.rs` names `proc_macro`; `version.workspace = + true`; `rust-version` declared; no dependency beyond the three approved. +- Recovery: delete the directory; the `crates/*` glob makes removal complete. +- Remaining gaps: all behaviour. +- Compatibility decision: none. New crate, no consumers. + +### EP-M2 — the macro is correct + +- Outcome: parsing, validation, and expansion are correct; every test passes; + fixtures and snapshots are reviewed and blessed; R-1's probe has answered. +- Requirements and gaps: ADR002-FR-1 through FR-4, ADR002-TR-2; INV-SHAPE, + INV-EXP-1, INV-EXP-2, INV-EXP-3, INV-DIAG-1. +- Acceptance evidence: EV-M2 — `cargo nextest run -p whitaker_support_macros` + reports all tests passed with the count recorded. Every negative control has + been run and reverted, with its failing output recorded. +- Conformance check: the expansion matches the amended ADR 002 exactly; no file + exceeds 400 lines; no `unwrap`/`expect` in non-test code. +- Recovery: snapshots and `.stderr` files regenerate with `INSTA_UPDATE=always` + and `TRYBUILD=overwrite`, but must be re-read before committing. +- Remaining gaps: wiring, release, documentation. +- Compatibility decision: none. + +### EP-M3 — warning-free under the full workspace lint policy + +- Outcome: `make lint` and `make test` pass with the crate included and the + `check-cfg` entry present. +- Requirements and gaps: ADR002-TR-1; INV-WARN-1. +- Acceptance evidence: EV-M3 — `make lint` exits 0 with no warning referencing + the crate, contrasted against the EP-M0 transcript. +- Conformance check: if adding the crate to `WHITAKER_PACKAGES` breaks the + Dylint check build, as it may for a proc-macro crate, record the failure, + revert that one line, and note the deviation — do not weaken any lint. +- Recovery: revert the `Makefile` line; the rest stands. +- Remaining gaps: release, documentation. +- Compatibility decision: none. + +### EP-M4 — publish-ready and wired into release + +- Outcome: `cargo package -p whitaker_support_macros --allow-dirty` succeeds; + `ci.yml` and `release.yml` include the crate, published last and guarded. +- Requirements and gaps: resolves ADR 002 §Outstanding decisions item 3. +- Acceptance evidence: EV-M4 — the packaging step completes and the `.crate` + lists `src/`, `Cargo.toml`, and the licence, with no nested manifest. +- Conformance check: the new crate publishes **after** every existing one; each + publish skips an already-uploaded version rather than aborting; the tag + version check covers every publishable crate. +- Recovery: revert the two workflow edits. **This is the one milestone whose + rollback expires** — after the first release tag, publication is irreversible + and the crates.io name is permanent. +- Remaining gaps: documentation. +- Compatibility decision: none. First publication. + +### EP-M5 — documentation and roadmap + +- Outcome: ADR 002 amended and `Accepted` with a dated summary; suite design + cross-referenced; repository layout updated; roadmap 1.3.1 marked `[x]`. +- Requirements and gaps: ADR002-TR-4 is explicitly **deferred to 1.3.4**. +- Acceptance evidence: EV-M5 — `make markdownlint` and `make nixie` pass, and + `rg -n 'dylint_expect' docs/` lists ADR 002, the suite design, the repository + layout, and this plan. +- Conformance check: ADR 002 §Options considered must record that Option D's + original rejection rationale was factually wrong; §Decision outcome must + carry the amended expansion; §Known risks must carry R-2 and R-3. +- Recovery: documentation edits are independently revertible. +- Remaining gaps: roadmap 1.3.2, 1.3.3, 1.3.4, and the proposed 1.3.5 lint. +- Compatibility decision: none. + +## Interfaces and dependencies + +### Crate layout + +```plaintext +crates/whitaker_support_macros/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # adapter: the ONLY file naming `proc_macro` +│ ├── model.rs # domain value types +│ ├── keys.rs # pure key-shape policy + exhaustive tests +│ ├── args.rs # syn adapter: tokens -> validated DylintExpect +│ ├── expand.rs # renderer + unit, snapshot, property tests +│ └── snapshots/ # insta snapshots, resolved relative to src/ +└── tests/ + ├── applies_to_items.rs + ├── ui.rs # trybuild harness; fn NOT named `ui` (R-8) + └── ui/ # compile-fail fixtures plus .stderr +``` + +Flat modules, not directories. The earlier draft used `args/mod.rs` and +`args/grammar/mod.rs` on the belief that +`clippy::self_named_module_files` requires the `mod.rs` style. It does not — it +forbids `args/args.rs`. A flat `src/args.rs` with no sibling directory is fully +compliant, and every file here sits well under 400 lines. + +On the boundary: the infrastructure in a procedural macro is +`proc_macro::TokenStream`, which exists only inside a compiler invocation and +cannot be constructed in a unit test. Everything else — `syn`, `quote`, +`proc_macro2` — is pure compile-time data with no ambient effects, so it +belongs to the domain's vocabulary rather than being something to abstract +away. `src/lib.rs` is therefore the sole adapter and every other module is +directly unit-testable. That is the whole of the architectural claim; it is the +standard `proc_macro2` hygiene idiom, and calling it "hexagonal" would invite a +future contributor to add a trait to complete a pattern that has no second +implementation to abstract over. + +### Signatures that must exist at the end of EP-M2 + +In `crates/whitaker_support_macros/src/keys.rs`: + +```rust +/// Identifies which keyword an argument in a `dylint_expect` list supplied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArgKey { + Lib, + Lints, + Reason, +} + +/// Describes why a sequence of argument keys is malformed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArgShapeError { + Duplicate(ArgKey), + MissingLib, + MissingLints, +} + +/// Accepts a key sequence with exactly one `lib`, exactly one `lints`, and at +/// most one `reason`, in any order. The `usize` locates the offending key so +/// the caller can anchor a diagnostic span on it. +pub(crate) fn validate_keys(keys: &[ArgKey]) -> Result<(), (usize, ArgShapeError)>; +``` + +The index is load-bearing. `Duplicate(ArgKey)` alone says _which key_ but never +_which occurrence_, forcing the parser to re-scan to find the second `lib` to +point at. Returning the position keeps the policy pure and the diagnostics +precise, and hardens before ten `.stderr` files are blessed. + +In `crates/whitaker_support_macros/src/model.rs`: + +```rust +/// Names the Dylint library whose `dylint_lib` cfg gates the expectation. +/// +/// Construction rejects an empty name and any name containing a hyphen, since +/// Dylint injects a Rust identifier and a hyphenated package name silently +/// produces a suppression that never applies. +pub(crate) struct LibraryName(syn::LitStr); + +/// Carries the human-readable justification for a suppression. +pub(crate) struct Reason(syn::LitStr); + +/// Holds a validated `dylint_expect` invocation. +pub(crate) struct DylintExpect { + lib: LibraryName, + lints: Vec, + reason: Option, +} +``` + +Both newtypes hold `syn::LitStr` rather than `String`, so the span survives for +diagnostics and `LibraryName` has a real invariant to enforce — which is the +one route to R-2's silent no-op that the macro _can_ close by itself. + +In `crates/whitaker_support_macros/src/expand.rs`: + +```rust +/// Renders the cfg-gated expectation followed by the untouched item. +pub(crate) fn expand(spec: &DylintExpect, item: &proc_macro2::TokenStream) -> proc_macro2::TokenStream; +``` + +In `crates/whitaker_support_macros/src/lib.rs`: + +```rust +#[proc_macro_attribute] +pub fn dylint_expect(attr: TokenStream, item: TokenStream) -> TokenStream; +``` + +### Required expansion + +For `lib = "whitaker_suite"`, `lints(no_std_fs_operations, module_max_lines)`, +`reason = "legacy call-site"`: + +```rust,no_run +#[cfg_attr( + dylint_lib = "whitaker_suite", + expect(no_std_fs_operations, module_max_lines, reason = "legacy call-site") +)] +fn read_legacy_config() {} +``` + +When `reason` is omitted, the `reason = "..."` key is omitted from +`expect(...)`. Nothing else is emitted. _This is the D-9(b) expansion; under +D-9(a) it would instead be ADR 002's four-attribute set._ + +Note the library name. The earlier draft used `whitaker_lints` throughout, +copied from ADR 002. No such library exists: `rg whitaker_lints` finds nothing +outwith ADR 002 and this plan. The real names are `whitaker_suite` +(`suite/Cargo.toml`, `installer/src/resolution.rs`) and the individual lint +crates listed in `Makefile` `LINT_CRATES`. Shipping the ADR's string as the +canonical example would have made every copied call-site a guaranteed silent +no-op, and it must be corrected in ADR 002 too. + +Lint paths are accepted as `syn::Path` but validated to Dylint's actual shape: +a single-segment identifier, no leading `::`, no generics. Dylint registers +plain names, so a tool-qualified path such as `clippy::needless_return` gated +on `dylint_lib` would be absent during every Clippy run — the only run where it +could fire. Accepting it is a footgun, not future-proofing. + +### Dependencies + +`crates/whitaker_support_macros/Cargo.toml`: + +```toml +[package] +name = "whitaker_support_macros" +version.workspace = true +edition = "2024" +rust-version = "1.81" +description = "Attribute macro for conditional Dylint expect suppressions" +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +keywords = ["dylint", "lint", "macro", "expect"] +categories = ["development-tools"] + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } +trybuild = { workspace = true } + +[lints] +workspace = true +``` + +`rust-version = "1.81"` is a contract, not decoration: the expansion relies on +`#[expect]` and `reason =` in lint attributes, both stabilized in 1.81. + +New `[workspace.dependencies]` entries in the root `Cargo.toml`: + +```toml +proc-macro2 = "1.0.106" +quote = "1.0.46" +syn = { version = "2.0.119", default-features = false, features = ["derive", "parsing", "printing", "proc-macro"] } +whitaker_support_macros = { path = "crates/whitaker_support_macros", version = "0.2.7" } +``` + +`syn` is pinned **without** the `full` feature. The item is passed through as an +opaque `proc_macro2::TokenStream` (INV-EXP-1), so only `Path`, `LitStr`, +`Punctuated`, and `parenthesized!` are parsed. `full` is the expensive feature, +and a `[workspace.dependencies]` feature set is baked into the published +manifest — so once roadmap 1.3.2 puts this crate on downstream build graphs, +`full` would cost every consumer 5–10 s of cold compile for nothing. +`crates/whitaker_test_macros` genuinely needs `full` and is `publish = false`, +so it adds that feature at its own use site. + +Verify the exact current versions with `cargo search` before pinning; the +values above come from `Cargo.lock` at planning time. + +Also add to `[workspace.lints.rust]`: + +```toml +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)', 'cfg(dylint_lib, values(any()))'] } +``` + +This one line is what actually makes the gated form warning-free. Everything +the macro does is ergonomics on top of it, and the plan should not pretend +otherwise. + +## Concrete steps + +Run everything from the repository root, +`/home/leynos/.lody/repos/github---leynos---whitaker/worktrees/c0b1e9dd-2aff-4206-8bb8-19335d3fa354`. + +Gate output is long and the terminal truncates the middle, so pipe through +`tee`: + +```bash +set -o pipefail +make check-fmt 2>&1 | tee "/tmp/check-fmt-whitaker-$(git branch --show-current | tr '/' '-').out" +``` + +### Focused loops + +```bash +cargo nextest run -p whitaker_support_macros 2>&1 | tee /tmp/nextest-support-macros.out +cargo nextest run -p whitaker_support_macros -E 'test(exhaustive_key_sequences)' +TRYBUILD=overwrite cargo nextest run -p whitaker_support_macros -E 'binary(ui)' +INSTA_UPDATE=always cargo nextest run -p whitaker_support_macros +cargo insta review +cargo package -p whitaker_support_macros --allow-dirty +``` + +`TRYBUILD=overwrite` and `INSTA_UPDATE=always` regenerate expected output. +Never commit output blessed this way without reading every regenerated file — +blessing blind converts a test into a tautology. + +Use `cargo package` for the EP-M4 loop, **not** `make publish-check` (R-7). + +### Full gates + +```bash +make check-fmt 2>&1 | tee /tmp/check-fmt-support-macros.out +make typecheck 2>&1 | tee /tmp/typecheck-support-macros.out +make lint 2>&1 | tee /tmp/lint-support-macros.out +make test 2>&1 | tee /tmp/test-support-macros.out +make markdownlint 2>&1 | tee /tmp/markdownlint-support-macros.out +make nixie 2>&1 | tee /tmp/nixie-support-macros.out +``` + +Delegate full gate runs to the `scrutineer` sub-agent rather than running them +in the planning context; it runs them sequentially, captures each log, and +returns a bounded report. Do not run gates in parallel — this environment uses +build caching, and sequential execution is what benefits from it. + +## Validation and acceptance + +A reader can confirm this work as follows. + +Apply the attribute to a function in the crate's own test tree and run +`make test`, then `make lint`. Expect a clean pass with no warnings. That is +INV-WARN-1. Then remove `cfg(dylint_lib, values(any()))` from the workspace +`check-cfg` array and re-run: expect `unexpected_cfgs` at the call-site. That +contrast is the point of the whole change, and it is the negative control that +makes the clean run meaningful. + +Remove the `lints(...)` argument from a fixture and expect a compiler error +reading ``dylint_expect` requires a `lints(...)` argument with at least one lint +path`` anchored at the attribute's span. + +### Red-Green-Refactor evidence to record + +- Red: `cargo nextest run -p whitaker_support_macros` at the end of Stage B + fails to compile, with errors naming `validate_keys`, `expand`, and + `DylintExpect` as unresolved. That is the intended failure reason. +- Green: the same command at the end of Stage C reports all tests passed. + Record the exact count. +- Refactor: after any extraction, re-run the focused command and then + `make lint`; both must pass unchanged. + +### Quality criteria + +- Tests: `make test` passes; `cargo nextest run -p whitaker_support_macros` + passes. +- Verification: INV-SHAPE, INV-EXP-1 through INV-EXP-3, INV-DIAG-1, and + INV-WARN-1 discharged by their named artefacts, each with its negative + control run and recorded. R-1's probe answered. +- Lint and typecheck: `make check-fmt`, `make typecheck`, `make lint` exit 0. +- Documentation: `make markdownlint`, `make nixie` exit 0. +- Packaging: `cargo package -p whitaker_support_macros` exits 0. +- Performance: no threshold, but `syn` must not carry `full`. +- Security: none beyond the workspace's `unsafe_code = "forbid"`. + +## Idempotence and recovery + +Every step is re-runnable. The crate directory can be deleted and recreated +without touching any other member, because `crates/*` globbing means there is +no `members` list to keep in step. + +Snapshot and `trybuild` fixtures are regenerable, but regeneration is not +recovery — a regenerated expectation must be read before it is committed. + +Nothing in this plan publishes anything: `cargo publish` runs only from +`release.yml` on a release tag, so the release wiring is inert until a tag is +pushed. **After that first tag it is irreversible**, which is why R-4's +ordering and guards are not optional. + +## Artefacts and notes + +### EP-M0 transcripts + +All commands below were run on the pinned toolchain during planning and are +reproducible in under a minute. Probe 1 (R-1) is **not** among them and remains +open. + +**Probe 3 — the unmitigated baseline, and INV-WARN-1's negative control.** +A bare gated attribute, no `allow` attributes, warnings denied, +`check-cfg = 'cfg(kani)'` only: + +```plaintext +error: unexpected `cfg` condition name: `dylint_lib` + --> probe.rs:1:12 + | +1 | #[cfg_attr(dylint_lib = "whitaker_suite", expect(no_std_fs_operations, reason = "legacy"))] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: `-D unexpected-cfgs` implied by `-D warnings` +``` + +One diagnostic. No `unknown_lints`: a false `cfg_attr` predicate is stripped +before lint-attribute processing, so `rustc` never sees the unknown lint name. +No `clippy::allow_attributes`: there are no `allow` attributes to lint. + +**Probe 3b — the same file with `dylint_lib` added to `check-cfg`.** Exit 0, +no output. This is the entire fix. + +**Probe 3c — the cfg active with an unregistered lint name.** + +```plaintext +error: unknown lint: `no_std_fs_operations` + --> probe.rs:1:50 + = note: `-D unknown-lints` implied by `-D warnings` +``` + +This is the misspelt-lint safety net. ADR 002's mandated +`#[allow(unknown_lints)]` would remove it, converting every typo into a silent +no-op. That is the strongest single argument for D-9(b). + +**Probe 3d — where `allow(unexpected_cfgs)` actually works.** Three placements +of the same suppression against the same gated attribute: + +```plaintext +(a) allow as a SIBLING attribute on the same item -> WARNS +(b) allow on an ENCLOSING module -> silent +(c) allow as an INNER attribute of the item's body -> silent +``` + +Only the ADR 002 shape fails. `unexpected_cfgs` is resolved during +cfg-expansion, before the item's own lint levels are in scope. + +**Probe 3e — the decisive one.** A real `proc-macro = true` crate emitting +ADR 002's exact four-attribute expansion, consumed by a second crate carrying +this workspace's lint policy: + +```plaintext +warning: unexpected `cfg` condition name: `dylint_lib` + --> src/lib.rs:1:1 +``` + +The macro, built and run exactly as ADR 002 specifies, still warns — at the +call-site. This is the finding that blocks the plan. + +**Probe 2 — Clippy self-suppression.** `#[allow(clippy::allow_attributes, +reason = "...")]` does suppress `clippy::allow_attributes` for its own +attribute list, so ADR 002's shape would have worked had it been needed. Under +D-9(b) it is not needed, because no `allow` attributes are emitted. + +**Probe 4 — Kani.** Moot under D-11, which removes the bounded-model-checking +question entirely. + +### R-1 spike + +A throwaway crate was compiled under a real Dylint session against libraries +built from this worktree's source, then discarded. The fixture annotated +identical `std::fs::read_to_string` call-sites four ways and compared which +were suppressed. + +Against `libno_std_fs_operations` — an individual lint library: + +```plaintext +error: std::fs operation ... bypasses the capability-based filesystem policy. + --> src/lib.rs:2:46 <- a_none, no attribute (the control) +error: could not compile `spike_expect` (lib) due to 1 previous error +``` + +One diagnostic, from the unannotated control. Item-level `#[allow]`, +item-level `#[expect]`, module-level `#![allow]`, and the `cfg_attr`-gated +`#[expect]` were all suppressed, and no `unfulfilled_lint_expectations` warning +was emitted for the fulfilled expectations. **R-1 is answered: `expect` works.** + +The controlled experiment, same fixture and source, only the library differing: + +```plaintext +########## LIBRARY: no_std_fs_operations ########## +error: LINT FIRED + --> src/lib.rs:2:46 <- control only +########## LIBRARY: whitaker_suite ########## +error: LINT FIRED + --> src/lib.rs:2:46 <- control +error: LINT FIRED + --> src/lib.rs:5:52 <- #[allow(no_std_fs_operations)] IGNORED +error: LINT FIRED + --> src/lib.rs:8:53 <- #[expect(no_std_fs_operations)] IGNORED +warning: this lint expectation is unfulfilled + --> src/lib.rs:7:10 <- spurious +``` + +Both libraries were built from the same commit with +`cargo build --release --features dylint-driver`. The aggregated suite ignores +lint levels; the individual library honours them. This is R-1b. + +Ruled out as causes: staleness of the installed release build (the suite was +rebuilt from source and behaved identically), lint-identity mismatch +(`suite/src/lints.rs` registers the same `&'static Lint` statics the +constituent passes emit with), and `cfg_attr` interaction (plain `#[allow]` +with no gating fails too). The remaining suspect is the +`declare_combined_late_lint_pass!` aggregation in `suite/src/driver.rs`, but +root-causing it is out of scope here and belongs to D-13. + +**Crates.io name availability**, checked against the registry API: + +```plaintext +whitaker_support_macros -> 404 (free) +whitaker_support -> 404 (free) +whitaker-common -> 200 (control: exists) +``` + +### Compile-fail fixture inventory + +Fixtures under `crates/whitaker_support_macros/tests/ui/`, each paired with a +reviewed `.stderr`: `missing_lib.rs`, `missing_lints.rs`, `empty_lints.rs`, +`duplicate_lib.rs`, `duplicate_lints.rs`, `duplicate_reason.rs`, +`non_string_lib.rs`, `non_string_reason.rs`, `unknown_argument.rs`, +`empty_argument_list.rs`, `lints_as_string.rs`, `non_path_in_lints.rs`, +`generic_lint_path.rs`, `leading_colons_lint_path.rs`, `empty_lib.rs`, +`hyphenated_lib.rs`, `trailing_comma_in_lints.rs`, and +`trailing_comma_top_level.rs`. + +Compare **normalized** message text across them, with paths and line numbers +stripped; trybuild embeds the fixture path in every `.stderr`, so raw file +comparison always shows a difference and proves nothing. + +## Progress + +- [x] (2026-08-21) EP-M0 probes 2, 3, and 4 run; transcripts recorded above. +- [x] (2026-08-21) D-9 deviation accepted as option (b) by the repository + owner. ADR 002 amended and moved to `Accepted`. +- [x] (2026-08-21) R-1 discharged by spike: `#[expect(...)]` works for + Dylint-registered lints. Spike discarded as planned. Discovered R-1b — + the aggregated suite ignores lint-level attributes — recorded in ADR 002 + §Known risks and tracked as D-13. +- [ ] EP-M0 remaining: commit the probes as a script with asserted exit codes, + so INV-WARN-1's negative control is a re-runnable artefact. +- [ ] EP-M1: crate skeleton, manifest, workspace dependency and `check-cfg` + entries. +- [ ] EP-M2: keys, parser, expansion, all tests green, snapshots and `.stderr` + fixtures reviewed and blessed. +- [ ] EP-M3: warning-free under the full workspace lint policy. +- [ ] EP-M4: packaging verified; `ci.yml` and `release.yml` wired, new crate + published last and guarded. +- [ ] EP-M5: ADR 002 amended and accepted; suite design and repository layout + updated; roadmap 1.3.1 marked done. + +## Surprises & discoveries + +- **S-1 — ADR 002's expansion does not suppress `unexpected_cfgs`.** + Evidence: probes 3d and 3e above. Impact: blocks the plan; drives D-9. The + only working mitigations are a manifest `check-cfg` entry or an + enclosing-scope `allow`, neither of which an attribute macro can provide for + an arbitrary item. + +- **S-2 — the diagnostics ADR 002 sets out to suppress mostly do not fire.** + Evidence: probe 3. Impact: `allow(unknown_lints)` and + `allow(clippy::allow_attributes)` address diagnostics the gated form never + emits outwith Dylint, and the former destroys the misspelt-lint safety net + inside Dylint. ADR 002 §Options considered rejects Option D partly on this + basis, so that rejection rationale must be corrected. + +- **S-3 — `whitaker_test_macros` now emits a pattern that trips a Clippy + lint.** Its expansion uses `#[cfg_attr(clippy, expect(clippy::allow_attributes, + ...))]`, which the current toolchain rejects with + `clippy::unnecessary_clippy_cfg` ("no need to put clippy lints behind a + `clippy` cfg"). Evidence: probe 2's scratch crate. Impact: out of scope here, + but worth raising as separate follow-up work before it surfaces in a gate. + +- **S-4 — the library name in ADR 002 does not exist.** `whitaker_lints` + appears nowhere in the repository. Impact: corrected throughout this plan to + `whitaker_suite`; ADR 002 must be corrected at EP-M5, before the string + becomes copy-paste canon. + +- **S-5 — `make verus` and `make kani` run in no CI workflow.** Only + `scripts/check-verus-fragment-id-bridge.sh` does. Impact: contributed to + D-11; a Verus obligation here would never have been enforced. + +- **S-6 — no proof file in `verus/` has ever been modified.** All five are + single "Add" commits, while `common/src` has taken 19 commits and + `whitaker_clones_core/src` 8 over the same period. Impact: corroborates that + a sidecar with no executable partner drifts silently. + +## Decision log + +- **D-1**: scope 1.3.1 to the macro crate alone, using the + `#[whitaker_support_macros::dylint_expect(...)]` path in its own tests and + documentation. + Rationale: ADR 002 §Migration plan phase 1 covers exactly this crate, and + roadmap 1.3.2 owns the facade. A placeholder facade now would be + compatibility theatre — there is no consumer to be compatible with. + Date/Author: 2026-08-21, planning agent. + +- **D-2**: make the crate publish-ready and wire the release pipeline within + 1.3.1. + Rationale: user direction, resolving ADR 002 §Outstanding decisions item 3. + Amended by R-4: the new crate publishes **last**, not first, and every + publish in the block is guarded against an already-uploaded version. The + user's decision predates the discovery that `release.yml`'s publish block is + all-or-nothing under `set -euxo pipefail`. + Date/Author: 2026-08-21, user; amended by planning agent. + +- **D-3**: move ADR 002 to `Accepted` within 1.3.1. + Rationale: user direction. Now conditional on D-9: the ADR must first be + **amended**, because accepting it unchanged would ratify an expansion that + provably does not work. + Date/Author: 2026-08-21, user; qualified by planning agent. + +- **D-8**: migrate `crates/whitaker_test_macros` to the promoted workspace pins + for `syn`, `quote`, and `proc-macro2`, with `full` added at its use site. + Rationale: two pins for the same dependency is a second version of truth. The + `full` feature stays local so it does not leak into the published manifest. + Date/Author: 2026-08-21, planning agent. + +- **D-9 — ARCHITECTURE DEVIATION. Status: ACCEPTED as option (b), 2026-08-21, + by the repository owner.** ADR 002 has been amended: §Status, §Decision + drivers, §Technical requirements, §Options considered (Option D and Table 1), + §Decision outcome, §Known risks, and §Outstanding decisions. The macro emits + the `cfg_attr` gate alone; the `check-cfg` entry is documented as the + mechanism rather than a convenience. The original deviation record follows, + retained for provenance. + Affected upstream identifiers: ADR002-TR-1 (primary), ADR002-FR-4, and + ADR 002 §Options considered, §Decision outcome, §Known risks. + Finding: the mandated four-attribute expansion does not suppress + `unexpected_cfgs` (S-1), two of its attributes suppress diagnostics that never + fire (S-2), and the third removes the misspelt-lint safety net. + Options: + - **(a) Implement ADR 002 verbatim.** Roadmap-faithful. Ships a macro that + does not achieve warning-freedom and that masks misspelt lint names. + Requires no ADR amendment but knowingly delivers a broken requirement. + - **(b) Amend ADR 002 and ship a minimal macro (recommended).** The + expansion becomes the `cfg_attr` gate alone; Whitaker adds one `check-cfg` + entry and documents it as the prerequisite for consumers. Preserves typo + detection. Requires correcting §Options considered (Option D's rejection + rationale is factually wrong), §Decision outcome, and §Known risks. + - **(c) Supersede ADR 002 with Option D plus a lint.** No macro: one + `check-cfg` line, plus a `dylint_expect_shape` lint that validates + call-sites against loaded libraries and registered lint names — closing all + three silent-no-op routes in R-2, which no macro can. On-thesis for a lint + suite. Removes roadmap 1.3.1–1.3.4 as written. + Recommendation: (b), with (c)'s lint booked as roadmap 1.3.5 regardless, + since R-2's remaining two routes survive under every option. + Required upstream change: ADR 002 amendment before EP-M1. + Approving authority: repository owner. + Date/Author: 2026-08-21, planning agent. + +- **D-10**: defer the users' and developers' guide narratives to roadmap 1.3.4. + Rationale: 1.3.4 is literally "Document intended usage, narrow-scope review + guidance, and pre-expansion limitations". The earlier draft mapped + ADR002-TR-4 into EP-M5, duplicating a later roadmap item. The same reasoning + that correctly refuses to pre-empt 1.3.3 applies here. ADR 002, the suite + design cross-reference, the repository layout, and the roadmap tick stay in + EP-M5. + Date/Author: 2026-08-21, planning agent, on reviewer finding. + +- **D-11**: cut the Verus sidecar, the permutation property test, the BDD + feature file, and the `googletest`/`pretty_assertions` dependencies. + Rationale: the pigeonhole argument makes the 121-case enumeration a total + decision procedure, so the Verus lemma would restate a decidable property — + which `AGENTS.md` forbids. `make verus` runs in no CI workflow (S-5) and no + proof file has ever been maintained (S-6). The seven BDD scenarios restate + the `insta` snapshots with no stakeholder who reads `.feature` files but not + `#[expect(...)]`, and `rstest-bdd-macros` is documented in + `.config/nextest.toml` as hanging during dependency resolution on Windows CI. + `googletest` and `pretty_assertions` appear in none of the repository's 20-plus + crates; adding two assertion libraries for one crate forks the testing dialect + and is its own decision, not a side-effect of this one. Net effect: three + fewer verification layers, no loss of coverage. + Date/Author: 2026-08-21, planning agent, on reviewer findings. + +- **D-12**: hold `syn::LitStr` in `LibraryName` and `Reason` rather than + `String`, and give `LibraryName` a validating constructor. + Rationale: the earlier draft's newtypes carried no invariant and discarded the + span. Rejecting an empty or hyphenated library name closes the one route to a + silent no-op that the macro can close unaided, and keeping the span is what + makes the diagnostics anchorable. + Date/Author: 2026-08-21, planning agent, on reviewer finding. + +- **D-13**: do not fix the aggregated-suite lint-level bug (R-1b) inside this + plan. + Rationale: the fix touches `suite/src/driver.rs` wiring, which `Constraints` + places out of scope, and its blast radius covers every lint in the suite + rather than anything this plan adds. It also needs its own regression + coverage — a UI fixture per lint proving that `#[allow]` and `#[expect]` + suppress under the aggregated library, which is exactly the coverage roadmap + 1.3.3 was scoped to build. Recorded in ADR 002 §Known risks, tracked as + separate work, and gating ADR 002 migration phase 3 rather than 1.3.1 + delivery. Attempting it here would silently double the plan's scope and blur + which change caused which regression. + Date/Author: 2026-08-21, planning agent. + +- **D-14**: keep `lib = "whitaker_suite"` as the canonical documented example + despite R-1b. + Rationale: it is the correct value for the shipping configuration, and will + be correct once R-1b is fixed. Documenting the individual-library form + instead would optimize the examples for a bug. R-1b is disclosed in ADR 002 + §Known risks so nobody adopts the attribute expecting it to work today. + Date/Author: 2026-08-21, planning agent. + +## Outcomes & retrospective + +To be completed at EP-M5. Before setting this plan to `COMPLETE`, reconcile +every discovery against ADR 002: + +- S-1, S-2, and S-4 require ADR 002 amendments and cannot be recorded as + mechanical differences. +- R-2's surviving silent-no-op routes must appear in ADR 002 §Known risks and + be booked as roadmap 1.3.5. +- R-3's deployment-mode problem must appear in ADR 002 §Outstanding decisions + with the reserved `lib(...)` extension. +- S-3 must be raised as separate follow-up work against + `crates/whitaker_test_macros`. + +Do not mark this plan `COMPLETE` while any upstream change or deviation remains +unrecorded or unaccepted. + +## Signposts + +Documentation to read before starting: + +- `docs/adr-002-dylint-expect-attribute-macro.md` — the governing decision, + pending the D-9 amendment. +- `docs/whitaker-dylint-suite-design.md` — how the suite is assembled and where + support crates sit. +- `docs/repository-layout.md` — the directory map. +- `docs/documentation-style-guide.md` — ADR section requirements, sentence-case + headings, 80-column prose, table and figure captions, en-GB-oxendict spelling. +- `docs/developers-guide.md` §Creating a New Lint — relevant if D-9(c) is + chosen, or for the 1.3.5 lint. +- `docs/rust-testing-with-rstest-fixtures.md` — fixture and parameterization + conventions. +- `docs/rust-doctest-dry-guide.md` — keeping the rustdoc examples `AGENTS.md` + requires from duplicating test logic. +- `docs/complexity-antipatterns-and-refactoring-strategies.md` — the standard + the 400-line and small-function rules serve. +- `AGENTS.md` — the binding style, testing, dependency, and commit rules. + +Skills to load before starting: + +- `leta` — semantic navigation; load at session start and prefer it to text + search for symbol lookup. +- `rust-router` — routes to the narrower Rust skills; load first and follow. +- `arch-crate-design` — crate boundaries, `publish` decisions, and public + versus internal API shape. +- `rust-unit-testing` — `rstest` parameterization and `insta` snapshot + discipline. +- `proptest` — strategy design and shrinking for INV-EXP-3. +- `arch-decision-records` — for the ADR 002 amendment at EP-M5. +- `nextest` — filtersets for the focused commands, and R-8's group interaction. +- `execplans` — for keeping this document current. +- `commit-message` — file-based commit messages, never `-m`. + +The `verus`, `kani`, and `hexagonal-architecture` skills were loaded during +planning and informed D-11 and the boundary discussion in `Interfaces and +dependencies`. They are **not** needed during implementation: there is no +proof obligation and no port to invert. + +Sub-agents to use: + +- `scrutineer` — the exclusive runner of full commit gates. Read its cited log + rather than re-running a gate. +- `scribe` — the documentation edits at EP-M5. +- `wyvern` — read-only reconnaissance when a file's shape is unclear. +- `alchemist` — only for a single falsifiable hypothesis with a supplied + prediction and minimal experiment. + +## Revision note + +**Revision 2, 2026-08-21.** Status moved from `DRAFT` to `BLOCKED`. + +What changed. EP-M0's probes were run during planning rather than deferred, and +falsified a premise of ADR 002: the mandated expansion does not suppress +`unexpected_cfgs`, verified end-to-end with a real proc macro (S-1, probe 3e). +D-9 records the resulting proposed deviation with three options and a +recommendation. A six-lens design review then drove eleven further changes: the +verification apparatus was cut by three layers on a pigeonhole argument that +makes the enumeration total (D-11); the module layout was flattened after a +misread of `clippy::self_named_module_files`; `args/grammar` was renamed `keys` +because it never covered arity; `ArgShapeError` now carries position so +diagnostics can be anchored; the newtypes now hold `LitStr` and enforce an +invariant (D-12); `syn` lost the `full` feature that would have leaked to every +downstream consumer; the publish step was reordered and guarded (R-4); the +non-existent `whitaker_lints` library name was corrected throughout (S-4); +INV-DIAG-1's non-vacuity control was replaced because the original was itself +vacuous; the fixture inventory grew from ten to eighteen; and the guide +narratives were deferred to roadmap 1.3.4 (D-10). + +Why. The plan's own prototyping milestone did its job. Acting on its findings +rather than proceeding around them is the point of having the milestone, and +the ExecPlan standard requires a deviation to be recorded and accepted rather +than absorbed. + +Effect on remaining work. No implementation may begin until D-9 is resolved. +Under the recommended option (b) the plan is ready to execute as written; under +(a) the `Constraints` expansion clause and INV-EXP-2 revert to ADR 002's +four-attribute set; under (c) roadmap items 1.3.1–1.3.4 are superseded and this +plan is withdrawn. R-1 remains the one open empirical question under every +option, and must be answered before EP-M2 closes. + +**Revision 3, 2026-08-21.** Status moved from `BLOCKED` to `APPROVED`. + +What changed. The repository owner accepted deviation D-9 as option (b), so +ADR 002 was amended — §Status to `Accepted`, plus corrections to §Decision +drivers, §Technical requirements, §Options considered (Option D's rejection +rationale and two rows of Table 1), §Decision outcome, §Known risks, and +§Outstanding decisions — and the non-existent `whitaker_lints` library name was +corrected there too. + +A spike then settled R-1, the last open empirical question, and was discarded +as instructed. `#[expect(...)]` **does** work for Dylint-registered Whitaker +lints, so axiom A-4 is established and R-1 is closed. The spike also surfaced +R-1b: the aggregated `whitaker_suite` library ignores lint-level attributes +entirely and emits spurious unfulfilled-expectation warnings, while individual +lint libraries built from the same commit behave correctly. Staleness, +lint-identity mismatch, and `cfg_attr` interaction were each ruled out by +controlled comparison. + +Why it matters, and why it does not block. R-1b means no attribute-based +suppression works today in the configuration the macro targets, which is what +every installed consumer loads. It is disclosed in ADR 002 §Known risks and +tracked as D-13. It does not block 1.3.1, whose obligations are all +token-level, but it does gate ADR 002 migration phase 3 — adopting the +attribute across the estate before the fix would fill the estate with +annotations that suppress nothing. + +Effect on remaining work. Implementation may begin at EP-M1. The one +outstanding EP-M0 item is committing the probes as a script with asserted exit +codes, so INV-WARN-1's negative control survives as a re-runnable artefact +rather than a transcript in this document. diff --git a/docs/execplans/2-2-5-test-must-not-have-example-lint.md b/docs/execplans/2-2-5-test-must-not-have-example-lint.md index a85ff270..a6c6e7e2 100644 --- a/docs/execplans/2-2-5-test-must-not-have-example-lint.md +++ b/docs/execplans/2-2-5-test-must-not-have-example-lint.md @@ -211,7 +211,7 @@ Current repository state relevant to this task: - Existing reusable detection helpers already exist: - `common::context::{is_test_fn_with, in_test_like_context_with}`; - `crates/no_expect_outside_tests/src/context.rs` for HIR ancestor collection - and `cfg(test)` handling (`collect_context`, `summarise_context`). + and `cfg(test)` handling (`collect_context`, `summarize_context`). - Existing BDD tests use `rstest_bdd_macros::{given, when, then, scenario}` and are executed under `cargo test`. diff --git a/docs/execplans/3-4-4-install-prebuilt-artefacts.md b/docs/execplans/3-4-4-install-prebuilt-artefacts.md index f3252a24..568a849f 100644 --- a/docs/execplans/3-4-4-install-prebuilt-artefacts.md +++ b/docs/execplans/3-4-4-install-prebuilt-artefacts.md @@ -558,7 +558,7 @@ All phases produce additive changes. If a phase fails partway through, fix the issue and re-run the quality gates. The `tempfile` crate ensures downloaded archives are cleaned up on failure. -## Artifacts and notes +## Artefacts and notes Key files created: diff --git a/docs/execplans/3.4.6. Record download-versus-build rates.md b/docs/execplans/3.4.6. Record download-versus-build rates.md index 05af2d05..2c300c78 100644 --- a/docs/execplans/3.4.6. Record download-versus-build rates.md +++ b/docs/execplans/3.4.6. Record download-versus-build rates.md @@ -356,7 +356,7 @@ Expected observable checks after implementation: - If metrics write fails, continue installation and log a warning; retry on next installation. -## Artifacts and notes +## Artefacts and notes During implementation, capture concise evidence snippets in this section: diff --git a/docs/execplans/7-1-1-whitaker-sarif-crate.md b/docs/execplans/7-1-1-whitaker-sarif-crate.md index ac6ed966..44cd6e7c 100644 --- a/docs/execplans/7-1-1-whitaker-sarif-crate.md +++ b/docs/execplans/7-1-1-whitaker-sarif-crate.md @@ -263,9 +263,9 @@ field. Convenience type alias `Result`. ### Stage D: Implement SARIF model types Split into `model/` directory: `mod.rs`, `log.rs` (`SarifLog`), `run.rs` (`Run`, -`Tool`, `ToolComponent`, `Invocation`, `Artifact`), `result.rs` (`SarifResult`, +`Tool`, `ToolComponent`, `Invocation`, `Artefact`), `result.rs` (`SarifResult`, `Level`, `Message`), `location.rs` (`Location`, `PhysicalLocation`, -`ArtifactLocation`, `Region`, `RelatedLocation`), `descriptor.rs` +`ArtefactLocation`, `Region`, `RelatedLocation`), `descriptor.rs` (`ReportingDescriptor`, `MultiformatMessageString`). All types derive `Debug`, `Clone`, `PartialEq`, `Serialize`, `Deserialize` with `#[serde(rename_all = "camelCase")]`. diff --git a/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md b/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md index d72b8581..8477738e 100644 --- a/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md +++ b/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md @@ -679,7 +679,7 @@ If a quality gate fails because of unrelated main-branch drift or another agent's changes, stop, record the evidence in this plan, and ask for direction. Do not revert unrelated work. -## Artifacts and notes +## Artefacts and notes Wyvern planning agents reported three useful facts: diff --git a/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md b/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md index ae2e52dd..bf8e223f 100644 --- a/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md +++ b/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md @@ -77,7 +77,7 @@ escalation, not a workaround. bare `ra_ap_syntax::`/`rowan::` path appears outside comments, with the forbidden-crate list as a `const`. - **No persisted `KindId` from 7.3.1.** Only `AstHash` (which is seeded with - `PARSER_SCHEMA_VERSION`) is hashable/serialisable in this item. `KindId` is + `PARSER_SCHEMA_VERSION`) is hashable/serializable in this item. `KindId` is an in-memory opaque token and must not be persisted, so a future cache (7.6.x) cannot accidentally compare raw discriminants across parser pins. - **Bounded per-candidate cost.** Lowering touches one candidate subtree; the @@ -617,7 +617,7 @@ Decisions already taken while drafting this plan: corruption, no crash). Seeding makes every hash change on a bump, so any cross-pin cache compare fails closed. An `insta` snapshot of `PARSER_SCHEMA_VERSION` forces any bump to be reviewed. `KindId` itself is - **not** persisted by 7.3.1 (only `AstHash` is hashable/serialisable here); + **not** persisted by 7.3.1 (only `AstHash` is hashable/serializable here); this is stated as a Constraint so 7.6.x inherits the rule. Date/Author: 2026-06-09, Doggylump (review panel). - Decision: **Promote the FNV-1a constants and byte-mixing step from diff --git a/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md b/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md index b168c6d5..770a2066 100644 --- a/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md +++ b/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md @@ -730,7 +730,7 @@ If validation fails, inspect the matching changing code. Record persistent failures in `Surprises & Discoveries` or `Decision Log` with the command and log path. -## Artifacts and notes +## Artefacts and notes Wyvern repository-pattern findings: diff --git a/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md b/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md index 84b0648d..a0ae48ce 100644 --- a/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md +++ b/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md @@ -892,13 +892,13 @@ If validation fails, inspect the matching changing code. Record persistent failures under "Surprises & discoveries" or "Decision log" with the command and log path. -## Artifacts and notes +## Artefacts and notes Wyvern repository-pattern findings (summary): ```plaintext - No existing Whitaker lint uses check_crate_post. The current lints - initialise per-crate config in check_crate and emit diagnostics + initialize per-crate config in check_crate and emit diagnostics expression-locally through check_expr / check_fn / check_item / check_impl_item. - Callee resolution is consistently done via cx.qpath_res for Call and diff --git a/docs/execplans/installer-does-not-install-toolchain.md b/docs/execplans/installer-does-not-install-toolchain.md index fc33b4e2..2c0ed377 100644 --- a/docs/execplans/installer-does-not-install-toolchain.md +++ b/docs/execplans/installer-does-not-install-toolchain.md @@ -186,7 +186,7 @@ already installed, no rustup install command should run. If rustup fails, the installer should return an error and can be re-run once the environment recovers. -## Artifacts and Notes +## Artefacts and Notes Example log (expected after change): diff --git a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md index a0df36c8..0e6eef52 100644 --- a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md +++ b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md @@ -216,7 +216,7 @@ Edits are safe to re-run. If a step fails, revert file changes with Git and re-apply. Validation commands are safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artifacts and Notes +## Artefacts and Notes Keep the validation logs from `/tmp/whitaker-*.log` as evidence for each quality gate. Record any output from `cargo dylint list` that shows the updated diff --git a/docs/execplans/issue-94-dual-ownership-of-binary.md b/docs/execplans/issue-94-dual-ownership-of-binary.md index 158b01e3..0c1b8b19 100644 --- a/docs/execplans/issue-94-dual-ownership-of-binary.md +++ b/docs/execplans/issue-94-dual-ownership-of-binary.md @@ -186,7 +186,7 @@ Edits are safe to re-run. If a step fails, revert the file changes with Git and re-apply. The `install-smoke` target uses a temporary directory and is safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artifacts and Notes +## Artefacts and Notes Capture key outputs in the log files listed in Concrete Steps. These logs are sufficient evidence for validation and troubleshooting. diff --git a/docs/local-validation-of-github-actions-with-act-and-pytest.md b/docs/local-validation-of-github-actions-with-act-and-pytest.md index 0b6494ad..be7114f6 100644 --- a/docs/local-validation-of-github-actions-with-act-and-pytest.md +++ b/docs/local-validation-of-github-actions-with-act-and-pytest.md @@ -121,9 +121,9 @@ def run_act( job: str = "selftest", event_path: Path = EVENT, *, - artifact_dir: Path, + artefact_dir: Path, ) -> tuple[int, Path, str]: - artifact_dir.mkdir(parents=True, exist_ok=True) + artefact_dir.mkdir(parents=True, exist_ok=True) cmd = [ "act", "pull_request", @@ -134,18 +134,18 @@ def run_act( "-P", "ubuntu-latest=catthehacker/ubuntu:act-latest", "--artifact-server-path", - str(artifact_dir), + str(artefact_dir), "--json", # machine-parseable log stream "-b", # bind-mount repo as workspace (preserves side effects) ] completed = subprocess.run(cmd, text=True, capture_output=True) logs = completed.stdout + "\n" + completed.stderr - return completed.returncode, artifact_dir, logs + return completed.returncode, artefact_dir, logs def test_workflow_produces_expected_artefact_and_logs(tmp_path: Path) -> None: - artifact_dir = tmp_path / "act-artifacts" - code, artdir, logs = run_act(artifact_dir=artifact_dir) + artefact_dir = tmp_path / "act-artifacts" + code, artdir, logs = run_act(artefact_dir=artefact_dir) assert code == 0, f"act failed:\n{logs}" # Assert artefact presence and contents @@ -214,11 +214,11 @@ loop: from cmd_mox import CmdMox def test_record(tmp_path: Path) -> None: - artifact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artifacts" with CmdMox() as mox: gh = mox.spy("gh").passthrough() mox.replay() - code, _, logs = run_act(artifact_dir=artifact_dir) + code, _, logs = run_act(artefact_dir=artefact_dir) assert code == 0, logs mox.verify() assert gh.call_count == 1 @@ -229,7 +229,7 @@ loop: ```python def test_replay(tmp_path: Path, cmd_mox) -> None: - artifact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artifacts" cmd_mox.mock("gh").with_args( "release", "view", @@ -237,7 +237,7 @@ loop: "tagName", ).returns(stdout='{"tagName":"v9.9.9"}\n') cmd_mox.replay() - code, _, logs = run_act(artifact_dir=artifact_dir) + code, _, logs = run_act(artefact_dir=artefact_dir) assert code == 0, logs cmd_mox.verify() ``` diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 67db8409..9e2ad2c7 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -988,7 +988,7 @@ assert_eq!(row_number, 2); assert_eq!(column_index, 2); assert!(err .to_string() - .contains("unrecognised boolean value 'maybe'")); + .contains("unrecognized boolean value 'maybe'")); ``` [`DataTableError`]: crate::datatable::DataTableError diff --git a/docs/scripting-standards.md b/docs/scripting-standards.md index 288fc5ff..15b042b0 100644 --- a/docs/scripting-standards.md +++ b/docs/scripting-standards.md @@ -241,7 +241,7 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] DIST = PROJECT_ROOT / "dist" -(DIST / "artifacts").mkdir(parents=True, exist_ok=True) +(DIST / "artefacts").mkdir(parents=True, exist_ok=True) # Portable joins and normalization cfg = PROJECT_ROOT.joinpath("config", "release.toml").resolve() diff --git a/docs/whitaker-clone-detector-design.md b/docs/whitaker-clone-detector-design.md index fd05d25f..3fb1366a 100644 --- a/docs/whitaker-clone-detector-design.md +++ b/docs/whitaker-clone-detector-design.md @@ -61,7 +61,7 @@ passes. Use `runs[0]` for the token pass and `runs[1]` for the AST pass. Each - `serde` models of SARIF 2.1.0 (subset plus extensions) with `From` and `Into` helpers. -- Helpers to build rules, results, locations, artifacts, and invocations. +- Helpers to build rules, results, locations, artefacts, and invocations. - Stable file layout: `target/whitaker/clones.{pass}.sarif` and `target/whitaker/clones.refined.sarif`. - Merge logic: combine runs and deduplicate results by diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 6745627d..af0e75b0 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -1237,7 +1237,7 @@ and predicate complexity. Collect segments `(start_line, end_line, value)` using `SourceMap` mapping and accumulate contributions with weights (`wD = 1.0`, `wP = 0.5`, `wK = 0.5`). -Rasterise once per function to produce a per-line signal `C[line]` representing +Rasterize once per function to produce a per-line signal `C[line]` representing local complexity. ```rust @@ -1295,7 +1295,7 @@ highlight the top two intervals in the diagnostic. 1. Walk the function HIR, updating depth and collecting segments for blocks, branches, and predicate spans. -2. Rasterise segments to per-line values, then smooth with the configured +2. Rasterize segments to per-line values, then smooth with the configured window. 3. Detect bumps where the smoothed value meets or exceeds `threshold`. 4. Emit a diagnostic on the function name span when bumps ≥ 2, attaching labels @@ -1321,7 +1321,7 @@ functions typically remain below the threshold after smoothing. Deep single nests fall under other lints such as `excessive_nesting`. **Performance.** The pass is linear in the size of each function’s HIR. Segment -rasterisation touches at most the number of lines in the function, keeping the +rasterization touches at most the number of lines in the function, keeping the overhead negligible for typical Rust code. **Test plan.** Provide UI cases covering two separated nested blocks, @@ -1366,7 +1366,7 @@ function and is suitable as a stable Dylint rule in the default suite. ### Phase 0 — Repo scaffolding -- Initialise workspace +- Initialize workspace - Create `Cargo.toml` with `[workspace]`, resolver = 2, members = `crates/*`, `common`, `suite`, `installer`. - Add `rust-toolchain.toml` (pin nightly) and `rustfmt.toml`. @@ -1660,7 +1660,7 @@ artefacts remain optional. The decision is recorded in `docs/adr-001-prebuilt-dylint-libraries.md` (Accepted 2026-02-03). **Implementation decision (2026-02-18):** The installer now derives prebuilt -extraction paths from `BaseDirs::whitaker_data_dir()` and writes libraries to +extraction paths from `BaseDirs::whitaker_data()` and writes libraries to `/lints///lib`. Wrapper scripts and shell snippets use that exact `lib` directory for `DYLINT_LIBRARY_PATH`. Local build staging continues to honour `--target-dir` and existing release-layout behaviour. diff --git a/dylint.toml b/dylint.toml index cd9c0972..1b3a1a14 100644 --- a/dylint.toml +++ b/dylint.toml @@ -12,4 +12,41 @@ # 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. -excluded_crates = ["whitaker_installer", "whitaker_common", "whitaker"] +# +# Integration-test targets compile as their own crates named after the test +# file, so they are not covered by the `whitaker_common` entry above. +# `i18n_packaging` (`common/tests/i18n_packaging.rs`) drives `cargo package` +# and inspects the resulting `target/package` directory; both are ambient by +# construction and cannot be scoped to a `cap_std` root. +# +# The installer's packaging binaries (`whitaker-package-lints`, +# `whitaker-package-installer`, `whitaker-package-dependency-binary`) are +# separate crates from `whitaker_installer`. They write release archives, +# checksums, and manifests to output directories supplied by Cargo or CI, so +# their roots are ambient by construction and cannot be scoped to a `cap_std` +# root. +# +# The installer's behavioural integration tests each compile as their own crate +# named after the test file. They stage fixture trees, permission-denied +# directories, and packaged archives at ambient temporary paths, and several +# read repository documentation relative to `CARGO_MANIFEST_DIR`. Migrating +# them to `cap_std` would mean re-rooting fixtures the code under test then +# reopens by absolute path, so they are excluded rather than half-migrated. +excluded_crates = [ + "whitaker_installer", + "whitaker_common", + "whitaker", + "i18n_packaging", + "whitaker_package_lints", + "whitaker_package_installer", + "whitaker_package_dependency_binary", + "behaviour_artefact_packaging", + "behaviour_cli", + "behaviour_docs", + "behaviour_install_metrics", + "behaviour_installer_release", + "behaviour_prebuilt", + "behaviour_staging", + "behaviour_toolchain", + "behaviour_workflows", +] diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 6d514e9e..c6e4107a 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -78,7 +78,8 @@ zip = { workspace = true } zstd = { workspace = true } [dev-dependencies] -libc = { workspace = true } +whitaker_test_macros = { workspace = true } +rustix = { version = "1.1.4", features = ["process"] } mockall = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/installer/src/artefact/download.rs b/installer/src/artefact/download.rs index e25b8781..a412142b 100644 --- a/installer/src/artefact/download.rs +++ b/installer/src/artefact/download.rs @@ -4,9 +4,7 @@ //! and manifests from the GitHub rolling release, enabling dependency //! injection for testing. -use std::path::Path; -use std::sync::OnceLock; -use std::time::Duration; +use std::{path::Path, sync::OnceLock, time::Duration}; /// The GitHub repository owner/name for URL construction. const GITHUB_REPO: &str = "leynos/whitaker"; @@ -157,9 +155,10 @@ fn map_ureq_error(url: &str, err: &ureq::Error) -> DownloadError { mod tests { //! Tests for artefact download URL and error mapping behaviour. - use super::*; use rstest::rstest; + use super::*; + #[test] fn asset_url_contains_repo_and_tag() { let url = HttpDownloader::asset_url("test.tar.zst"); diff --git a/installer/src/artefact/extraction.rs b/installer/src/artefact/extraction.rs index fdb6f812..e4a03e82 100644 --- a/installer/src/artefact/extraction.rs +++ b/installer/src/artefact/extraction.rs @@ -113,10 +113,14 @@ fn validate_entry_path(path: &Path) -> Result<(), ExtractionError> { #[cfg(test)] mod tests { - use super::*; - use rstest::rstest; + //! Tests for artefact archive extraction. + use std::path::PathBuf; + use rstest::rstest; + + use super::*; + #[test] fn extract_real_archive() { // Create a temp archive with a single file, then extract it. @@ -137,8 +141,8 @@ mod tests { builder .append_path_with_name(&source_file, "hello.txt") .expect("append"); - let encoder = builder.into_inner().expect("tar finish"); - encoder.finish().expect("zstd finish"); + let finished_encoder = builder.into_inner().expect("tar finish"); + finished_encoder.finish().expect("zstd finish"); let extractor = ZstdExtractor; let files = extractor @@ -184,8 +188,8 @@ mod tests { let output_file = std::fs::File::create(&archive_path).expect("create"); let encoder = zstd::Encoder::new(output_file, 0).expect("zstd"); let builder = tar::Builder::new(encoder); - let encoder = builder.into_inner().expect("tar finish"); - encoder.finish().expect("zstd finish"); + let finished_encoder = builder.into_inner().expect("tar finish"); + finished_encoder.finish().expect("zstd finish"); let extractor = ZstdExtractor; let result = extractor.extract(&archive_path, &dest_dir); diff --git a/installer/src/artefact/git_sha.rs b/installer/src/artefact/git_sha.rs index 4bc775b5..ea81112e 100644 --- a/installer/src/artefact/git_sha.rs +++ b/installer/src/artefact/git_sha.rs @@ -4,10 +4,12 @@ //! of 7–40 characters, matching the range of abbreviated to full git //! object names. -use super::error::{ArtefactError, Result}; -use serde::Serialize; use std::fmt; +use serde::Serialize; + +use super::error::{ArtefactError, Result}; + /// Minimum length of an abbreviated git SHA (7 hex characters). const MIN_LEN: usize = 7; @@ -40,9 +42,7 @@ impl GitSha { /// assert_eq!(sha.as_str(), "abc1234"); /// ``` #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper and return the inner string. /// @@ -55,9 +55,7 @@ impl GitSha { /// assert_eq!(sha.into_inner(), "abc1234"); /// ``` #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl TryFrom<&str> for GitSha { @@ -80,15 +78,11 @@ impl TryFrom for GitSha { } impl AsRef for GitSha { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for GitSha { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl<'de> serde::Deserialize<'de> for GitSha { @@ -131,10 +125,10 @@ fn validate_git_sha(value: &str) -> Result<()> { .chars() .find(|c| !c.is_ascii_hexdigit() || c.is_ascii_uppercase()) { - let reason = if !bad.is_ascii_hexdigit() { - format!("non-hex character '{bad}'") - } else { + let reason = if bad.is_ascii_hexdigit() { "SHA must be lowercase".to_owned() + } else { + format!("non-hex character '{bad}'") }; return Err(ArtefactError::InvalidGitSha { value: value.to_owned(), @@ -146,9 +140,12 @@ fn validate_git_sha(value: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::*; + //! Tests for git SHA parsing and validation. + use rstest::rstest; + use super::*; + #[test] fn accepts_seven_char_abbreviated_sha() { let sha = GitSha::try_from("abc1234"); diff --git a/installer/src/artefact/manifest.rs b/installer/src/artefact/manifest.rs index 2a1c2ea3..936e6811 100644 --- a/installer/src/artefact/manifest.rs +++ b/installer/src/artefact/manifest.rs @@ -4,14 +4,18 @@ //! archive ships a `manifest.json` capturing provenance, content listing, //! and the archive checksum. -use super::git_sha::GitSha; -use super::schema_version::SchemaVersion; -use super::sha256_digest::Sha256Digest; -use super::target::TargetTriple; -use super::toolchain_channel::ToolchainChannel; -use serde::{Deserialize, Serialize}; use std::fmt; +use serde::{Deserialize, Serialize}; + +use super::{ + git_sha::GitSha, + schema_version::SchemaVersion, + sha256_digest::Sha256Digest, + target::TargetTriple, + toolchain_channel::ToolchainChannel, +}; + /// Provenance fields that identify an artefact build. /// /// Groups the identity components (git SHA, schema version, toolchain, and @@ -62,28 +66,26 @@ pub struct ManifestContent { /// # Examples /// /// ``` -/// use whitaker_installer::artefact::manifest::{ -/// GeneratedAt, Manifest, ManifestContent, ManifestProvenance, +/// use whitaker_installer::artefact::{ +/// git_sha::GitSha, +/// manifest::{GeneratedAt, Manifest, ManifestContent, ManifestProvenance}, +/// schema_version::SchemaVersion, +/// sha256_digest::Sha256Digest, +/// target::TargetTriple, +/// toolchain_channel::ToolchainChannel, /// }; -/// use whitaker_installer::artefact::git_sha::GitSha; -/// use whitaker_installer::artefact::schema_version::SchemaVersion; -/// use whitaker_installer::artefact::sha256_digest::Sha256Digest; -/// use whitaker_installer::artefact::target::TargetTriple; -/// use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; /// /// let provenance = ManifestProvenance { /// git_sha: GitSha::try_from("abc1234").expect("valid git SHA"), /// schema_version: SchemaVersion::current(), /// toolchain: ToolchainChannel::try_from("nightly-2026-05-28") /// .expect("valid toolchain channel"), -/// target: TargetTriple::try_from("x86_64-unknown-linux-gnu") -/// .expect("valid target triple"), +/// target: TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target triple"), /// }; /// let content = ManifestContent { /// generated_at: GeneratedAt::new("2026-05-28T00:00:00Z"), /// files: vec!["libwhitaker_lints.so".to_owned()], -/// sha256: Sha256Digest::try_from("a".repeat(64).as_str()) -/// .expect("valid SHA-256 digest"), +/// sha256: Sha256Digest::try_from("a".repeat(64).as_str()).expect("valid SHA-256 digest"), /// }; /// let manifest = Manifest::new(provenance, content); /// assert_eq!(manifest.git_sha().as_str(), "abc1234"); @@ -117,9 +119,7 @@ impl GeneratedAt { /// assert_eq!(ts.as_str(), "2026-05-28T00:00:00Z"); /// ``` #[must_use] - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } + pub fn new(value: impl Into) -> Self { Self(value.into()) } /// Return the timestamp as a string slice. /// @@ -132,15 +132,11 @@ impl GeneratedAt { /// assert_eq!(ts.as_str(), "2026-05-28T00:00:00Z"); /// ``` #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for GeneratedAt { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// Helper macro for manifest doc examples — constructs a sample @@ -151,14 +147,14 @@ impl fmt::Display for GeneratedAt { #[macro_export] macro_rules! _manifest_doc_setup { ($manifest:ident) => { - use whitaker_installer::artefact::git_sha::GitSha; - use whitaker_installer::artefact::manifest::{ - GeneratedAt, Manifest, ManifestContent, ManifestProvenance, + use whitaker_installer::artefact::{ + git_sha::GitSha, + manifest::{GeneratedAt, Manifest, ManifestContent, ManifestProvenance}, + schema_version::SchemaVersion, + sha256_digest::Sha256Digest, + target::TargetTriple, + toolchain_channel::ToolchainChannel, }; - use whitaker_installer::artefact::schema_version::SchemaVersion; - use whitaker_installer::artefact::sha256_digest::Sha256Digest; - use whitaker_installer::artefact::target::TargetTriple; - use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; let provenance = ManifestProvenance { git_sha: GitSha::try_from("abc1234").expect("valid git SHA"), @@ -187,7 +183,7 @@ impl Manifest { /// assert_eq!(manifest.git_sha().as_str(), "abc1234"); /// ``` #[must_use] - pub fn new(provenance: ManifestProvenance, content: ManifestContent) -> Self { + pub const fn new(provenance: ManifestProvenance, content: ManifestContent) -> Self { Self { provenance, content, @@ -203,9 +199,7 @@ impl Manifest { /// assert_eq!(manifest.git_sha().as_str(), "abc1234"); /// ``` #[must_use] - pub fn git_sha(&self) -> &GitSha { - &self.provenance.git_sha - } + pub const fn git_sha(&self) -> &GitSha { &self.provenance.git_sha } /// Return the schema version. /// @@ -216,9 +210,7 @@ impl Manifest { /// assert_eq!(u32::from(manifest.schema_version()), 1); /// ``` #[must_use] - pub fn schema_version(&self) -> SchemaVersion { - self.provenance.schema_version - } + pub const fn schema_version(&self) -> SchemaVersion { self.provenance.schema_version } /// Return the toolchain channel. /// @@ -229,9 +221,7 @@ impl Manifest { /// assert_eq!(manifest.toolchain().as_str(), "nightly-2026-05-28"); /// ``` #[must_use] - pub fn toolchain(&self) -> &ToolchainChannel { - &self.provenance.toolchain - } + pub const fn toolchain(&self) -> &ToolchainChannel { &self.provenance.toolchain } /// Return the target triple. /// @@ -242,9 +232,7 @@ impl Manifest { /// assert_eq!(manifest.target().as_str(), "x86_64-unknown-linux-gnu"); /// ``` #[must_use] - pub fn target(&self) -> &TargetTriple { - &self.provenance.target - } + pub const fn target(&self) -> &TargetTriple { &self.provenance.target } /// Return the build timestamp. /// @@ -255,9 +243,7 @@ impl Manifest { /// assert_eq!(manifest.generated_at().as_str(), "2026-05-28T00:00:00Z"); /// ``` #[must_use] - pub fn generated_at(&self) -> &GeneratedAt { - &self.content.generated_at - } + pub const fn generated_at(&self) -> &GeneratedAt { &self.content.generated_at } /// Return the list of files in the archive. /// @@ -268,9 +254,7 @@ impl Manifest { /// assert_eq!(manifest.files(), &["libwhitaker_lints.so"]); /// ``` #[must_use] - pub fn files(&self) -> &[String] { - &self.content.files - } + pub fn files(&self) -> &[String] { &self.content.files } /// Return the SHA-256 digest of the archive. /// @@ -281,9 +265,7 @@ impl Manifest { /// assert_eq!(manifest.sha256().as_str().len(), 64); /// ``` #[must_use] - pub fn sha256(&self) -> &Sha256Digest { - &self.content.sha256 - } + pub const fn sha256(&self) -> &Sha256Digest { &self.content.sha256 } } #[cfg(test)] diff --git a/installer/src/artefact/manifest_parser.rs b/installer/src/artefact/manifest_parser.rs index 1adcc02d..9c2a9faf 100644 --- a/installer/src/artefact/manifest_parser.rs +++ b/installer/src/artefact/manifest_parser.rs @@ -48,9 +48,12 @@ pub fn parse_manifest(json: &str) -> Result { #[cfg(test)] mod tests { - use super::*; + //! Tests for artefact manifest parsing. + use rstest::rstest; + use super::*; + fn valid_manifest_json() -> String { concat!( r#"{"git_sha":"abc1234","schema_version":1,"#, diff --git a/installer/src/artefact/manifest_tests.rs b/installer/src/artefact/manifest_tests.rs index 784f4d04..67a59757 100644 --- a/installer/src/artefact/manifest_tests.rs +++ b/installer/src/artefact/manifest_tests.rs @@ -1,51 +1,49 @@ //! Tests for manifest schema types. -use super::*; use rstest::{fixture, rstest}; use serde_json::Value; +use super::*; +use crate::artefact::error::ArtefactError; + #[fixture] -fn sample_provenance() -> ManifestProvenance { - ManifestProvenance { - git_sha: GitSha::try_from("abc1234").expect("valid sha"), +fn sample_provenance() -> Result { + Ok(ManifestProvenance { + git_sha: GitSha::try_from("abc1234")?, schema_version: SchemaVersion::current(), - toolchain: ToolchainChannel::try_from("nightly-2026-05-28").expect("valid channel"), - target: TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"), - } + toolchain: ToolchainChannel::try_from("nightly-2026-05-28")?, + target: TargetTriple::try_from("x86_64-unknown-linux-gnu")?, + }) } #[fixture] -fn sample_content() -> ManifestContent { - ManifestContent { +fn sample_content() -> Result { + Ok(ManifestContent { generated_at: GeneratedAt::new("2026-05-28T00:00:00Z"), files: vec!["libwhitaker_lints@nightly-2026-05-28-x86_64-unknown-linux-gnu.so".to_owned()], - sha256: Sha256Digest::try_from("a".repeat(64).as_str()).expect("valid digest"), - } + sha256: Sha256Digest::try_from("a".repeat(64).as_str())?, + }) } #[fixture] fn sample_manifest( - sample_provenance: ManifestProvenance, - sample_content: ManifestContent, -) -> Manifest { - Manifest::new(sample_provenance, sample_content) + sample_provenance: Result, + sample_content: Result, +) -> Result { + Ok(Manifest::new(sample_provenance?, sample_content?)) } #[rstest] -fn accessors_return_all_fields(sample_manifest: Manifest) { - assert_eq!(sample_manifest.git_sha().as_str(), "abc1234"); - assert_eq!(sample_manifest.schema_version().as_u32(), 1); - assert_eq!(sample_manifest.toolchain().as_str(), "nightly-2026-05-28"); - assert_eq!( - sample_manifest.target().as_str(), - "x86_64-unknown-linux-gnu" - ); - assert_eq!( - sample_manifest.generated_at().as_str(), - "2026-05-28T00:00:00Z" - ); - assert_eq!(sample_manifest.files().len(), 1); - assert_eq!(sample_manifest.sha256().as_str().len(), 64); +fn accessors_return_all_fields(sample_manifest: Result) { + let manifest = sample_manifest.expect("sample manifest should build"); + + assert_eq!(manifest.git_sha().as_str(), "abc1234"); + assert_eq!(manifest.schema_version().as_u32(), 1); + assert_eq!(manifest.toolchain().as_str(), "nightly-2026-05-28"); + assert_eq!(manifest.target().as_str(), "x86_64-unknown-linux-gnu"); + assert_eq!(manifest.generated_at().as_str(), "2026-05-28T00:00:00Z"); + assert_eq!(manifest.files().len(), 1); + assert_eq!(manifest.sha256().as_str().len(), 64); } #[rstest] @@ -55,8 +53,9 @@ fn generated_at_display() { } #[rstest] -fn serialized_json_contains_all_adr_001_keys(sample_manifest: Manifest) { - let json = serde_json::to_string(&sample_manifest).expect("serialization succeeds"); +fn serialized_json_contains_all_adr_001_keys(sample_manifest: Result) { + let manifest = sample_manifest.expect("sample manifest should build"); + let json = serde_json::to_string(&manifest).expect("serialization succeeds"); let parsed: Value = serde_json::from_str(&json).expect("valid JSON"); let obj = parsed.as_object().expect("top-level object"); @@ -110,10 +109,11 @@ fn manifest_with_multiple_files() { } #[rstest] -fn serde_round_trip(sample_manifest: Manifest) { - let json = serde_json::to_string_pretty(&sample_manifest).expect("serialize"); +fn serde_round_trip(sample_manifest: Result) { + let manifest = sample_manifest.expect("sample manifest should build"); + let json = serde_json::to_string_pretty(&manifest).expect("serialize"); let back: Manifest = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(sample_manifest, back); + assert_eq!(manifest, back); } #[rstest] diff --git a/installer/src/artefact/naming.rs b/installer/src/artefact/naming.rs index af6d94c2..f37bc7cd 100644 --- a/installer/src/artefact/naming.rs +++ b/installer/src/artefact/naming.rs @@ -3,11 +3,10 @@ //! Constructs deterministic archive names in the format specified by ADR-001: //! `whitaker-lints---.tar.zst`. -use super::git_sha::GitSha; -use super::target::TargetTriple; -use super::toolchain_channel::ToolchainChannel; use std::fmt; +use super::{git_sha::GitSha, target::TargetTriple, toolchain_channel::ToolchainChannel}; + /// The fixed prefix for all artefact archive names. const ARTEFACT_PREFIX: &str = "whitaker-lints"; @@ -22,10 +21,12 @@ const ARTEFACT_EXTENSION: &str = ".tar.zst"; /// # Examples /// /// ``` -/// use whitaker_installer::artefact::naming::ArtefactName; -/// use whitaker_installer::artefact::git_sha::GitSha; -/// use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; -/// use whitaker_installer::artefact::target::TargetTriple; +/// use whitaker_installer::artefact::{ +/// git_sha::GitSha, +/// naming::ArtefactName, +/// target::TargetTriple, +/// toolchain_channel::ToolchainChannel, +/// }; /// /// let sha: GitSha = "abc1234".try_into().expect("valid git SHA"); /// let toolchain: ToolchainChannel = "nightly-2026-05-28" @@ -51,7 +52,7 @@ pub struct ArtefactName { impl ArtefactName { /// Create an artefact name from validated components. #[must_use] - pub fn new(git_sha: GitSha, toolchain: ToolchainChannel, target: TargetTriple) -> Self { + pub const fn new(git_sha: GitSha, toolchain: ToolchainChannel, target: TargetTriple) -> Self { Self { git_sha, toolchain, @@ -61,27 +62,19 @@ impl ArtefactName { /// Return the git SHA component. #[must_use] - pub fn git_sha(&self) -> &GitSha { - &self.git_sha - } + pub const fn git_sha(&self) -> &GitSha { &self.git_sha } /// Return the toolchain channel component. #[must_use] - pub fn toolchain(&self) -> &ToolchainChannel { - &self.toolchain - } + pub const fn toolchain(&self) -> &ToolchainChannel { &self.toolchain } /// Return the target triple component. #[must_use] - pub fn target(&self) -> &TargetTriple { - &self.target - } + pub const fn target(&self) -> &TargetTriple { &self.target } /// Return the filename as a string without consuming the value. #[must_use] - pub fn filename(&self) -> String { - self.to_string() - } + pub fn filename(&self) -> String { self.to_string() } } impl fmt::Display for ArtefactName { @@ -96,22 +89,28 @@ impl fmt::Display for ArtefactName { #[cfg(test)] mod tests { - use super::*; + //! Tests for artefact release-asset naming. + use rstest::{fixture, rstest}; + use super::*; + use crate::artefact::error::ArtefactError; + #[fixture] - fn sample_name() -> ArtefactName { - ArtefactName::new( - GitSha::try_from("abc1234").expect("valid sha"), - ToolchainChannel::try_from("nightly-2026-05-28").expect("valid channel"), - TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"), - ) + fn sample_name() -> Result { + Ok(ArtefactName::new( + GitSha::try_from("abc1234")?, + ToolchainChannel::try_from("nightly-2026-05-28")?, + TargetTriple::try_from("x86_64-unknown-linux-gnu")?, + )) } #[rstest] - fn display_matches_adr_format(sample_name: ArtefactName) { + fn display_matches_adr_format(sample_name: Result) { + let name = sample_name.expect("sample artefact name should build"); + assert_eq!( - sample_name.to_string(), + name.to_string(), concat!( "whitaker-lints-abc1234-nightly-2026-05-28", "-x86_64-unknown-linux-gnu.tar.zst" @@ -120,15 +119,19 @@ mod tests { } #[rstest] - fn filename_matches_display(sample_name: ArtefactName) { - assert_eq!(sample_name.filename(), sample_name.to_string()); + fn filename_matches_display(sample_name: Result) { + let name = sample_name.expect("sample artefact name should build"); + + assert_eq!(name.filename(), name.to_string()); } #[rstest] - fn accessors_return_components(sample_name: ArtefactName) { - assert_eq!(sample_name.git_sha().as_str(), "abc1234"); - assert_eq!(sample_name.toolchain().as_str(), "nightly-2026-05-28"); - assert_eq!(sample_name.target().as_str(), "x86_64-unknown-linux-gnu"); + fn accessors_return_components(sample_name: Result) { + let name = sample_name.expect("sample artefact name should build"); + + assert_eq!(name.git_sha().as_str(), "abc1234"); + assert_eq!(name.toolchain().as_str(), "nightly-2026-05-28"); + assert_eq!(name.target().as_str(), "x86_64-unknown-linux-gnu"); } #[rstest] diff --git a/installer/src/artefact/packaging.rs b/installer/src/artefact/packaging.rs index b253e253..8dc96d7f 100644 --- a/installer/src/artefact/packaging.rs +++ b/installer/src/artefact/packaging.rs @@ -5,8 +5,8 @@ //! //! # Preconditions //! -//! - All library file paths in [`PackageParams::library_files`] must -//! exist on disk and have a filename component. +//! - All library file paths in [`PackageParams::library_files`] must exist on disk and have a +//! filename component. //! - The `output_dir` must exist and be writable. //! //! # Outputs and side effects @@ -27,19 +27,25 @@ //! `SHA-256(downloaded_archive)` and comparing against the `sha256` //! field from the manifest obtained via the release API. -use super::git_sha::GitSha; -use super::manifest::{GeneratedAt, Manifest, ManifestContent, ManifestProvenance}; -use super::naming::ArtefactName; -use super::packaging_error::PackagingError; -use super::schema_version::SchemaVersion; -use super::sha256_digest::Sha256Digest; -use super::target::TargetTriple; -use super::toolchain_channel::ToolchainChannel; -use crate::hex::to_lower_hex; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, +}; + use sha2::{Digest, Sha256}; -use std::fs; -use std::io::Read; -use std::path::{Path, PathBuf}; + +use super::{ + git_sha::GitSha, + manifest::{GeneratedAt, Manifest, ManifestContent, ManifestProvenance}, + naming::ArtefactName, + packaging_error::PackagingError, + schema_version::SchemaVersion, + sha256_digest::Sha256Digest, + target::TargetTriple, + toolchain_channel::ToolchainChannel, +}; +use crate::hex::to_lower_hex; /// Input parameters for the [`package_artefact`] function. /// @@ -72,8 +78,8 @@ pub struct PackageOutput { /// Compute the SHA-256 digest of a file. /// -/// Reads the file at `path` in chunks and returns the lowercase hex -/// digest as a validated [`Sha256Digest`]. +/// Streams the file at `path` through the hasher and returns the +/// lowercase hex digest as a validated [`Sha256Digest`]. /// /// # Errors /// @@ -84,11 +90,14 @@ pub fn compute_sha256(path: &Path) -> Result { let mut hasher = Sha256::new(); let mut buffer = [0u8; 8192]; loop { - let bytes_read = file.read(&mut buffer)?; - if bytes_read == 0 { + let read_count = file.read(&mut buffer)?; + if read_count == 0 { break; } - hasher.update(&buffer[..bytes_read]); + let Some(chunk) = buffer.get(..read_count) else { + break; + }; + hasher.update(chunk); } let hex = to_lower_hex(&hasher.finalize()); Ok(Sha256Digest::try_from(hex)?) @@ -136,8 +145,7 @@ pub fn create_archive( /// use whitaker_installer::artefact::packaging::generate_manifest_json; /// /// let json = generate_manifest_json(&manifest).expect("serialization"); -/// let parsed: serde_json::Value = -/// serde_json::from_str(&json).expect("valid JSON"); +/// let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); /// let obj = parsed.as_object().expect("top-level object"); /// assert!(obj.contains_key("git_sha")); /// assert!(obj.contains_key("sha256")); @@ -160,7 +168,7 @@ pub fn generate_manifest_json(manifest: &Manifest) -> Result Result { +pub fn package_artefact(params: &PackageParams) -> Result { if params.library_files.is_empty() { return Err(PackagingError::EmptyFileList); } @@ -182,7 +190,7 @@ pub fn package_artefact(params: PackageParams) -> Result TempDir { - TempDir::new().expect("temp dir creation succeeds") -} +fn temp_dir() -> std::io::Result { TempDir::new() } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn sample_git_sha() -> GitSha { - GitSha::try_from("abc1234").expect("valid sha") -} +fn sample_git_sha() -> Result { GitSha::try_from("abc1234") } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn sample_toolchain() -> ToolchainChannel { - ToolchainChannel::try_from("nightly-2026-05-28").expect("valid channel") +fn sample_toolchain() -> Result { + ToolchainChannel::try_from("nightly-2026-05-28") } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn sample_target() -> TargetTriple { - TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target") +fn sample_target() -> Result { + TargetTriple::try_from("x86_64-unknown-linux-gnu") } #[rstest] -fn compute_sha256_of_known_content(temp_dir: TempDir) { +fn compute_sha256_of_known_content(#[from(temp_dir)] temp_dir_res: std::io::Result) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let path = temp_dir.path().join("test.bin"); // SHA-256 of empty file is the well-known constant. fs::write(&path, b"").expect("write"); @@ -40,7 +44,9 @@ fn compute_sha256_of_known_content(temp_dir: TempDir) { } #[rstest] -fn create_archive_contains_files(temp_dir: TempDir) { +fn create_archive_contains_files(#[from(temp_dir)] temp_dir_res: std::io::Result) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let file_a = temp_dir.path().join("a.txt"); let file_b = temp_dir.path().join("b.txt"); fs::write(&file_a, b"alpha").expect("write a"); @@ -53,17 +59,21 @@ fn create_archive_contains_files(temp_dir: TempDir) { ) .expect("archive creation succeeds"); - let entry_names = list_archive_entries(&archive_path); + let entry_names = list_archive_entries(&archive_path).expect("archive should list entries"); assert!(entry_names.contains(&"a.txt".to_owned())); assert!(entry_names.contains(&"b.txt".to_owned())); } #[rstest] fn generate_manifest_json_matches_schema( - sample_git_sha: GitSha, - sample_toolchain: ToolchainChannel, - sample_target: TargetTriple, + #[from(sample_git_sha)] sample_git_sha_res: Result, + #[from(sample_toolchain)] sample_toolchain_res: Result, + #[from(sample_target)] sample_target_res: Result, ) { + let sample_git_sha = sample_git_sha_res.expect("sample git SHA should validate"); + let sample_toolchain = sample_toolchain_res.expect("sample toolchain should validate"); + let sample_target = sample_target_res.expect("sample target should validate"); + let provenance = ManifestProvenance { git_sha: sample_git_sha, schema_version: SchemaVersion::current(), @@ -95,11 +105,16 @@ fn generate_manifest_json_matches_schema( #[rstest] fn package_artefact_produces_valid_archive( - temp_dir: TempDir, - sample_git_sha: GitSha, - sample_toolchain: ToolchainChannel, - sample_target: TargetTriple, + #[from(temp_dir)] temp_dir_res: std::io::Result, + #[from(sample_git_sha)] sample_git_sha_res: Result, + #[from(sample_toolchain)] sample_toolchain_res: Result, + #[from(sample_target)] sample_target_res: Result, ) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let sample_git_sha = sample_git_sha_res.expect("sample git SHA should validate"); + let sample_toolchain = sample_toolchain_res.expect("sample toolchain should validate"); + let sample_target = sample_target_res.expect("sample target should validate"); + let lib_path = temp_dir.path().join("libwhitaker_suite.so"); fs::write(&lib_path, b"fake library").expect("write lib"); @@ -115,7 +130,7 @@ fn package_artefact_produces_valid_archive( generated_at: GeneratedAt::new("2026-02-11T10:00:00Z"), }; - let output = package_artefact(params).expect("packaging succeeds"); + let output = package_artefact(¶ms).expect("packaging succeeds"); assert!(output.archive_path.exists()); let expected_name = ArtefactName::new(sample_git_sha, sample_toolchain, sample_target); @@ -128,7 +143,8 @@ fn package_artefact_produces_valid_archive( expected_name.filename() ); - let entry_names = list_archive_entries(&output.archive_path); + let entry_names = + list_archive_entries(&output.archive_path).expect("archive should list entries"); assert!(entry_names.contains(&"libwhitaker_suite.so".to_owned())); assert!( !entry_names.contains(&"manifest.json".to_owned()), @@ -137,7 +153,9 @@ fn package_artefact_produces_valid_archive( } #[rstest] -fn package_artefact_rejects_empty_files(temp_dir: TempDir) { +fn package_artefact_rejects_empty_files(#[from(temp_dir)] temp_dir_res: std::io::Result) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let output_dir = temp_dir.path().join("dist"); fs::create_dir_all(&output_dir).expect("mkdir"); @@ -150,7 +168,7 @@ fn package_artefact_rejects_empty_files(temp_dir: TempDir) { generated_at: GeneratedAt::new("2026-02-11T10:00:00Z"), }; - let result = package_artefact(params); + let result = package_artefact(¶ms); assert!(matches!( result.expect_err("expected error"), PackagingError::EmptyFileList @@ -158,7 +176,11 @@ fn package_artefact_rejects_empty_files(temp_dir: TempDir) { } #[rstest] -fn package_artefact_fails_when_library_file_missing(temp_dir: TempDir) { +fn package_artefact_fails_when_library_file_missing( + #[from(temp_dir)] temp_dir_res: std::io::Result, +) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let missing = temp_dir.path().join("nonexistent_lib.so"); let output_dir = temp_dir.path().join("dist"); fs::create_dir_all(&output_dir).expect("mkdir"); @@ -172,7 +194,7 @@ fn package_artefact_fails_when_library_file_missing(temp_dir: TempDir) { generated_at: GeneratedAt::new("2026-02-11T10:00:00Z"), }; - let result = package_artefact(params); + let result = package_artefact(¶ms); assert!(result.is_err(), "expected error for missing library file"); assert!( matches!(result.expect_err("checked above"), PackagingError::Io(_)), @@ -182,11 +204,16 @@ fn package_artefact_fails_when_library_file_missing(temp_dir: TempDir) { #[rstest] fn archive_name_follows_adr_convention( - sample_git_sha: GitSha, - sample_toolchain: ToolchainChannel, - sample_target: TargetTriple, - temp_dir: TempDir, + #[from(sample_git_sha)] sample_git_sha_res: Result, + #[from(sample_toolchain)] sample_toolchain_res: Result, + #[from(sample_target)] sample_target_res: Result, + #[from(temp_dir)] temp_dir_res: std::io::Result, ) { + let sample_git_sha = sample_git_sha_res.expect("sample git SHA should validate"); + let sample_toolchain = sample_toolchain_res.expect("sample toolchain should validate"); + let sample_target = sample_target_res.expect("sample target should validate"); + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let lib_path = temp_dir.path().join("libtest.so"); fs::write(&lib_path, b"content").expect("write"); @@ -202,7 +229,7 @@ fn archive_name_follows_adr_convention( generated_at: GeneratedAt::new("2026-02-11T00:00:00Z"), }; - let output = package_artefact(params).expect("packaging"); + let output = package_artefact(¶ms).expect("packaging"); let expected = ArtefactName::new(sample_git_sha, sample_toolchain, sample_target); assert_eq!( output @@ -216,28 +243,30 @@ fn archive_name_follows_adr_convention( /// Create a [`PackageOutput`] with a single dummy library for tests that /// only need a valid package without caring about specific file content. -fn create_test_package(temp_dir: &TempDir) -> PackageOutput { +fn create_test_package(temp_dir: &TempDir) -> Result { let lib_path = temp_dir.path().join("libtest.so"); - fs::write(&lib_path, b"test content for hash").expect("write"); + fs::write(&lib_path, b"test content for hash")?; let output_dir = temp_dir.path().join("dist"); - fs::create_dir_all(&output_dir).expect("mkdir"); + fs::create_dir_all(&output_dir)?; let params = PackageParams { - git_sha: GitSha::try_from("deadbeef").expect("valid"), - toolchain: ToolchainChannel::try_from("nightly-2026-05-28").expect("valid"), - target: TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid"), + git_sha: GitSha::try_from("deadbeef")?, + toolchain: ToolchainChannel::try_from("nightly-2026-05-28")?, + target: TargetTriple::try_from("x86_64-unknown-linux-gnu")?, library_files: vec![lib_path], output_dir, generated_at: GeneratedAt::new("2026-02-11T12:00:00Z"), }; - package_artefact(params).expect("packaging") + package_artefact(¶ms) } #[rstest] -fn manifest_sha256_is_valid_hex(temp_dir: TempDir) { - let output = create_test_package(&temp_dir); +fn manifest_sha256_is_valid_hex(#[from(temp_dir)] temp_dir_res: std::io::Result) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + + let output = create_test_package(&temp_dir).expect("test package should build"); assert_eq!(output.manifest.sha256().as_str().len(), 64); assert!( output @@ -250,8 +279,12 @@ fn manifest_sha256_is_valid_hex(temp_dir: TempDir) { } #[rstest] -fn manifest_sha256_matches_archive_digest(temp_dir: TempDir) { - let output = create_test_package(&temp_dir); +fn manifest_sha256_matches_archive_digest( + #[from(temp_dir)] temp_dir_res: std::io::Result, +) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + + let output = create_test_package(&temp_dir).expect("test package should build"); let archive_digest = compute_sha256(&output.archive_path).expect("sha256 of archive"); assert_eq!( archive_digest.as_str(), @@ -261,7 +294,11 @@ fn manifest_sha256_matches_archive_digest(temp_dir: TempDir) { } #[rstest] -fn packaging_produces_deterministic_digest(temp_dir: TempDir) { +fn packaging_produces_deterministic_digest( + #[from(temp_dir)] temp_dir_res: std::io::Result, +) { + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let lib_path = temp_dir.path().join("libtest.so"); fs::write(&lib_path, b"content for hash check").expect("write"); @@ -278,11 +315,14 @@ fn packaging_produces_deterministic_digest(temp_dir: TempDir) { output_dir, generated_at: GeneratedAt::new("2026-02-12T10:00:00Z"), }; - let output = package_artefact(params).expect("packaging"); + let output = package_artefact(¶ms).expect("packaging"); digests.push(output.manifest.sha256().as_str().to_owned()); } + let [first, second] = digests.as_slice() else { + panic!("expected exactly two digests, got {}", digests.len()); + }; assert_eq!( - digests[0], digests[1], + first, second, "identical inputs must produce identical manifest digests" ); } @@ -301,16 +341,16 @@ fn collect_file_names_rejects_path_without_filename() { } /// Extract entry names from a `.tar.zst` archive for test assertions. -fn list_archive_entries(archive_path: &Path) -> Vec { - let file = fs::File::open(archive_path).expect("open archive"); - let decoder = zstd::Decoder::new(file).expect("zstd decode"); +fn list_archive_entries(archive_path: &Path) -> std::io::Result> { + let file = fs::File::open(archive_path)?; + let decoder = zstd::Decoder::new(file)?; let mut archive = tar::Archive::new(decoder); archive - .entries() - .expect("entries") - .map(|e| { - let entry = e.expect("entry"); - entry.path().expect("path").to_string_lossy().into_owned() + .entries()? + .map(|entry_result| { + let entry = entry_result?; + let path = entry.path()?; + Ok(path.to_string_lossy().into_owned()) }) .collect() } diff --git a/installer/src/artefact/schema_version.rs b/installer/src/artefact/schema_version.rs index 2f0b101a..62676b53 100644 --- a/installer/src/artefact/schema_version.rs +++ b/installer/src/artefact/schema_version.rs @@ -3,10 +3,12 @@ //! Restricts the version to the range `1..=CURRENT_MAX`, matching the //! versioning policy defined in ADR-001. -use super::error::{ArtefactError, Result}; -use serde::Serialize; use std::fmt; +use serde::Serialize; + +use super::error::{ArtefactError, Result}; + /// The highest schema version this build can read. const CURRENT_MAX: u32 = 1; @@ -32,15 +34,11 @@ pub struct SchemaVersion(u32); impl SchemaVersion { /// Return the current (latest) schema version. #[must_use] - pub fn current() -> Self { - Self(CURRENT_MAX) - } + pub const fn current() -> Self { Self(CURRENT_MAX) } /// Return the inner version number. #[must_use] - pub fn as_u32(self) -> u32 { - self.0 - } + pub const fn as_u32(self) -> u32 { self.0 } } impl<'de> serde::Deserialize<'de> for SchemaVersion { @@ -68,19 +66,17 @@ impl TryFrom for SchemaVersion { } impl From for u32 { - fn from(v: SchemaVersion) -> Self { - v.0 - } + fn from(v: SchemaVersion) -> Self { v.0 } } impl fmt::Display for SchemaVersion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[cfg(test)] mod tests { + //! Tests for artefact manifest schema versioning. + use super::*; #[test] @@ -100,7 +96,7 @@ mod tests { fn rejects_version_zero() { let result = SchemaVersion::try_from(0_u32); assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("schema version zero must be rejected"); assert!(matches!( err, ArtefactError::UnsupportedSchemaVersion { value: 0, max: 1 } diff --git a/installer/src/artefact/sha256_digest.rs b/installer/src/artefact/sha256_digest.rs index c64cfc14..ad3ad127 100644 --- a/installer/src/artefact/sha256_digest.rs +++ b/installer/src/artefact/sha256_digest.rs @@ -3,10 +3,12 @@ //! Validates that the value is a 64-character lowercase hexadecimal string //! representing a 256-bit hash digest. -use super::error::{ArtefactError, Result}; -use serde::Serialize; use std::fmt; +use serde::Serialize; + +use super::error::{ArtefactError, Result}; + /// Expected length of a hex-encoded SHA-256 digest. const DIGEST_HEX_LEN: usize = 64; @@ -38,9 +40,7 @@ impl Sha256Digest { /// assert_eq!(digest.as_str(), hex); /// ``` #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper and return the inner string. /// @@ -54,9 +54,7 @@ impl Sha256Digest { /// assert_eq!(digest.into_inner(), hex); /// ``` #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl<'de> serde::Deserialize<'de> for Sha256Digest { @@ -89,15 +87,11 @@ impl TryFrom for Sha256Digest { } impl AsRef for Sha256Digest { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for Sha256Digest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// Validate that `value` is a well-formed hex-encoded SHA-256 digest. @@ -114,10 +108,10 @@ fn validate_sha256(value: &str) -> Result<()> { .chars() .find(|c| !c.is_ascii_hexdigit() || c.is_ascii_uppercase()) { - let reason = if !bad.is_ascii_hexdigit() { - format!("non-hex character '{bad}'") - } else { + let reason = if bad.is_ascii_hexdigit() { "digest must be lowercase".to_owned() + } else { + format!("non-hex character '{bad}'") }; return Err(ArtefactError::InvalidSha256Digest { reason }); } @@ -126,13 +120,15 @@ fn validate_sha256(value: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::*; + //! Tests for SHA-256 digest parsing and formatting. + use rstest::{fixture, rstest}; + use super::*; + + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] - fn valid_digest() -> String { - "a".repeat(64) - } + fn valid_digest() -> String { "a".repeat(64) } #[rstest] fn accepts_valid_sixty_four_char_hex(valid_digest: String) { diff --git a/installer/src/artefact/target.rs b/installer/src/artefact/target.rs index f05aeb41..fb8b20c6 100644 --- a/installer/src/artefact/target.rs +++ b/installer/src/artefact/target.rs @@ -3,10 +3,12 @@ //! Only the five triples listed in ADR-001 are accepted. Any other triple //! is rejected at construction time with a descriptive error. -use super::error::{ArtefactError, Result}; -use serde::Serialize; use std::fmt; +use serde::Serialize; + +use super::error::{ArtefactError, Result}; + /// The supported target triples for prebuilt artefact distribution. /// /// These correspond to the target matrix defined in ADR-001. @@ -39,21 +41,15 @@ pub struct TargetTriple(String); impl TargetTriple { /// Return the triple as a string slice. #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper and return the inner string. #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } /// Return the full list of supported target triples. #[must_use] - pub fn supported() -> &'static [&'static str] { - SUPPORTED_TARGETS - } + pub const fn supported() -> &'static [&'static str] { SUPPORTED_TARGETS } /// Return the shared library extension for this target triple. /// @@ -66,8 +62,7 @@ impl TargetTriple { /// ``` /// use whitaker_installer::artefact::target::TargetTriple; /// - /// let linux: TargetTriple = "x86_64-unknown-linux-gnu" - /// .try_into().expect("valid"); + /// let linux: TargetTriple = "x86_64-unknown-linux-gnu".try_into().expect("valid"); /// assert_eq!(linux.library_extension(), ".so"); /// ``` #[must_use] @@ -91,25 +86,18 @@ impl TargetTriple { /// ``` /// use whitaker_installer::artefact::target::TargetTriple; /// - /// let win: TargetTriple = "x86_64-pc-windows-msvc" - /// .try_into().expect("valid"); + /// let win: TargetTriple = "x86_64-pc-windows-msvc".try_into().expect("valid"); /// assert_eq!(win.library_prefix(), ""); /// ``` #[must_use] - pub fn library_prefix(&self) -> &'static str { - if self.is_windows() { "" } else { "lib" } - } + pub fn library_prefix(&self) -> &'static str { if self.is_windows() { "" } else { "lib" } } /// Whether this target is a Windows platform. #[must_use] - pub fn is_windows(&self) -> bool { - self.0.contains("windows") - } + pub fn is_windows(&self) -> bool { self.0.contains("windows") } /// Whether this target is a macOS (Darwin) platform. - fn is_darwin(&self) -> bool { - self.0.contains("darwin") - } + fn is_darwin(&self) -> bool { self.0.contains("darwin") } } impl<'de> serde::Deserialize<'de> for TargetTriple { @@ -140,28 +128,25 @@ impl TryFrom<&str> for TargetTriple { impl TryFrom for TargetTriple { type Error = ArtefactError; - fn try_from(value: String) -> Result { - Self::try_from(value.as_str()) - } + fn try_from(value: String) -> Result { Self::try_from(value.as_str()) } } impl AsRef for TargetTriple { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for TargetTriple { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[cfg(test)] mod tests { - use super::*; + //! Tests for target-triple parsing and validation. + use rstest::rstest; + use super::*; + #[test] fn accepts_all_supported_targets() { for target in SUPPORTED_TARGETS { diff --git a/installer/src/artefact/toolchain_channel.rs b/installer/src/artefact/toolchain_channel.rs index 03e0b0dc..5ff05ef7 100644 --- a/installer/src/artefact/toolchain_channel.rs +++ b/installer/src/artefact/toolchain_channel.rs @@ -5,10 +5,12 @@ //! characters permitted in Rust toolchain channel specifiers, including //! host-qualified names such as `nightly-2026-05-28-x86_64-unknown-linux-gnu`. -use super::error::{ArtefactError, Result}; -use serde::Serialize; use std::fmt; +use serde::Serialize; + +use super::error::{ArtefactError, Result}; + /// A validated Rust toolchain channel string (e.g. `nightly-2026-05-28`). /// /// # Examples @@ -28,7 +30,7 @@ pub struct ToolchainChannel(String); /// Check that every byte is ASCII alphanumeric, a hyphen, a dot, or an /// underscore. Underscores appear in host-qualified toolchain names /// (e.g. `nightly-2026-05-28-x86_64-unknown-linux-gnu`). -fn is_valid_channel_char(c: char) -> bool { +const fn is_valid_channel_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' } @@ -40,15 +42,11 @@ impl ToolchainChannel { /// ``` /// use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; /// - /// let channel: ToolchainChannel = "stable" - /// .try_into() - /// .expect("valid toolchain channel"); + /// let channel: ToolchainChannel = "stable".try_into().expect("valid toolchain channel"); /// assert_eq!(channel.as_str(), "stable"); /// ``` #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper and return the inner string. /// @@ -64,9 +62,7 @@ impl ToolchainChannel { /// assert_eq!(inner, "nightly-2026-05-28"); /// ``` #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl TryFrom<&str> for ToolchainChannel { @@ -98,9 +94,7 @@ impl TryFrom for ToolchainChannel { } impl AsRef for ToolchainChannel { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl<'de> serde::Deserialize<'de> for ToolchainChannel { @@ -114,16 +108,17 @@ impl<'de> serde::Deserialize<'de> for ToolchainChannel { } impl fmt::Display for ToolchainChannel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[cfg(test)] mod tests { - use super::*; + //! Tests for toolchain channel parsing and validation. + use rstest::rstest; + use super::*; + #[rstest] #[case::nightly_with_date("nightly-2026-05-28")] #[case::stable("stable")] diff --git a/installer/src/artefact/verification.rs b/installer/src/artefact/verification.rs index e558fdc4..60179d22 100644 --- a/installer/src/artefact/verification.rs +++ b/installer/src/artefact/verification.rs @@ -33,9 +33,7 @@ impl VerificationPolicy { /// digest of the downloaded archive and compare it against the digest /// recorded in the manifest before extracting any files. #[must_use] - pub fn require_checksum(&self) -> bool { - self.require_checksum - } + pub const fn require_checksum(&self) -> bool { self.require_checksum } } impl Default for VerificationPolicy { @@ -81,6 +79,8 @@ impl fmt::Display for VerificationFailureAction { #[cfg(test)] mod tests { + //! Tests for artefact checksum verification. + use super::*; #[test] diff --git a/installer/src/bin/package_dependency_binary.rs b/installer/src/bin/package_dependency_binary.rs index bcec4f0c..13256c2b 100644 --- a/installer/src/bin/package_dependency_binary.rs +++ b/installer/src/bin/package_dependency_binary.rs @@ -1,16 +1,23 @@ //! Package dependency binaries and shared provenance assets for release uploads. +use std::{ + io::Write, + path::{Path, PathBuf}, +}; + use clap::{Parser, Subcommand}; -use std::path::PathBuf; use thiserror::Error; -use whitaker_installer::dependency_binaries::{ - find_dependency_binary, required_dependency_binaries, -}; -use whitaker_installer::dependency_packaging::{ - DependencyPackageParams, DependencyPackagingError, package_dependency_binary, - write_provenance_markdown, +use whitaker_installer::{ + dependency_binaries::{find_dependency_binary, required_dependency_binaries}, + dependency_packaging::{ + DependencyPackageParams, + DependencyPackagingError, + package_dependency_binary, + write_provenance_markdown, + }, + installer_packaging::TargetTriple, + output::write_stderr_line, }; -use whitaker_installer::installer_packaging::TargetTriple; /// Package repository-hosted dependency binaries for release publication. #[derive(Parser, Debug)] @@ -65,6 +72,9 @@ enum CliError { #[error("{0}")] Target(#[from] whitaker_installer::artefact::error::ArtefactError), + + #[error("failed to write to stdout: {0}")] + Stdout(#[from] std::io::Error), } /// Parse command-line arguments, execute the requested subcommand, and report @@ -72,7 +82,7 @@ enum CliError { fn main() { let cli = Cli::parse(); if let Err(error) = run(cli) { - eprintln!("error: {error}"); + write_stderr_line(&mut std::io::stderr(), format!("error: {error}")); std::process::exit(1); } } @@ -85,36 +95,55 @@ fn run(cli: Cli) -> Result<(), CliError> { target, binary_path, output_dir, - } => { - let dependency = find_dependency_binary(&package) - .map_err(|error| CliError::Manifest(error.to_string()))? - .cloned() - .ok_or(CliError::UnknownPackage(package))?; - let target = TargetTriple::try_from(target.as_str())?; - let output = package_dependency_binary(DependencyPackageParams { - dependency, - target, - binary_path, - output_dir, - })?; - println!("Created {}", output.archive_path.display()); - } - Command::Provenance { output_dir } => { - let dependencies = required_dependency_binaries() - .map_err(|error| CliError::Manifest(error.to_string()))?; - let output = write_provenance_markdown(&output_dir, dependencies)?; - println!("Created {}", output.display()); - } + } => run_package(package, &target, binary_path, output_dir), + Command::Provenance { output_dir } => run_provenance(&output_dir), } +} + +/// Package a single dependency binary into a release archive. +fn run_package( + package: String, + target: &str, + binary_path: PathBuf, + output_dir: PathBuf, +) -> Result<(), CliError> { + let dependency = find_dependency_binary(&package) + .map_err(|error| CliError::Manifest(error.to_string()))? + .cloned() + .ok_or(CliError::UnknownPackage(package))?; + let target_triple = TargetTriple::try_from(target)?; + let output = package_dependency_binary(&DependencyPackageParams { + dependency, + target: target_triple, + binary_path, + output_dir, + })?; + writeln!( + std::io::stdout(), + "Created {}", + output.archive_path.display() + )?; + Ok(()) +} + +/// Write the dependency provenance summary into `output_dir`. +fn run_provenance(output_dir: &Path) -> Result<(), CliError> { + let dependencies = + required_dependency_binaries().map_err(|error| CliError::Manifest(error.to_string()))?; + let output = write_provenance_markdown(output_dir, dependencies)?; + writeln!(std::io::stdout(), "Created {}", output.display())?; Ok(()) } #[cfg(test)] mod tests { - use super::*; + //! Tests for the dependency-binary packaging command. + use tempfile::tempdir; use whitaker_installer::dependency_binaries::provenance_filename; + use super::*; + #[test] fn run_package_command_rejects_invalid_target() { let temp_dir = tempdir().expect("temp dir"); diff --git a/installer/src/bin/package_installer_bin.rs b/installer/src/bin/package_installer_bin.rs index 944d74e0..9db1ef0c 100644 --- a/installer/src/bin/package_installer_bin.rs +++ b/installer/src/bin/package_installer_bin.rs @@ -4,12 +4,14 @@ //! [`whitaker_installer::installer_packaging::package_installer`] invoked //! by the release CI workflow to create binstall-compatible archives. +use std::{io::Write, path::PathBuf}; + use clap::Parser; -use std::path::PathBuf; use thiserror::Error; -use whitaker_installer::artefact::error::ArtefactError; -use whitaker_installer::installer_packaging::{ - InstallerPackagingError, TargetTriple, Version, package_installer, +use whitaker_installer::{ + artefact::error::ArtefactError, + installer_packaging::{InstallerPackagingError, TargetTriple, Version, package_installer}, + output::write_stderr_line, }; /// Package the `whitaker-installer` binary into a release archive. @@ -47,12 +49,16 @@ enum CliError { /// An invalid target triple was provided. #[error("{0}")] Artefact(#[from] ArtefactError), + + /// Failed to write the success report to standard output. + #[error("failed to write to stdout: {0}")] + Stdout(#[from] std::io::Error), } fn main() { let cli = Cli::parse(); if let Err(err) = run(cli) { - eprintln!("error: {err}"); + write_stderr_line(&mut std::io::stderr(), format!("error: {err}")); std::process::exit(1); } } @@ -66,17 +72,24 @@ fn run(cli: Cli) -> Result<(), CliError> { output_dir: cli.output_dir, }; - let output = package_installer(params)?; - println!("Created {}", output.archive_path.display()); + let output = package_installer(¶ms)?; + writeln!( + std::io::stdout(), + "Created {}", + output.archive_path.display() + )?; Ok(()) } #[cfg(test)] mod tests { - use super::*; + //! Tests for the installer packaging command. + use clap::Parser; use rstest::rstest; + use super::*; + const BASE_ARGS: [&str; 9] = [ "whitaker-package-installer", "--crate-version", diff --git a/installer/src/bin/package_lints.rs b/installer/src/bin/package_lints.rs index 1c6cdcd1..fd9dc35b 100644 --- a/installer/src/bin/package_lints.rs +++ b/installer/src/bin/package_lints.rs @@ -4,20 +4,27 @@ //! invoked by both the Makefile `package-lints` target and the //! rolling-release CI workflow. +use std::{ + io::Write, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + use clap::Parser; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; -use whitaker_installer::artefact::error::ArtefactError; -use whitaker_installer::artefact::git_sha::GitSha; -use whitaker_installer::artefact::manifest::GeneratedAt; -use whitaker_installer::artefact::packaging::{ - PackageParams, generate_manifest_json, package_artefact, +use whitaker_installer::{ + artefact::{ + error::ArtefactError, + git_sha::GitSha, + manifest::GeneratedAt, + packaging::{PackageParams, generate_manifest_json, package_artefact}, + packaging_error::PackagingError, + target::TargetTriple, + toolchain_channel::ToolchainChannel, + }, + output::write_stderr_line, + resolution::{LINT_CRATES, SUITE_CRATE}, }; -use whitaker_installer::artefact::packaging_error::PackagingError; -use whitaker_installer::artefact::target::TargetTriple; -use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; -use whitaker_installer::resolution::{LINT_CRATES, SUITE_CRATE}; /// Package prebuilt lint libraries into `.tar.zst` archives following /// the ADR-001 naming convention and write a sidecar @@ -82,12 +89,16 @@ enum PackageCliError { /// Failed to read the system clock. #[error("system time error: {0}")] SystemTime(#[from] std::time::SystemTimeError), + + /// Failed to write the success report to standard output. + #[error("failed to write to stdout: {0}")] + Stdout(#[from] std::io::Error), } fn main() { let cli = PackageCli::parse(); if let Err(err) = run(cli) { - eprintln!("error: {err}"); + write_stderr_line(&mut std::io::stderr(), format!("error: {err}")); std::process::exit(1); } } @@ -119,6 +130,7 @@ fn run(cli: PackageCli) -> Result<(), PackageCliError> { std::fs::create_dir_all(&cli.output_dir).map_err(PackagingError::from)?; + let out_dir = cli.output_dir.clone(); let params = PackageParams { git_sha, toolchain, @@ -128,14 +140,14 @@ fn run(cli: PackageCli) -> Result<(), PackageCliError> { generated_at: GeneratedAt::new(timestamp), }; - let output = package_artefact(params)?; + let output = package_artefact(¶ms)?; let manifest_json = generate_manifest_json(&output.manifest)?; - let out_dir = output.archive_path.parent().expect("archive has parent"); let manifest_filename = format!("manifest-{}.json", output.manifest.target()); let manifest_path = out_dir.join(manifest_filename); std::fs::write(&manifest_path, &manifest_json).map_err(PackagingError::from)?; - println!("Created {}", output.archive_path.display()); - println!("Manifest {}", manifest_path.display()); + let mut stdout = std::io::stdout(); + writeln!(stdout, "Created {}", output.archive_path.display())?; + writeln!(stdout, "Manifest {}", manifest_path.display())?; Ok(()) } @@ -174,34 +186,37 @@ fn validate_library_files(paths: &[PathBuf]) -> Result<(), PackageCliError> { /// Verify that `ts` matches the expected `YYYY-MM-DDThh:mm:ssZ` shape. fn validate_iso8601(ts: &str) -> Result<(), PackageCliError> { - let b = ts.as_bytes(); - if !has_valid_length(b) { - return Err(PackageCliError::InvalidTimestamp(ts.to_owned())); - } - if !has_valid_separators(b) { - return Err(PackageCliError::InvalidTimestamp(ts.to_owned())); - } - if !has_valid_digits(b) { + let &[ + y0, + y1, + y2, + y3, + b'-', + mo0, + mo1, + b'-', + d0, + d1, + b'T', + h0, + h1, + b':', + mi0, + mi1, + b':', + s0, + s1, + b'Z', + ] = ts.as_bytes() + else { return Err(PackageCliError::InvalidTimestamp(ts.to_owned())); + }; + let digits = [y0, y1, y2, y3, mo0, mo1, d0, d1, h0, h1, mi0, mi1, s0, s1]; + if digits.iter().all(u8::is_ascii_digit) { + Ok(()) + } else { + Err(PackageCliError::InvalidTimestamp(ts.to_owned())) } - Ok(()) -} - -fn has_valid_length(b: &[u8]) -> bool { - b.len() == 20 -} - -fn has_valid_separators(b: &[u8]) -> bool { - b[4] == b'-' && b[7] == b'-' && b[10] == b'T' && b[13] == b':' && b[16] == b':' && b[19] == b'Z' -} - -/// Digit positions in `YYYY-MM-DDThh:mm:ssZ` (start, end pairs). -const DIGIT_RANGES: [(usize, usize); 6] = [(0, 4), (5, 7), (8, 10), (11, 13), (14, 16), (17, 19)]; - -fn has_valid_digits(b: &[u8]) -> bool { - DIGIT_RANGES - .iter() - .all(|&(s, e)| b[s..e].iter().all(u8::is_ascii_digit)) } /// Return the current UTC time as an ISO 8601 string (`YYYY-MM-DDThh:mm:ssZ`). @@ -215,10 +230,10 @@ fn now_utc_iso8601() -> Result { /// Format a Unix epoch timestamp as `YYYY-MM-DDThh:mm:ssZ`. fn format_epoch_secs(epoch_secs: u64) -> String { let (year, month, day) = civil_from_epoch(epoch_secs); - let day_secs = (epoch_secs % 86_400) as u32; - let hour = day_secs / 3_600; - let minute = (day_secs % 3_600) / 60; - let second = day_secs % 60; + let day_secs = epoch_secs.rem_euclid(86_400); + let hour = day_secs.div_euclid(3_600); + let minute = day_secs.rem_euclid(3_600).div_euclid(60); + let second = day_secs.rem_euclid(60); format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") } @@ -226,30 +241,31 @@ fn format_epoch_secs(epoch_secs: u64) -> String { /// /// Adapted from Howard Hinnant's `civil_from_days` algorithm, which is /// public domain and widely used in C++ `` implementations. -fn civil_from_epoch(epoch_secs: u64) -> (u32, u32, u32) { - let z = (epoch_secs / 86_400) as i64 + 719_468; +const fn civil_from_epoch(epoch_secs: u64) -> (i64, u64, u64) { + let z = epoch_secs.div_euclid(86_400).cast_signed() + 719_468; let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097) as u64; // day of era [0, 146_096] - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = (yoe as i64) + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; + let doe = z.rem_euclid(146_097).cast_unsigned(); // day of era [0, 146_096] + let yoe = (doe - doe.div_euclid(1_460) + doe.div_euclid(36_524) - doe.div_euclid(146_096)) + .div_euclid(365); + let y = yoe.cast_signed() + era * 400; + let doy = doe - (365 * yoe + yoe.div_euclid(4) - yoe.div_euclid(100)); // day of year + let mp = (5 * doy + 2).div_euclid(153); + let d = doy - (153 * mp + 2).div_euclid(5) + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - #[expect( - clippy::cast_sign_loss, - reason = "year is always positive for post-epoch dates" - )] - (y as u32, m as u32, d as u32) + let year = if m <= 2 { y + 1 } else { y }; + (year, m, d) } #[cfg(test)] mod tests { - use super::*; + //! Tests for the lint-library packaging command. + + use std::fs; + use clap::Parser; use rstest::{fixture, rstest}; - use std::fs; + + use super::*; /// Common CLI base arguments shared across parsing tests. const BASE_ARGS: [&str; 9] = [ @@ -274,8 +290,8 @@ mod tests { } #[fixture] - fn linux_target() -> TargetTriple { - TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid") + fn linux_target() -> Result { + TargetTriple::try_from("x86_64-unknown-linux-gnu") } #[test] @@ -331,7 +347,11 @@ mod tests { } #[rstest] - fn discover_library_files_finds_expected_files(linux_target: TargetTriple) { + fn discover_library_files_finds_expected_files( + #[from(linux_target)] linux_target_res: Result, + ) { + let linux_target = linux_target_res.expect("linux target triple should validate"); + let dir = tempfile::tempdir().expect("temp dir"); for name in LINT_CRATES.iter().chain(std::iter::once(&SUITE_CRATE)) { fs::write(dir.path().join(format!("lib{name}.so")), b"fake").expect("write"); @@ -356,7 +376,11 @@ mod tests { } #[rstest] - fn discover_library_files_rejects_missing(linux_target: TargetTriple) { + fn discover_library_files_rejects_missing( + #[from(linux_target)] linux_target_res: Result, + ) { + let linux_target = linux_target_res.expect("linux target triple should validate"); + let dir = tempfile::tempdir().expect("temp dir"); fs::write(dir.path().join("libconditional_max_n_branches.so"), b"fake").expect("write"); let result = discover_library_files(dir.path(), &linux_target); diff --git a/installer/src/binstall_metadata.rs b/installer/src/binstall_metadata.rs index 1c0a0fa6..8bc1797c 100644 --- a/installer/src/binstall_metadata.rs +++ b/installer/src/binstall_metadata.rs @@ -93,15 +93,19 @@ pub fn expand_bin_dir(version: &str, target: &str) -> String { /// Returns the full TOML table for `installer/Cargo.toml`, located via /// `CARGO_MANIFEST_DIR`. This helper is shared by unit tests and /// behaviour-driven scenarios to avoid duplicating manifest-loading logic. +/// +/// # 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"))] -#[must_use] -pub fn load_cargo_toml() -> toml::Table { +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) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", cargo_toml_path.display())); - content.parse::().unwrap_or_else(|err| { - panic!( + .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() ) @@ -111,17 +115,19 @@ pub fn load_cargo_toml() -> toml::Table { /// Extract the `[package.metadata.binstall]` sub-table from a parsed /// `Cargo.toml`. /// -/// Panics if the expected table path is missing. +/// # Errors +/// +/// Returns a description of the failure if the expected table path is +/// missing. #[cfg(any(test, feature = "test-support"))] -#[must_use] -pub fn extract_binstall_table(table: &toml::Table) -> toml::Table { +pub fn extract_binstall_table(table: &toml::Table) -> Result { table .get("package") .and_then(|p| p.get("metadata")) .and_then(|m| m.get("binstall")) .and_then(|b| b.as_table()) - .expect("[package.metadata.binstall] table not found") - .clone() + .cloned() + .ok_or_else(|| "[package.metadata.binstall] table not found".to_owned()) } #[cfg(test)] diff --git a/installer/src/binstall_metadata_tests.rs b/installer/src/binstall_metadata_tests.rs index dab46c36..2adf60e1 100644 --- a/installer/src/binstall_metadata_tests.rs +++ b/installer/src/binstall_metadata_tests.rs @@ -4,13 +4,14 @@ //! the `[package.metadata.binstall]` section matches the specification in //! the design document (§ Installer release artefacts). -use super::*; use rstest::rstest; +use super::*; + #[rstest] fn pkg_url_matches_design_document() { - let table = load_cargo_toml(); - let binstall = extract_binstall_table(&table); + let table = load_cargo_toml().expect("load installer Cargo.toml"); + let binstall = extract_binstall_table(&table).expect("extract binstall table"); let pkg_url = binstall .get("pkg-url") .and_then(|v| v.as_str()) @@ -20,8 +21,8 @@ fn pkg_url_matches_design_document() { #[rstest] fn bin_dir_matches_design_document() { - let table = load_cargo_toml(); - let binstall = extract_binstall_table(&table); + let table = load_cargo_toml().expect("load installer Cargo.toml"); + let binstall = extract_binstall_table(&table).expect("extract binstall table"); let bin_dir = binstall .get("bin-dir") .and_then(|v| v.as_str()) @@ -31,8 +32,8 @@ fn bin_dir_matches_design_document() { #[rstest] fn default_pkg_fmt_is_tgz() { - let table = load_cargo_toml(); - let binstall = extract_binstall_table(&table); + let table = load_cargo_toml().expect("load installer Cargo.toml"); + let binstall = extract_binstall_table(&table).expect("extract binstall table"); let pkg_fmt = binstall .get("pkg-fmt") .and_then(|v| v.as_str()) @@ -42,8 +43,8 @@ fn default_pkg_fmt_is_tgz() { #[rstest] fn windows_override_uses_zip() { - let table = load_cargo_toml(); - let binstall = extract_binstall_table(&table); + let table = load_cargo_toml().expect("load installer Cargo.toml"); + let binstall = extract_binstall_table(&table).expect("extract binstall table"); let overrides = binstall .get("overrides") .and_then(|o| o.as_table()) @@ -61,8 +62,8 @@ fn windows_override_uses_zip() { #[rstest] fn no_unexpected_overrides() { - let table = load_cargo_toml(); - let binstall = extract_binstall_table(&table); + let table = load_cargo_toml().expect("load installer Cargo.toml"); + let binstall = extract_binstall_table(&table).expect("extract binstall table"); let overrides = binstall .get("overrides") .and_then(|o| o.as_table()) @@ -81,8 +82,8 @@ fn no_unexpected_overrides() { #[rstest] fn essential_binstall_fields_present() { - let table = load_cargo_toml(); - let binstall = extract_binstall_table(&table); + let table = load_cargo_toml().expect("load installer Cargo.toml"); + let binstall = extract_binstall_table(&table).expect("extract binstall table"); let required = ["pkg-url", "bin-dir", "pkg-fmt"]; for key in &required { assert!( @@ -99,8 +100,9 @@ fn essential_binstall_fields_present() { #[case::macos_arm("aarch64-apple-darwin")] fn non_windows_targets_expand_to_tgz(#[case] target: &str) { let url = expand_pkg_url("0.2.0", target); + let extension = std::path::Path::new(&url).extension(); assert!( - url.ends_with(".tgz"), + extension.is_some_and(|ext| ext.eq_ignore_ascii_case("tgz")), "expected URL for {target} to end with .tgz, got {url}" ); assert!(url.contains(target)); @@ -110,8 +112,9 @@ fn non_windows_targets_expand_to_tgz(#[case] target: &str) { #[rstest] fn windows_target_expands_to_zip() { let url = expand_pkg_url("0.2.0", WINDOWS_OVERRIDE_TARGET); + let extension = std::path::Path::new(&url).extension(); assert!( - url.ends_with(".zip"), + extension.is_some_and(|ext| ext.eq_ignore_ascii_case("zip")), "expected URL for Windows to end with .zip, got {url}" ); } diff --git a/installer/src/builder.rs b/installer/src/builder.rs index 9fdbc1aa..38d109ea 100644 --- a/installer/src/builder.rs +++ b/installer/src/builder.rs @@ -3,18 +3,28 @@ //! This module provides utilities to build Dylint lint crates in release mode //! with the required features enabled. -use crate::error::{InstallerError, Result}; -use crate::toolchain::Toolchain; -use camino::Utf8PathBuf; use std::process::Command; +use camino::Utf8PathBuf; + // Re-export from submodules for backwards compatibility pub use crate::crate_name::CrateName; -pub use crate::resolution::{ - CrateResolutionOptions, EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE, is_known_crate, - resolve_crates, validate_crate_names, +use crate::{ + error::{InstallerError, Result}, + toolchain::Toolchain, +}; +pub use crate::{ + resolution::{ + CrateResolutionOptions, + EXPERIMENTAL_LINT_CRATES, + LINT_CRATES, + SUITE_CRATE, + is_known_crate, + resolve_crates, + validate_crate_names, + }, + workspace::find_workspace_root, }; -pub use crate::workspace::find_workspace_root; /// Configuration for the build process. #[derive(Debug, Clone)] @@ -63,9 +73,7 @@ pub struct Builder { impl Builder { /// Create a new builder with the given configuration. #[must_use] - pub fn new(config: BuildConfig) -> Self { - Self { config } - } + pub const fn new(config: BuildConfig) -> Self { Self { config } } /// Build a single lint crate. /// @@ -186,15 +194,11 @@ impl Builder { /// This method is primarily useful for testing to verify that the correct /// configuration was constructed. #[must_use] - pub fn config(&self) -> &BuildConfig { - &self.config - } + pub const fn config(&self) -> &BuildConfig { &self.config } } impl CrateBuilder for Builder { - fn build_all(&self, crates: &[CrateName]) -> Result> { - self.build_all(crates) - } + fn build_all(&self, crates: &[CrateName]) -> Result> { self.build_all(crates) } } /// Return the platform-specific library file extension (including the dot). @@ -229,9 +233,12 @@ pub const fn library_prefix() -> &'static str { #[cfg(test)] mod tests { - use super::*; + //! Tests for lint-library build orchestration. + use rstest::{fixture, rstest}; + use super::*; + #[fixture] fn builder() -> Builder { Builder { diff --git a/installer/src/cli.rs b/installer/src/cli.rs index 0ef5fbc5..a2807936 100644 --- a/installer/src/cli.rs +++ b/installer/src/cli.rs @@ -4,11 +4,11 @@ //! from the main entrypoint to keep the binary small and focused on //! orchestration. -use crate::crate_name::CrateName; -use crate::resolution::EXPERIMENTAL_LINT_CRATES; use camino::Utf8PathBuf; use clap::{Parser, Subcommand}; +use crate::{crate_name::CrateName, resolution::EXPERIMENTAL_LINT_CRATES}; + /// Install Whitaker Dylint lint libraries. #[derive(Parser, Debug)] #[command(name = "whitaker-installer")] @@ -83,13 +83,9 @@ pub struct InstallArgs { #[arg(short, long, value_name = "NAME")] pub lint: Vec, - /// Build all individual lint crates instead of the aggregated suite. - #[arg(long, conflicts_with = "lint")] - pub individual_lints: bool, - - /// Include experimental lints when available. - #[arg(long)] - pub experimental: bool, + /// Flags selecting which lint crates are built. + #[command(flatten)] + pub lint_selection: LintSelectionFlags, /// Number of parallel cargo build jobs. #[arg(short, long, value_name = "N")] @@ -99,13 +95,9 @@ pub struct InstallArgs { #[arg(long, value_name = "TOOLCHAIN")] pub toolchain: Option, - /// Install rustc-codegen-cranelift via rustup. - #[arg(long, default_value_t = false)] - pub cranelift: bool, - - /// Show configuration and exit without building. - #[arg(long)] - pub dry_run: bool, + /// Flags adjusting how the installer executes the build. + #[command(flatten)] + pub execution: ExecutionFlags, /// Increase cargo output verbosity (repeatable: -v, -vv, -vvv). #[arg( @@ -121,6 +113,48 @@ pub struct InstallArgs { #[arg(short, long, conflicts_with = "verbosity")] pub quiet: bool, + /// Flags that skip individual installation steps. + #[command(flatten)] + pub skip: SkipFlags, + + /// Skip prebuilt artefact download and build from source. + #[arg(long = "build-only")] + pub is_build_only: bool, +} + +/// Flags selecting which lint crates are built. +/// +/// Flattened into [`InstallArgs`] so the command-line surface is unchanged. +#[derive(clap::Args, Debug, Clone, Default)] +pub struct LintSelectionFlags { + /// Build all individual lint crates instead of the aggregated suite. + #[arg(long, conflicts_with = "lint")] + pub individual_lints: bool, + + /// Include experimental lints when available. + #[arg(long)] + pub experimental: bool, +} + +/// Flags adjusting how the installer executes the build. +/// +/// Flattened into [`InstallArgs`] so the command-line surface is unchanged. +#[derive(clap::Args, Debug, Clone, Default)] +pub struct ExecutionFlags { + /// Install rustc-codegen-cranelift via rustup. + #[arg(long, default_value_t = false)] + pub cranelift: bool, + + /// Show configuration and exit without building. + #[arg(long)] + pub dry_run: bool, +} + +/// Flags that skip individual installation steps. +/// +/// Flattened into [`InstallArgs`] so the command-line surface is unchanged. +#[derive(clap::Args, Debug, Clone, Default)] +pub struct SkipFlags { /// Skip installation of cargo-dylint and dylint-link. #[arg(long)] pub skip_deps: bool, @@ -132,10 +166,6 @@ pub struct InstallArgs { /// Do not update existing repository clone. #[arg(long)] pub no_update: bool, - - /// Skip prebuilt artefact download and build from source. - #[arg(long = "build-only")] - pub is_build_only: bool, } /// Arguments for the list command. @@ -155,15 +185,13 @@ impl InstallArgs { /// /// Prebuilt artefacts are skipped when: /// - `--build-only` is set, or - /// - experimental lint behaviour is requested, either via - /// `--experimental` (suite build) or explicit experimental crates when - /// the experimental crate list is non-empty. + /// - experimental lint behaviour is requested, either via `--experimental` (suite build) or + /// explicit experimental crates when the experimental crate list is non-empty. /// /// # Examples /// /// ``` - /// use whitaker_installer::cli::InstallArgs; - /// use whitaker_installer::crate_name::CrateName; + /// use whitaker_installer::{cli::InstallArgs, crate_name::CrateName}; /// /// let requested = vec![CrateName::from("whitaker_suite")]; /// @@ -178,7 +206,7 @@ impl InstallArgs { /// ``` #[must_use] pub fn should_attempt_prebuilt(&self, requested_crates: &[CrateName]) -> bool { - if self.is_build_only || self.experimental { + if self.is_build_only || self.lint_selection.experimental { return false; } !requested_crates @@ -199,25 +227,21 @@ impl Default for InstallArgs { /// use whitaker_installer::cli::InstallArgs; /// /// let args = InstallArgs::default(); - /// assert!(!args.individual_lints); - /// assert!(!args.skip_deps); + /// assert!(!args.lint_selection.individual_lints); + /// assert!(!args.skip.skip_deps); /// assert!(args.lint.is_empty()); /// ``` fn default() -> Self { Self { target_dir: None, lint: Vec::new(), - individual_lints: false, - experimental: false, + lint_selection: LintSelectionFlags::default(), jobs: None, toolchain: None, - cranelift: false, - dry_run: false, + execution: ExecutionFlags::default(), verbosity: 0, quiet: false, - skip_deps: false, - skip_wrapper: false, - no_update: false, + skip: SkipFlags::default(), is_build_only: false, } } @@ -256,7 +280,7 @@ impl Cli { /// install arguments. Callers should check `self.command` before calling /// this method if the `List` case needs different handling. #[must_use] - pub fn install_args(&self) -> &InstallArgs { + pub const fn install_args(&self) -> &InstallArgs { match &self.command { Some(Command::Install(args)) => args, Some(Command::List(_)) | None => &self.install, diff --git a/installer/src/cli_tests.rs b/installer/src/cli_tests.rs index 8b732bd2..bec75e32 100644 --- a/installer/src/cli_tests.rs +++ b/installer/src/cli_tests.rs @@ -1,24 +1,60 @@ //! Tests for installer CLI parsing and default behaviours. -use super::*; use rstest::rstest; +use super::*; + #[test] fn cli_parses_defaults() { let cli = Cli::parse_from(["whitaker-installer"]); assert!(cli.command.is_none()); - assert!(cli.install.target_dir.is_none()); - assert!(cli.install.lint.is_empty()); - assert!(!cli.install.individual_lints); - assert!(!cli.install.experimental); - assert!(!cli.install.cranelift); - assert!(!cli.install.dry_run); - assert_eq!(cli.install.verbosity, 0); - assert!(!cli.install.quiet); - assert!(!cli.install.skip_deps); - assert!(!cli.install.skip_wrapper); - assert!(!cli.install.no_update); - assert!(!cli.install.is_build_only); + assert_default_lint_selection(&cli); + assert_default_execution_options(&cli); + assert_default_skip_flags(&cli); +} + +/// Assert each named condition holds, reporting the first field that differs +/// from its documented default. +fn assert_default_conditions(conditions: &[(&str, bool)]) { + for &(name, condition) in conditions { + assert!(condition, "{name} must have its default value"); + } +} + +/// Assert that no target directory or lint selection is present by default. +fn assert_default_lint_selection(cli: &Cli) { + assert_default_conditions(&[ + ("target_dir", cli.install.target_dir.is_none()), + ("lint", cli.install.lint.is_empty()), + ( + "lint_selection.individual_lints", + !cli.install.lint_selection.individual_lints, + ), + ( + "lint_selection.experimental", + !cli.install.lint_selection.experimental, + ), + ]); +} + +/// Assert that execution options default to a full, non-quiet build. +fn assert_default_execution_options(cli: &Cli) { + assert_default_conditions(&[ + ("execution.cranelift", !cli.install.execution.cranelift), + ("execution.dry_run", !cli.install.execution.dry_run), + ("verbosity", cli.install.verbosity == 0), + ("quiet", !cli.install.quiet), + ]); +} + +/// Assert that no pipeline steps are skipped by default. +fn assert_default_skip_flags(cli: &Cli) { + assert_default_conditions(&[ + ("skip.skip_deps", !cli.install.skip.skip_deps), + ("skip.skip_wrapper", !cli.install.skip.skip_wrapper), + ("skip.no_update", !cli.install.skip.no_update), + ("is_build_only", !cli.install.is_build_only), + ]); } #[test] @@ -85,7 +121,7 @@ fn cli_parses_install_with_args() { ]); match cli.command { Some(Command::Install(args)) => { - assert!(args.experimental); + assert!(args.lint_selection.experimental); assert_eq!(args.lint, vec!["module_max_lines"]); } _ => panic!("expected Install command"), @@ -112,7 +148,10 @@ fn should_attempt_prebuilt_false_when_build_only() { #[test] fn should_attempt_prebuilt_false_when_experimental_flag_enabled() { let args = InstallArgs { - experimental: true, + lint_selection: LintSelectionFlags { + experimental: true, + ..LintSelectionFlags::default() + }, ..InstallArgs::default() }; let requested = vec![CrateName::from("whitaker_suite")]; @@ -128,15 +167,15 @@ fn should_attempt_prebuilt_true_for_stable_bumpy_road_requests() { /// Parameterized tests for boolean CLI flags (backwards compatibility). #[rstest] -#[case::individual_lints(&["whitaker-installer", "--individual-lints"], |cli: &Cli| cli.install.individual_lints)] -#[case::experimental(&["whitaker-installer", "--experimental"], |cli: &Cli| cli.install.experimental)] -#[case::cranelift(&["whitaker-installer", "--cranelift"], |cli: &Cli| cli.install.cranelift)] -#[case::dry_run(&["whitaker-installer", "--dry-run"], |cli: &Cli| cli.install.dry_run)] +#[case::individual_lints(&["whitaker-installer", "--individual-lints"], |cli: &Cli| cli.install.lint_selection.individual_lints)] +#[case::experimental(&["whitaker-installer", "--experimental"], |cli: &Cli| cli.install.lint_selection.experimental)] +#[case::cranelift(&["whitaker-installer", "--cranelift"], |cli: &Cli| cli.install.execution.cranelift)] +#[case::dry_run(&["whitaker-installer", "--dry-run"], |cli: &Cli| cli.install.execution.dry_run)] #[case::verbose(&["whitaker-installer", "-v"], |cli: &Cli| cli.install.verbosity > 0)] #[case::quiet(&["whitaker-installer", "-q"], |cli: &Cli| cli.install.quiet)] -#[case::skip_deps(&["whitaker-installer", "--skip-deps"], |cli: &Cli| cli.install.skip_deps)] -#[case::skip_wrapper(&["whitaker-installer", "--skip-wrapper"], |cli: &Cli| cli.install.skip_wrapper)] -#[case::no_update(&["whitaker-installer", "--no-update"], |cli: &Cli| cli.install.no_update)] +#[case::skip_deps(&["whitaker-installer", "--skip-deps"], |cli: &Cli| cli.install.skip.skip_deps)] +#[case::skip_wrapper(&["whitaker-installer", "--skip-wrapper"], |cli: &Cli| cli.install.skip.skip_wrapper)] +#[case::no_update(&["whitaker-installer", "--no-update"], |cli: &Cli| cli.install.skip.no_update)] #[case::build_only(&["whitaker-installer", "--build-only"], |cli: &Cli| cli.install.is_build_only)] fn cli_parses_boolean_flags(#[case] args: &[&str], #[case] check: fn(&Cli) -> bool) { let cli = Cli::parse_from(args); @@ -165,10 +204,18 @@ fn cli_rejects_conflicting_flags(#[case] args: &[&str]) { #[test] fn install_args_default_is_valid() { let args = InstallArgs::default(); - assert!(!args.individual_lints); - assert!(!args.experimental); - assert!(!args.cranelift); - assert!(!args.skip_deps); + assert_default_conditions(&[ + ( + "lint_selection.individual_lints", + !args.lint_selection.individual_lints, + ), + ( + "lint_selection.experimental", + !args.lint_selection.experimental, + ), + ("execution.cranelift", !args.execution.cranelift), + ("skip.skip_deps", !args.skip.skip_deps), + ]); } #[test] @@ -182,12 +229,12 @@ fn list_args_default_is_valid() { fn install_args_returns_flattened_when_no_subcommand() { let cli = Cli::parse_from(["whitaker-installer", "--experimental"]); let args = cli.install_args(); - assert!(args.experimental); + assert!(args.lint_selection.experimental); } #[test] fn install_args_returns_subcommand_args_when_present() { let cli = Cli::parse_from(["whitaker-installer", "install", "--dry-run"]); let args = cli.install_args(); - assert!(args.dry_run); + assert!(args.execution.dry_run); } diff --git a/installer/src/crate_name.rs b/installer/src/crate_name.rs index 5a384cc7..8d36993f 100644 --- a/installer/src/crate_name.rs +++ b/installer/src/crate_name.rs @@ -17,52 +17,41 @@ pub struct CrateName(String); impl CrateName { /// Create a new crate name. #[must_use] - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } + pub fn new(name: impl Into) -> Self { Self(name.into()) } /// Get the crate name as a string slice. #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper and return the inner string. #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl AsRef for CrateName { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl From<&str> for CrateName { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } + fn from(s: &str) -> Self { Self(s.to_owned()) } } impl From for CrateName { - fn from(s: String) -> Self { - Self(s) - } + fn from(s: String) -> Self { Self(s) } } impl fmt::Display for CrateName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[cfg(test)] mod tests { - use super::*; + //! Tests for crate-name normalization. + use std::collections::HashSet; + use super::*; + #[test] fn new_creates_valid_instance() { let name = CrateName::new("test_crate"); diff --git a/installer/src/dependency_binaries/install/checksum.rs b/installer/src/dependency_binaries/install/checksum.rs index 066adb12..741cdf49 100644 --- a/installer/src/dependency_binaries/install/checksum.rs +++ b/installer/src/dependency_binaries/install/checksum.rs @@ -7,14 +7,14 @@ //! owned here and imported by `downloader`, keeping the module dependency //! one-way. -use super::installer::DependencyBinaryInstallError; -use crate::hex::to_lower_hex; +use std::{io, io::Read, path::Path}; + use sha2::{Digest, Sha256}; -use std::io; -use std::io::Read; -use std::path::Path; use tracing::{debug, warn}; +use super::installer::DependencyBinaryInstallError; +use crate::hex::to_lower_hex; + /// Bounded `category` field for every checksum boundary event. Owned here so the /// module dependency stays one-way (`downloader` imports this; not vice versa). pub(super) const CATEGORY_CHECKSUM: &str = "checksum"; @@ -95,7 +95,7 @@ pub(super) fn fetch_expected_checksum( ); DependencyBinaryInstallError::Download { url: checksum_url.to_owned(), - reason: "empty or invalid checksum file".to_string(), + reason: "empty or invalid checksum file".to_owned(), } })?; let expected = token.to_ascii_lowercase(); @@ -133,7 +133,10 @@ fn compute_sha256(mut reader: impl Read) -> io::Result { Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => return Err(error), }; - hasher.update(&buffer[..bytes_read]); + let chunk = buffer + .get(..bytes_read) + .ok_or_else(|| io::Error::other("reader returned an out-of-range byte count"))?; + hasher.update(chunk); } Ok(to_lower_hex(&hasher.finalize())) } @@ -159,7 +162,7 @@ pub(super) fn verify_archive_checksum( if actual_checksum != expected { return Err(DependencyBinaryInstallError::Checksum { archive: archive.to_path_buf(), - expected: expected.to_string(), + expected: expected.to_owned(), actual: actual_checksum, }); } @@ -170,23 +173,23 @@ pub(super) fn verify_archive_checksum( mod tests { //! Tests for checksum parsing, streaming SHA-256, and verification. - use super::*; - use rstest::rstest; use std::io::Write; + + use rstest::rstest; use tempfile::NamedTempFile; + use super::*; + /// Write `contents` to a fresh temp file and return the handle. - fn temp_file_with(contents: &[u8]) -> NamedTempFile { - let mut file = NamedTempFile::new().expect("create temp file"); - file.write_all(contents).expect("write temp file"); - file.flush().expect("flush temp file"); - file + fn temp_file_with(contents: &[u8]) -> io::Result { + let mut file = NamedTempFile::new()?; + file.write_all(contents)?; + file.flush()?; + Ok(file) } /// Reopen `file` as a fresh read handle for streaming into the hasher. - fn read_handle(file: &NamedTempFile) -> impl Read { - file.reopen().expect("reopen temp file") - } + fn read_handle(file: &NamedTempFile) -> io::Result { file.reopen() } #[rstest] #[case(404, true)] @@ -232,9 +235,10 @@ mod tests { #[test] fn compute_sha256_matches_known_vector() { - let file = temp_file_with(b"abc"); + let file = temp_file_with(b"abc").expect("temp file should be written"); assert_eq!( - compute_sha256(read_handle(&file)).expect("hash archive stream"), + compute_sha256(read_handle(&file).expect("temp file should reopen")) + .expect("hash archive stream"), concat!( "ba7816bf8f01cfea414140de5dae2223", "b00361a396177a9cb410ff61f20015ad", @@ -260,8 +264,14 @@ mod tests { return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); } let take = self.data.len().min(self.chunk).min(buf.len()); - buf[..take].copy_from_slice(&self.data[..take]); - self.data = &self.data[take..]; + let (to_copy, rest) = self.data.split_at(take); + let Some(target) = buf.get_mut(..take) else { + return Err(io::Error::other( + "read length must be bounded by the buffer", + )); + }; + target.copy_from_slice(to_copy); + self.data = rest; Ok(take) } } @@ -308,17 +318,29 @@ mod tests { #[test] fn verify_archive_checksum_accepts_a_matching_digest() { - let file = temp_file_with(b"hello world"); - let expected = compute_sha256(read_handle(&file)).expect("hash archive stream"); - assert!(verify_archive_checksum(read_handle(&file), file.path(), &expected).is_ok()); + let file = temp_file_with(b"hello world").expect("temp file should be written"); + let expected = compute_sha256(read_handle(&file).expect("temp file should reopen")) + .expect("hash archive stream"); + assert!( + verify_archive_checksum( + read_handle(&file).expect("temp file should reopen"), + file.path(), + &expected + ) + .is_ok() + ); } #[test] fn verify_archive_checksum_rejects_a_mismatched_digest() { - let file = temp_file_with(b"hello world"); + let file = temp_file_with(b"hello world").expect("temp file should be written"); let wrong = "0".repeat(64); - let error = verify_archive_checksum(read_handle(&file), file.path(), &wrong) - .expect_err("mismatched checksum must fail"); + let error = verify_archive_checksum( + read_handle(&file).expect("temp file should reopen"), + file.path(), + &wrong, + ) + .expect_err("mismatched checksum must fail"); match error { DependencyBinaryInstallError::Checksum { archive, diff --git a/installer/src/dependency_binaries/install/downloader.rs b/installer/src/dependency_binaries/install/downloader.rs index 2b5d4d70..8d853093 100644 --- a/installer/src/dependency_binaries/install/downloader.rs +++ b/installer/src/dependency_binaries/install/downloader.rs @@ -1,18 +1,28 @@ //! Download support for repository-hosted dependency-binary archives. -use crate::artefact::download::HttpDownloader; - -use super::checksum::{ - CATEGORY_CHECKSUM, fetch_expected_checksum, map_ureq_error, verify_archive_checksum, +use std::{ + io::{self, Read, Write}, + path::Path, }; -use super::installer::DependencyBinaryInstallError; + use camino::{Utf8Path, Utf8PathBuf}; -use cap_std::ambient_authority; -use cap_std::fs_utf8::{Dir, File}; -use std::io::{self, Read, Write}; -use std::path::Path; +use cap_std::{ + ambient_authority, + fs_utf8::{Dir, File}, +}; use tracing::{debug, instrument, warn}; +use super::{ + checksum::{ + CATEGORY_CHECKSUM, + fetch_expected_checksum, + map_ureq_error, + verify_archive_checksum, + }, + installer::DependencyBinaryInstallError, +}; +use crate::artefact::download::HttpDownloader; + const DOWNLOAD_TIMEOUT_SECS: u64 = 30; /// Maximum archive size accepted, so a runaway response cannot fill the disk. @@ -100,25 +110,30 @@ pub(super) fn download_from_urls( // Acquire the parent-directory capability up front so every archive read and // write flows through it (never ambient `std::fs`); validation happens here, // before any HTTP request. - let destination = open_download_destination(destination)?; - destination.download_archive(agent, archive_url)?; + let destination_handle = open_download_destination(destination)?; + destination_handle.download_archive(agent, archive_url)?; // Any failure after the archive is written removes it, so a retry never sees // a partial or unverified file. let expected_checksum = fetch_expected_checksum(agent, checksum_url) - .inspect_err(|_| destination.remove_partial_archive())?; + .inspect_err(|_| destination_handle.remove_partial_archive())?; // Re-open the written archive and verify it; `verify_archive_checksum` // consumes the reader, closing the handle before any cleanup below. - let archive = destination.open_archive().inspect_err(|error| { + let archive = destination_handle.open_archive().inspect_err(|error| { warn!( category = CATEGORY_CAPABILITY, - archive_name = %destination.archive_name, + archive_name = %destination_handle.archive_name, error = %error, "failed to reopen archive for verification", ); - destination.remove_partial_archive(); + destination_handle.remove_partial_archive(); })?; - match verify_archive_checksum(archive, destination.path.as_std_path(), &expected_checksum) { + let verification = verify_archive_checksum( + archive, + destination_handle.path.as_std_path(), + &expected_checksum, + ); + match verification { Ok(()) => { debug!( category = CATEGORY_CHECKSUM, @@ -136,7 +151,7 @@ pub(super) fn download_from_urls( "archive checksum verification failed", ); // Remove the unverified archive so a retry never observes stale data. - destination.remove_partial_archive(); + destination_handle.remove_partial_archive(); Err(error) } } @@ -189,9 +204,7 @@ impl DownloadDestination { } /// Reopen the written archive through the capability for verification. - fn open_archive(&self) -> io::Result { - self.dir.open(&self.archive_name) - } + fn open_archive(&self) -> io::Result { self.dir.open(&self.archive_name) } /// Remove a partial or unverified archive; a cleanup failure is only logged. fn remove_partial_archive(&self) { @@ -287,114 +300,5 @@ fn open_destination_dir(destination: &Utf8Path) -> io::Result<(Dir, &str)> { } #[cfg(test)] -mod tests { - //! Tests for downloader error mapping and archive checksum verification. - - use super::*; - use crate::hex::to_lower_hex; - use sha2::{Digest, Sha256}; - use std::io::Write; - use tempfile::TempDir; - - // The under-cap success path is covered end to end by the local-server - // boundary tests; this exercises the over-cap rejection they cannot. - #[test] - fn copy_capped_rejects_a_body_exceeding_the_limit() { - let url = "https://example.test/a.tgz"; - let error = copy_capped(&[0u8; 100][..], &mut Vec::new(), 8, url) - .expect_err("an over-cap body must be rejected"); - assert!( - matches!(&error, DependencyBinaryInstallError::Download { url: u, reason } - if u == url && reason.contains("exceeds the maximum")), - "unexpected error: {error:?}", - ); - } - - #[test] - fn open_destination_dir_rejects_a_path_without_a_file_name() { - // The filesystem root has no file name, so the capability boundary - // cannot derive an archive name and must reject it up front. - let root = Utf8Path::new("/"); - let error = open_destination_dir(root).expect_err("root path has no file name"); - - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert!( - error.to_string().contains("has no file name"), - "error should identify the missing file name, got: {error}", - ); - } - - /// Removes a probe file from a capability-scoped directory when dropped, so - /// a leaked artefact is cleaned up even if an assertion panics first. - struct ProbeCleanup { - dir: Dir, - name: String, - } - - impl Drop for ProbeCleanup { - fn drop(&mut self) { - // A correct run never writes the probe into this directory, so a - // missing file is the expected case. - let _ = self.dir.remove_file(&self.name); - } - } - - #[test] - fn open_destination_dir_writes_into_the_destination_parent_not_the_cwd() { - let temp = TempDir::new().expect("create temp dir"); - let temp_dir = Utf8Path::from_path(temp.path()).expect("temp path is UTF-8"); - // A unique, test-owned probe name derived from the temp directory, so - // it can never collide with — or delete — a pre-existing file in the - // working directory. - let archive_file = format!( - "{}.tgz", - temp_dir.file_name().expect("temp dir has a file name"), - ); - let destination = temp_dir.join(&archive_file); - - // Open the process working directory as a capability, paired with RAII - // cleanup: if a regression leaks the probe here, it is removed on drop - // even when an assertion below panics first. - let cwd_probe = ProbeCleanup { - dir: Dir::open_ambient_dir(".", ambient_authority()).expect("open cwd capability"), - name: archive_file.clone(), - }; - // An independent capability for the destination's directory, used to - // confirm the archive actually lands there rather than trusting the - // directory handle returned by the code under test. - let destination_dir = Dir::open_ambient_dir(temp_dir, ambient_authority()) - .expect("open destination capability"); - - let (dir, archive_name) = open_destination_dir(&destination).expect("open destination dir"); - assert_eq!(archive_name, archive_file.as_str()); - - let mut file = dir - .create(archive_name) - .expect("create archive via capability"); - file.write_all(b"hello world").expect("write archive"); - drop(file); - - // The capability must write into the destination's parent directory... - assert!( - destination_dir.exists(&archive_file), - "archive must exist at the destination path", - ); - // ...and never into the process working directory. This assertion fails - // if `open_destination_dir` opens `.` for a destination with a real - // parent. - assert!( - !cwd_probe.dir.exists(&archive_file), - "capability must not create the archive in the current working directory", - ); - - // Re-open through the same capability and keep the end-to-end checksum - // assertion. - let expected = to_lower_hex(&Sha256::digest(b"hello world")); - let archive = dir.open(archive_name).expect("open archive via capability"); - assert!(verify_archive_checksum(archive, destination.as_std_path(), &expected).is_ok()); - - // `cwd_probe` drops here (or on any earlier panic), removing a leaked - // probe through its capability. - drop(cwd_probe); - } -} +#[path = "downloader_tests.rs"] +mod tests; diff --git a/installer/src/dependency_binaries/install/downloader_boundary_non_utf8_tests.rs b/installer/src/dependency_binaries/install/downloader_boundary_non_utf8_tests.rs new file mode 100644 index 00000000..b4cc1a2f --- /dev/null +++ b/installer/src/dependency_binaries/install/downloader_boundary_non_utf8_tests.rs @@ -0,0 +1,80 @@ +//! Unix-only boundary tests rejecting non-UTF-8 archive destinations. + +use std::{collections::HashMap, io}; + +use super::{ + super::{ + downloader::{ + DependencyArchiveDownloader, + RepositoryArchiveDownloader, + download_from_urls, + }, + http_test_server::LocalServer, + installer::DependencyBinaryInstallError, + }, + agent, +}; + +#[test] +fn download_from_urls_rejects_a_non_utf8_destination_before_any_request() { + use std::os::unix::ffi::OsStringExt as _; + + // No route is registered; the server exists only to prove it is never + // contacted for an invalid destination. + let server = LocalServer::start(HashMap::new()).expect("local server should start"); + + // 0x80 is a lone UTF-8 continuation byte, so this path is never valid UTF-8 + // and must be rejected during validation, before any HTTP request. + let invalid = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![ + b'/', b't', b'm', b'p', b'/', 0x80, b'.', b't', b'g', b'z', + ])); + + let error = download_from_urls( + &agent(), + &server.url("/archive.tgz"), + &server.url("/archive.tgz.sha256"), + &invalid, + ) + .expect_err("non-UTF-8 destination must be rejected"); + + match error { + DependencyBinaryInstallError::Io(source) => { + assert_eq!(source.kind(), io::ErrorKind::InvalidInput); + } + other => panic!("expected an Io error, got {other:?}"), + } + assert!( + server.requested_paths().is_empty(), + "no HTTP request must be made for an invalid destination", + ); +} + +#[test] +fn download_rejects_a_non_utf8_destination_before_any_network_access() { + use std::os::unix::ffi::OsStringExt as _; + + // 0x80 is a lone UTF-8 continuation byte, so this path is never valid UTF-8. + // The production `download` must reject it during path validation, which + // happens before any HTTP call, so the test needs no network. + let invalid = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![ + b'/', b't', b'm', b'p', b'/', 0x80, b'.', b't', b'g', b'z', + ])); + + let downloader = RepositoryArchiveDownloader; + let error = downloader + .download("whitaker-dependency", &invalid) + .expect_err("non-UTF-8 destination must be rejected"); + + match error { + DependencyBinaryInstallError::Io(source) => { + assert_eq!(source.kind(), io::ErrorKind::InvalidInput); + assert!( + source + .to_string() + .contains("destination archive path is not valid UTF-8"), + "error should identify the non-UTF-8 destination, got: {source}", + ); + } + other => panic!("expected an Io error, got {other:?}"), + } +} diff --git a/installer/src/dependency_binaries/install/downloader_boundary_tests.rs b/installer/src/dependency_binaries/install/downloader_boundary_tests.rs index a45210ad..6affab33 100644 --- a/installer/src/dependency_binaries/install/downloader_boundary_tests.rs +++ b/installer/src/dependency_binaries/install/downloader_boundary_tests.rs @@ -5,30 +5,22 @@ //! runs without the network. The server helper lives here (not in `downloader.rs`) //! to keep that module within its size budget; it is test support for these cases. -use super::downloader::download_from_urls; -// Only the non-UTF-8 production-path test (gated `#[cfg(unix)]`) drives the -// trait and concrete downloader; keep these imports on the same gate so other -// platforms do not see them as unused. -#[cfg(unix)] -use super::downloader::{DependencyArchiveDownloader, RepositoryArchiveDownloader}; -use super::http_test_server::{CannedResponse, LocalServer}; -use super::installer::DependencyBinaryInstallError; -use crate::hex::to_lower_hex; +// `Read` is used across platforms for `read_to_end`. +use std::{collections::HashMap, io::Read, path::PathBuf, time::Duration}; + use camino::Utf8Path; -use cap_std::ambient_authority; -use cap_std::fs_utf8::Dir; +use cap_std::{ambient_authority, fs_utf8::Dir}; use rstest::{fixture, rstest}; use sha2::{Digest, Sha256}; -use std::collections::HashMap; -// `io` is only referenced by the `#[cfg(unix)]` non-UTF-8 tests (`io::ErrorKind`); -// `Read` is used across platforms for `read_to_end`. -#[cfg(unix)] -use std::io; -use std::io::Read; -use std::path::PathBuf; -use std::time::Duration; use tempfile::TempDir; +use super::{ + downloader::download_from_urls, + http_test_server::{CannedResponse, LocalServer}, + installer::DependencyBinaryInstallError, +}; +use crate::hex::to_lower_hex; + /// A short-timeout agent fixture for driving the local server. #[fixture] fn agent() -> ureq::Agent { @@ -47,28 +39,23 @@ struct DownloadHarness { } impl DownloadHarness { - fn archive_url(&self) -> String { - self.server.url("/archive.tgz") - } + fn archive_url(&self) -> String { self.server.url("/archive.tgz") } - fn checksum_url(&self) -> String { - self.server.url("/archive.tgz.sha256") - } + fn checksum_url(&self) -> String { self.server.url("/archive.tgz.sha256") } - fn requested_paths(&self) -> Vec { - self.server.requested_paths() - } + fn requested_paths(&self) -> Vec { self.server.requested_paths() } /// Open the destination's parent directory as a capability, for asserting on /// the written archive. - fn destination_dir(&self) -> Dir { - let parent = Utf8Path::from_path( - self.destination - .parent() - .expect("destination has a parent directory"), - ) - .expect("temp path is UTF-8"); - Dir::open_ambient_dir(parent, ambient_authority()).expect("open temp dir capability") + fn destination_dir(&self) -> std::io::Result { + let parent = self + .destination + .parent() + .and_then(Utf8Path::from_path) + .ok_or_else(|| { + std::io::Error::other("destination must have a UTF-8 parent directory") + })?; + Dir::open_ambient_dir(parent, ambient_authority()) } } @@ -79,15 +66,15 @@ impl DownloadHarness { #[fixture] fn download_harness( #[default(HashMap::new())] routes: HashMap, -) -> DownloadHarness { - let server = LocalServer::start(routes); - let temp = TempDir::new().expect("create temp dir"); +) -> std::io::Result { + let server = LocalServer::start(routes)?; + let temp = TempDir::new()?; let destination = temp.path().join("archive.tgz"); - DownloadHarness { + Ok(DownloadHarness { server, _temp: temp, destination, - } + }) } #[rstest] @@ -103,7 +90,7 @@ fn download_from_urls_writes_the_archive_and_requests_both_endpoints(agent: ureq "/archive.tgz.sha256".to_owned(), CannedResponse::ok(format!("{checksum} archive.tgz\n").into_bytes()), ); - let harness = download_harness(routes); + let harness = download_harness(routes).expect("download harness should start"); download_from_urls( &agent, @@ -118,6 +105,7 @@ fn download_from_urls_writes_the_archive_and_requests_both_endpoints(agent: ureq let mut written = Vec::new(); harness .destination_dir() + .expect("destination directory should open") .open("archive.tgz") .expect("open written archive") .read_to_end(&mut written) @@ -140,7 +128,7 @@ fn download_from_urls_reports_a_checksum_mismatch(agent: ureq::Agent) { "/archive.tgz.sha256".to_owned(), CannedResponse::ok(format!("{wrong_checksum} archive.tgz\n").into_bytes()), ); - let harness = download_harness(routes); + let harness = download_harness(routes).expect("download harness should start"); let error = download_from_urls( &agent, @@ -171,7 +159,10 @@ fn download_from_urls_reports_a_checksum_mismatch(agent: ureq::Agent) { // The unverified archive must not survive a checksum mismatch, so a retry // never reads stale data from the destination. assert!( - !harness.destination_dir().exists("archive.tgz"), + !harness + .destination_dir() + .expect("destination directory should open") + .exists("archive.tgz"), "archive must be removed from the destination after a checksum mismatch", ); } @@ -187,7 +178,7 @@ fn download_from_urls_rejects_an_oversized_checksum_sidecar(agent: ureq::Agent) "/archive.tgz.sha256".to_owned(), CannedResponse::ok(vec![b'a'; 128 * 1024]), ); - let harness = download_harness(routes); + let harness = download_harness(routes).expect("download harness should start"); let error = download_from_urls( &agent, @@ -209,7 +200,10 @@ fn download_from_urls_rejects_an_oversized_checksum_sidecar(agent: ureq::Agent) } assert!( - !harness.destination_dir().exists("archive.tgz"), + !harness + .destination_dir() + .expect("destination directory should open") + .exists("archive.tgz"), "archive must be removed after an oversized checksum sidecar", ); } @@ -233,7 +227,7 @@ fn download_from_urls_removes_the_partial_archive_when_the_write_fails(agent: ur "/archive.tgz.sha256".to_owned(), CannedResponse::ok(sidecar.into_bytes()), ); - let harness = download_harness(routes); + let harness = download_harness(routes).expect("download harness should start"); let error = download_from_urls( &agent, @@ -259,7 +253,10 @@ fn download_from_urls_removes_the_partial_archive_when_the_write_fails(agent: ur ); assert!( - !harness.destination_dir().exists("archive.tgz"), + !harness + .destination_dir() + .expect("destination directory should open") + .exists("archive.tgz"), "partial archive must be removed after a write failure", ); } @@ -277,7 +274,7 @@ fn download_from_urls_accepts_an_uppercase_checksum_sidecar(agent: ureq::Agent) "/archive.tgz.sha256".to_owned(), CannedResponse::ok(format!("{checksum} archive.tgz\n").into_bytes()), ); - let harness = download_harness(routes); + let harness = download_harness(routes).expect("download harness should start"); download_from_urls( &agent, @@ -306,7 +303,7 @@ fn download_from_urls_rejects_a_malformed_checksum_sidecar( "/archive.tgz.sha256".to_owned(), CannedResponse::ok(sidecar.as_bytes().to_vec()), ); - let harness = download_harness(routes); + let harness = download_harness(routes).expect("download harness should start"); let checksum_url = harness.checksum_url(); let error = download_from_urls( @@ -327,73 +324,16 @@ fn download_from_urls_rejects_a_malformed_checksum_sidecar( // The archive was written before the checksum failed, so it must be removed. assert!( - !harness.destination_dir().exists("archive.tgz"), + !harness + .destination_dir() + .expect("destination directory should open") + .exists("archive.tgz"), "archive must be removed after a checksum retrieval failure", ); } +// The non-UTF-8 destination cases are Unix-only and live in their own module so +// this one stays within its size budget. #[cfg(unix)] -#[test] -fn download_from_urls_rejects_a_non_utf8_destination_before_any_request() { - use std::os::unix::ffi::OsStringExt as _; - - // No route is registered; the server exists only to prove it is never - // contacted for an invalid destination. - let server = LocalServer::start(HashMap::new()); - - // 0x80 is a lone UTF-8 continuation byte, so this path is never valid UTF-8 - // and must be rejected during validation, before any HTTP request. - let invalid = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![ - b'/', b't', b'm', b'p', b'/', 0x80, b'.', b't', b'g', b'z', - ])); - - let error = download_from_urls( - &agent(), - &server.url("/archive.tgz"), - &server.url("/archive.tgz.sha256"), - &invalid, - ) - .expect_err("non-UTF-8 destination must be rejected"); - - match error { - DependencyBinaryInstallError::Io(source) => { - assert_eq!(source.kind(), io::ErrorKind::InvalidInput); - } - other => panic!("expected an Io error, got {other:?}"), - } - assert!( - server.requested_paths().is_empty(), - "no HTTP request must be made for an invalid destination", - ); -} - -#[cfg(unix)] -#[test] -fn download_rejects_a_non_utf8_destination_before_any_network_access() { - use std::os::unix::ffi::OsStringExt as _; - - // 0x80 is a lone UTF-8 continuation byte, so this path is never valid UTF-8. - // The production `download` must reject it during path validation, which - // happens before any HTTP call, so the test needs no network. - let invalid = std::path::PathBuf::from(std::ffi::OsString::from_vec(vec![ - b'/', b't', b'm', b'p', b'/', 0x80, b'.', b't', b'g', b'z', - ])); - - let downloader = RepositoryArchiveDownloader; - let error = downloader - .download("whitaker-dependency", &invalid) - .expect_err("non-UTF-8 destination must be rejected"); - - match error { - DependencyBinaryInstallError::Io(source) => { - assert_eq!(source.kind(), io::ErrorKind::InvalidInput); - assert!( - source - .to_string() - .contains("destination archive path is not valid UTF-8"), - "error should identify the non-UTF-8 destination, got: {source}", - ); - } - other => panic!("expected an Io error, got {other:?}"), - } -} +#[path = "downloader_boundary_non_utf8_tests.rs"] +mod non_utf8; diff --git a/installer/src/dependency_binaries/install/downloader_tests.rs b/installer/src/dependency_binaries/install/downloader_tests.rs new file mode 100644 index 00000000..e3999cad --- /dev/null +++ b/installer/src/dependency_binaries/install/downloader_tests.rs @@ -0,0 +1,112 @@ +//! Tests for downloader error mapping and archive checksum verification. + +use std::io::Write; + +use sha2::{Digest, Sha256}; +use tempfile::TempDir; + +use super::*; +use crate::hex::to_lower_hex; + +// The under-cap success path is covered end to end by the local-server +// boundary tests; this exercises the over-cap rejection they cannot. +#[test] +fn copy_capped_rejects_a_body_exceeding_the_limit() { + let url = "https://example.test/a.tgz"; + let error = copy_capped(&[0u8; 100][..], &mut Vec::new(), 8, url) + .expect_err("an over-cap body must be rejected"); + assert!( + matches!(&error, DependencyBinaryInstallError::Download { url: u, reason } + if u == url && reason.contains("exceeds the maximum")), + "unexpected error: {error:?}", + ); +} + +#[test] +fn open_destination_dir_rejects_a_path_without_a_file_name() { + // The filesystem root has no file name, so the capability boundary + // cannot derive an archive name and must reject it up front. + let root = Utf8Path::new("/"); + let error = open_destination_dir(root).expect_err("root path has no file name"); + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!( + error.to_string().contains("has no file name"), + "error should identify the missing file name, got: {error}", + ); +} + +/// Removes a probe file from a capability-scoped directory when dropped, so +/// a leaked artefact is cleaned up even if an assertion panics first. +struct ProbeCleanup { + dir: Dir, + name: String, +} + +impl Drop for ProbeCleanup { + fn drop(&mut self) { + if self.dir.remove_file(&self.name).is_err() { + // A correct run never writes the probe into this directory, so + // a missing file is the expected case. + } + } +} + +#[test] +fn open_destination_dir_writes_into_the_destination_parent_not_the_cwd() { + let temp = TempDir::new().expect("create temp dir"); + let temp_dir = Utf8Path::from_path(temp.path()).expect("temp path is UTF-8"); + // A unique, test-owned probe name derived from the temp directory, so + // it can never collide with — or delete — a pre-existing file in the + // working directory. + let archive_file = format!( + "{}.tgz", + temp_dir.file_name().expect("temp dir has a file name"), + ); + let destination = temp_dir.join(&archive_file); + + // Open the process working directory as a capability, paired with RAII + // cleanup: if a regression leaks the probe here, it is removed on drop + // even when an assertion below panics first. + let cwd_probe = ProbeCleanup { + dir: Dir::open_ambient_dir(".", ambient_authority()).expect("open cwd capability"), + name: archive_file.clone(), + }; + // An independent capability for the destination's directory, used to + // confirm the archive actually lands there rather than trusting the + // directory handle returned by the code under test. + let destination_dir = + Dir::open_ambient_dir(temp_dir, ambient_authority()).expect("open destination capability"); + + let (dir, archive_name) = open_destination_dir(&destination).expect("open destination dir"); + assert_eq!(archive_name, archive_file.as_str()); + + let mut file = dir + .create(archive_name) + .expect("create archive via capability"); + file.write_all(b"hello world").expect("write archive"); + drop(file); + + // The capability must write into the destination's parent directory... + assert!( + destination_dir.exists(&archive_file), + "archive must exist at the destination path", + ); + // ...and never into the process working directory. This assertion fails + // if `open_destination_dir` opens `.` for a destination with a real + // parent. + assert!( + !cwd_probe.dir.exists(&archive_file), + "capability must not create the archive in the current working directory", + ); + + // Re-open through the same capability and keep the end-to-end checksum + // assertion. + let expected = to_lower_hex(&Sha256::digest(b"hello world")); + let archive = dir.open(archive_name).expect("open archive via capability"); + assert!(verify_archive_checksum(archive, destination.as_std_path(), &expected).is_ok()); + + // `cwd_probe` drops here (or on any earlier panic), removing a leaked + // probe through its capability. + drop(cwd_probe); +} diff --git a/installer/src/dependency_binaries/install/extractor.rs b/installer/src/dependency_binaries/install/extractor.rs index 1fcde049..4eca82d0 100644 --- a/installer/src/dependency_binaries/install/extractor.rs +++ b/installer/src/dependency_binaries/install/extractor.rs @@ -1,11 +1,15 @@ //! Archive extraction helpers for repository-hosted dependency binaries. -use super::installer::DependencyBinaryInstallError; -use std::fs::File; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; +use std::{ + fs::File, + io::{self, Read, Write}, + path::{Path, PathBuf}, +}; + use tempfile::NamedTempFile; +use super::installer::DependencyBinaryInstallError; + /// Extracts a single executable from dependency archives. #[cfg_attr(test, mockall::automock)] pub trait DependencyArchiveExtractor { @@ -57,8 +61,8 @@ pub(crate) fn extract_from_tgz( archive: archive_path.to_path_buf(), reason: error.to_string(), }; - for entry in archive.entries().map_err(map_archive_err)? { - let mut entry = entry.map_err(map_archive_err)?; + for entry_result in archive.entries().map_err(map_archive_err)? { + let mut entry = entry_result.map_err(map_archive_err)?; let path = entry.path().map_err(map_archive_err)?.into_owned(); if path == Path::new(expected_member_path) { return extract_entry_to_destination(&mut entry, expected_member_path, destination_dir); @@ -84,17 +88,17 @@ pub(crate) fn extract_from_zip( })?; for index in 0..archive.len() { - let mut file = + let mut member = archive .by_index(index) .map_err(|error| DependencyBinaryInstallError::Extraction { archive: archive_path.to_path_buf(), reason: error.to_string(), })?; - if file.name() != expected_member_path { + if member.name() != expected_member_path { continue; } - return extract_entry_to_destination(&mut file, expected_member_path, destination_dir); + return extract_entry_to_destination(&mut member, expected_member_path, destination_dir); } Err(DependencyBinaryInstallError::MissingBinaryInArchive { diff --git a/installer/src/dependency_binaries/install/http_test_server.rs b/installer/src/dependency_binaries/install/http_test_server.rs index 17bd35bd..62497a91 100644 --- a/installer/src/dependency_binaries/install/http_test_server.rs +++ b/installer/src/dependency_binaries/install/http_test_server.rs @@ -5,14 +5,20 @@ //! Kept in its own module so the boundary-test file stays within its size //! budget. -use std::collections::HashMap; -use std::io; -use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::{ + collections::HashMap, + io, + io::{BufRead, BufReader, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, + Mutex, + PoisonError, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; /// One canned HTTP/1.1 response body served for a matched path. `declared_len` /// is the advertised `Content-Length`, which normally matches `body`. @@ -52,41 +58,47 @@ pub(super) struct LocalServer { } impl LocalServer { - pub(super) fn start(routes: HashMap) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback listener"); - let port = listener.local_addr().expect("resolve local addr").port(); - listener - .set_nonblocking(true) - .expect("set listener non-blocking"); + pub(super) fn start(routes: HashMap) -> io::Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; let requested = Arc::new(Mutex::new(Vec::new())); let stop = Arc::new(AtomicBool::new(false)); let handle = { - let requested = Arc::clone(&requested); - let stop = Arc::clone(&stop); - thread::spawn(move || run_server(&listener, &routes, &requested, &stop)) + let requested_for_thread = Arc::clone(&requested); + let stop_for_thread = Arc::clone(&stop); + thread::spawn(move || { + run_server(&listener, &routes, &requested_for_thread, &stop_for_thread); + }) }; - Self { + Ok(Self { base_url: format!("http://127.0.0.1:{port}"), requested, stop, handle: Some(handle), - } + }) } - pub(super) fn url(&self, path: &str) -> String { - format!("{}{path}", self.base_url) - } + pub(super) fn url(&self, path: &str) -> String { format!("{}{path}", self.base_url) } pub(super) fn requested_paths(&self) -> Vec { - self.requested.lock().expect("lock requested paths").clone() + // A poisoned lock only means a test thread panicked while logging a + // request; the recorded paths remain a valid `Vec`. + self.requested + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() } } impl Drop for LocalServer { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); + if let Some(handle) = self.handle.take() + && handle.join().is_err() + { + // The server thread panicked; tests observe failures through the + // requests they make, so nothing further can be reported here. } } } @@ -112,6 +124,14 @@ fn run_server( /// Read one request, record its path, and write the matching canned response /// (or a 404). `Connection: close` lets the client frame the response end. +/// Restores blocking mode and bounds reads and writes on an accepted socket. +fn configure_connection(stream: &TcpStream) -> io::Result<()> { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + Ok(()) +} + fn serve_connection( mut stream: TcpStream, routes: &HashMap, @@ -120,9 +140,10 @@ fn serve_connection( // Restore blocking mode and bound reads/writes on the accepted connection // (the listener is non-blocking only so the accept loop can poll for // shutdown). `try_clone` shares the socket, so `peer` inherits these. - let _ = stream.set_nonblocking(false); - let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + // Drop the connection if the socket cannot be configured. + if configure_connection(&stream).is_err() { + return; + } let Ok(peer) = stream.try_clone() else { return; }; @@ -139,20 +160,30 @@ fn serve_connection( // Drain the remaining request headers up to the blank line. loop { let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) => break, - Ok(_) if line == "\r\n" || line == "\n" => break, - Ok(_) => {} - Err(_) => break, + let bytes_read = reader.read_line(&mut line).unwrap_or(0); + let is_blank_line = matches!(line.as_str(), "\r\n" | "\n"); + if bytes_read == 0 || is_blank_line { + break; } } // Resolve the route first — the returned references borrow `routes`, not // `path` — so the owned `path` can then move into the request log. - let (status_line, body, declared_len): (&str, &[u8], usize) = match routes.get(&path) { - Some(response) => (response.status_line, &response.body, response.declared_len), - None => ("404 Not Found", b"not found", b"not found".len()), - }; - requested.lock().expect("lock requested paths").push(path); + let not_found: &[u8] = b"not found"; + let (status_line, body, declared_len) = routes.get(&path).map_or_else( + || ("404 Not Found", not_found, not_found.len()), + |response| { + ( + response.status_line, + response.body.as_slice(), + response.declared_len, + ) + }, + ); + // See `LocalServer::requested_paths` for why poisoning is recovered here. + requested + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(path); let header = format!( concat!( "HTTP/1.1 {}\r\n", @@ -162,7 +193,11 @@ fn serve_connection( ), status_line, declared_len, ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(body); - let _ = stream.flush(); + let write_result = stream + .write_all(header.as_bytes()) + .and_then(|()| stream.write_all(body)) + .and_then(|()| stream.flush()); + if write_result.is_err() { + // The client disconnected early; there is nothing further to serve. + } } diff --git a/installer/src/dependency_binaries/install/installer.rs b/installer/src/dependency_binaries/install/installer.rs index 1130bdc2..7c896d7b 100644 --- a/installer/src/dependency_binaries/install/installer.rs +++ b/installer/src/dependency_binaries/install/installer.rs @@ -1,17 +1,20 @@ //! Installer orchestration for repository-hosted dependency binaries. -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use thiserror::Error; +use std::{ + fs, + io, + path::{Path, PathBuf}, +}; -use crate::artefact::target::TargetTriple; -use crate::dirs::BaseDirs; +use thiserror::Error; -use super::super::manifest::DependencyBinary; -use super::downloader::{DependencyArchiveDownloader, RepositoryArchiveDownloader}; -use super::extractor::{DependencyArchiveExtractor, RepositoryArchiveExtractor}; -use super::metadata::{archive_filename, expected_member_path}; +use super::{ + super::manifest::DependencyBinary, + downloader::{DependencyArchiveDownloader, RepositoryArchiveDownloader}, + extractor::{DependencyArchiveExtractor, RepositoryArchiveExtractor}, + metadata::{archive_filename, expected_member_path}, +}; +use crate::{artefact::target::TargetTriple, dirs::BaseDirs}; /// Errors returned while installing repository-hosted dependency binaries. #[derive(Debug, Error)] @@ -80,9 +83,7 @@ pub enum DependencyBinaryInstallError { impl DependencyBinaryInstallError { /// Returns `true` when the failure is caused by a missing repository asset. #[must_use] - pub(crate) fn is_not_found(&self) -> bool { - matches!(self, Self::NotFound { .. }) - } + pub(crate) const fn is_not_found(&self) -> bool { matches!(self, Self::NotFound { .. }) } } /// Installs dependency binaries from repository-hosted release assets. @@ -143,7 +144,7 @@ pub(crate) fn install_with( ) -> Result { let bin_dir = support .dirs - .bin_dir() + .executables() .ok_or(DependencyBinaryInstallError::MissingBinDir)?; fs::create_dir_all(bin_dir.as_path())?; diff --git a/installer/src/dependency_binaries/install/metadata.rs b/installer/src/dependency_binaries/install/metadata.rs index 4ebf4810..61a7853d 100644 --- a/installer/src/dependency_binaries/install/metadata.rs +++ b/installer/src/dependency_binaries/install/metadata.rs @@ -1,8 +1,7 @@ //! Target and filename helpers for dependency-binary installation. -use crate::artefact::target::TargetTriple; - use super::super::manifest::DependencyBinary; +use crate::artefact::target::TargetTriple; const PROVENANCE_FILENAME: &str = "dependency-binaries-licences.md"; @@ -37,9 +36,7 @@ pub fn host_target() -> Option { /// Return the release-side provenance asset filename. #[must_use] -pub fn provenance_filename() -> &'static str { - PROVENANCE_FILENAME -} +pub const fn provenance_filename() -> &'static str { PROVENANCE_FILENAME } /// Compute the platform-specific executable name for a dependency binary. #[must_use] diff --git a/installer/src/dependency_binaries/install/mod.rs b/installer/src/dependency_binaries/install/mod.rs index f884de72..cd54d7ce 100644 --- a/installer/src/dependency_binaries/install/mod.rs +++ b/installer/src/dependency_binaries/install/mod.rs @@ -22,6 +22,8 @@ pub use extractor::DependencyArchiveExtractor; #[cfg(test)] pub use installer::MockDependencyBinaryInstaller; pub use installer::{ - DependencyBinaryInstallError, DependencyBinaryInstaller, RepositoryDependencyBinaryInstaller, + DependencyBinaryInstallError, + DependencyBinaryInstaller, + RepositoryDependencyBinaryInstaller, }; pub use metadata::{archive_filename, binary_filename, host_target, provenance_filename}; diff --git a/installer/src/dependency_binaries/install/tests.rs b/installer/src/dependency_binaries/install/tests.rs index 6b82a999..92b59824 100644 --- a/installer/src/dependency_binaries/install/tests.rs +++ b/installer/src/dependency_binaries/install/tests.rs @@ -1,37 +1,48 @@ //! Unit tests for repository-hosted dependency-binary installation helpers. -use super::downloader::MockDependencyArchiveDownloader; -use super::extractor::MockDependencyArchiveExtractor; -use super::installer::{InstallSupport, install_with}; -use super::metadata::expected_member_path; -use super::{archive_filename, *}; -use crate::dirs::MockBaseDirs; -use crate::installer_packaging::TargetTriple; +use std::{ + fs, + path::{Path, PathBuf}, +}; + use mockall::predicate::{always, eq}; use rstest::{fixture, rstest}; -use std::fs; -use std::path::{Path, PathBuf}; + +use super::{ + archive_filename, + downloader::MockDependencyArchiveDownloader, + extractor::MockDependencyArchiveExtractor, + installer::{InstallSupport, install_with}, + metadata::expected_member_path, + *, +}; +use crate::{dirs::MockBaseDirs, installer_packaging::TargetTriple}; /// Build a deterministic installation setup for success and missing-binary /// scenarios. fn run_install_scenario( dependency_name: &str, writes_binary: bool, -) -> ( +) -> std::io::Result<( tempfile::TempDir, PathBuf, Result, -) { - let temp_dir = tempfile::tempdir().expect("temp dir"); +)> { + let temp_dir = tempfile::tempdir()?; let bin_dir = temp_dir.path().join("bin"); let mut dirs = MockBaseDirs::new(); - dirs.expect_bin_dir() + dirs.expect_executables() .once() .return_const(Some(bin_dir.clone())); let dependency = crate::dependency_binaries::find_dependency_binary(dependency_name) - .expect("dependency manifest should load") - .expect("dependency should exist"); - let target = TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"); + .map_err(std::io::Error::other)? + .ok_or_else(|| { + std::io::Error::other(format!( + "dependency `{dependency_name}` is not in the manifest" + )) + })?; + let target = + TargetTriple::try_from("x86_64-unknown-linux-gnu").map_err(std::io::Error::other)?; let mut downloader = MockDependencyArchiveDownloader::new(); let expected_archive = archive_filename(dependency, &target); downloader @@ -54,9 +65,11 @@ fn run_install_scenario( binary: expected_member_path.to_owned(), }); } - let binary_name = Path::new(expected_member_path) - .file_name() - .expect("member path should include a filename"); + let Some(binary_name) = Path::new(expected_member_path).file_name() else { + return Err(DependencyBinaryInstallError::MissingBinaryInArchive { + binary: expected_member_path.to_owned(), + }); + }; let path = destination_dir.join(binary_name); fs::write(&path, b"fake binary")?; Ok(path) @@ -71,24 +84,24 @@ fn run_install_scenario( }, ); - (temp_dir, bin_dir, result) + Ok((temp_dir, bin_dir, result)) } #[fixture] -fn cargo_dylint_install_result() -> ( +fn cargo_dylint_install_result() -> std::io::Result<( tempfile::TempDir, PathBuf, Result, -) { +)> { run_install_scenario("cargo-dylint", true) } #[fixture] -fn missing_binary_install_result() -> ( +fn missing_binary_install_result() -> std::io::Result<( tempfile::TempDir, PathBuf, Result, -) { +)> { run_install_scenario("dylint-link", false) } @@ -115,12 +128,13 @@ fn binary_filename_adds_windows_suffix() { #[rstest] fn install_with_creates_missing_bin_directory( - cargo_dylint_install_result: ( + #[from(cargo_dylint_install_result)] scenario: std::io::Result<( tempfile::TempDir, PathBuf, Result, - ), + )>, ) { + let cargo_dylint_install_result = scenario.expect("install scenario should be staged"); let (_temp_dir, bin_dir, result) = cargo_dylint_install_result; let installed_path = result.expect("install"); assert!(bin_dir.is_dir()); @@ -137,12 +151,13 @@ fn install_with_creates_missing_bin_directory( #[rstest] fn install_with_returns_error_when_binary_missing_after_extract( - missing_binary_install_result: ( + #[from(missing_binary_install_result)] scenario: std::io::Result<( tempfile::TempDir, PathBuf, Result, - ), + )>, ) { + let missing_binary_install_result = scenario.expect("install scenario should be staged"); let (_temp_dir, _bin_dir, result) = missing_binary_install_result; let dependency = crate::dependency_binaries::find_dependency_binary("dylint-link") .expect("dependency manifest should load") @@ -154,7 +169,7 @@ fn install_with_returns_error_when_binary_missing_after_extract( DependencyBinaryInstallError::MissingBinaryInArchive { binary } => { assert_eq!(binary, expected_path); } - other => panic!("expected MissingBinaryInArchive, got {:?}", other), + other => panic!("expected MissingBinaryInArchive, got {other:?}"), } } diff --git a/installer/src/dependency_binaries/manifest.rs b/installer/src/dependency_binaries/manifest.rs index 51bfdc95..f9c3a602 100644 --- a/installer/src/dependency_binaries/manifest.rs +++ b/installer/src/dependency_binaries/manifest.rs @@ -3,8 +3,9 @@ //! The committed `installer/dependency-binaries.toml` file is the single source //! of truth for required dependency-tool versions, licences, and provenance. -use serde::Deserialize; use std::sync::OnceLock; + +use serde::Deserialize; use thiserror::Error; /// One repository-owned dependency binary requirement. @@ -13,12 +14,13 @@ use thiserror::Error; /// /// ``` /// use whitaker_installer::dependency_binaries::{ -/// parse_manifest, required_dependency_binaries, DependencyBinary +/// DependencyBinary, +/// parse_manifest, +/// required_dependency_binaries, /// }; /// /// // Parse the embedded manifest to obtain dependency binaries -/// let dependencies = required_dependency_binaries() -/// .expect("embedded manifest should be valid"); +/// let dependencies = required_dependency_binaries().expect("embedded manifest should be valid"); /// /// // Access fields on a dependency binary /// if let Some(tool) = dependencies.iter().find(|d| d.package() == "cargo-dylint") { @@ -45,9 +47,7 @@ impl DependencyBinary { /// /// See the [`DependencyBinary`] type documentation for a complete example. #[must_use] - pub fn package(&self) -> &str { - &self.package - } + pub fn package(&self) -> &str { &self.package } /// Return the executable basename without any platform suffix. /// @@ -55,9 +55,7 @@ impl DependencyBinary { /// /// See the [`DependencyBinary`] type documentation for a complete example. #[must_use] - pub fn binary(&self) -> &str { - &self.binary - } + pub fn binary(&self) -> &str { &self.binary } /// Return the required upstream version. /// @@ -65,9 +63,7 @@ impl DependencyBinary { /// /// See the [`DependencyBinary`] type documentation for a complete example. #[must_use] - pub fn version(&self) -> &str { - &self.version - } + pub fn version(&self) -> &str { &self.version } /// Return the upstream licence string recorded in the manifest. /// @@ -75,9 +71,7 @@ impl DependencyBinary { /// /// See the [`DependencyBinary`] type documentation for a complete example. #[must_use] - pub fn license(&self) -> &str { - &self.license - } + pub fn license(&self) -> &str { &self.license } /// Return the upstream repository URL. /// @@ -85,9 +79,7 @@ impl DependencyBinary { /// /// See the [`DependencyBinary`] type documentation for a complete example. #[must_use] - pub fn repository(&self) -> &str { - &self.repository - } + pub fn repository(&self) -> &str { &self.repository } } #[derive(Debug, Deserialize)] @@ -107,9 +99,7 @@ pub enum ManifestError { } impl From for ManifestError { - fn from(error: toml::de::Error) -> Self { - ManifestError::ParseError(error.to_string()) - } + fn from(error: toml::de::Error) -> Self { Self::ParseError(error.to_string()) } } /// Return the embedded manifest contents. @@ -123,9 +113,7 @@ impl From for ManifestError { /// assert!(contents.contains("dependency_binaries")); /// ``` #[must_use] -pub fn manifest_contents() -> &'static str { - include_str!("../../dependency-binaries.toml") -} +pub const fn manifest_contents() -> &'static str { include_str!("../../dependency-binaries.toml") } /// Parse manifest TOML into typed dependency entries. /// @@ -140,12 +128,10 @@ pub fn manifest_contents() -> &'static str { /// Parse the embedded manifest: /// /// ``` -/// use whitaker_installer::dependency_binaries::{ -/// manifest_contents, parse_manifest -/// }; +/// use whitaker_installer::dependency_binaries::{manifest_contents, parse_manifest}; /// -/// let dependencies = parse_manifest(manifest_contents()) -/// .expect("embedded manifest should be valid"); +/// let dependencies = +/// parse_manifest(manifest_contents()).expect("embedded manifest should be valid"); /// /// assert!(!dependencies.is_empty()); /// ``` @@ -190,8 +176,8 @@ pub fn manifest_contents() -> &'static str { /// repository = "https://github.com/trailofbits/dylint" /// "#; /// -/// let error = parse_manifest(manifest_with_duplicates) -/// .expect_err("should reject duplicate packages"); +/// let error = +/// parse_manifest(manifest_with_duplicates).expect_err("should reject duplicate packages"); /// assert!(error.to_string().contains("cargo-dylint")); /// ``` pub fn parse_manifest(contents: &str) -> Result, ManifestError> { @@ -201,8 +187,8 @@ pub fn parse_manifest(contents: &str) -> Result, ManifestE let mut seen_packages = std::collections::HashSet::new(); for dependency in &manifest.dependency_binaries { let package = dependency.package(); - if !seen_packages.insert(package.to_string()) { - return Err(ManifestError::DuplicatePackage(package.to_string())); + if !seen_packages.insert(package.to_owned()) { + return Err(ManifestError::DuplicatePackage(package.to_owned())); } } @@ -221,8 +207,7 @@ pub fn parse_manifest(contents: &str) -> Result, ManifestE /// ``` /// use whitaker_installer::dependency_binaries::required_dependency_binaries; /// -/// let dependencies = required_dependency_binaries() -/// .expect("embedded manifest should be valid"); +/// let dependencies = required_dependency_binaries().expect("embedded manifest should be valid"); /// /// // Iterate over all required dependency binaries /// for tool in dependencies { @@ -264,8 +249,7 @@ pub fn required_dependency_binaries() -> Result<&'static [DependencyBinary], Man /// ``` /// use whitaker_installer::dependency_binaries::find_dependency_binary; /// -/// let result = find_dependency_binary("non-existent-package") -/// .expect("manifest should parse"); +/// let result = find_dependency_binary("non-existent-package").expect("manifest should parse"); /// /// assert!(result.is_none()); /// ``` @@ -293,9 +277,12 @@ pub fn find_dependency_binary( #[cfg(test)] mod tests { - use super::*; + //! Tests for the embedded dependency-binary manifest. + use rstest::{fixture, rstest}; + use super::*; + #[fixture] fn missing_field_manifest() -> &'static str { r#" diff --git a/installer/src/dependency_binaries/mod.rs b/installer/src/dependency_binaries/mod.rs index abae4ad4..6d322818 100644 --- a/installer/src/dependency_binaries/mod.rs +++ b/installer/src/dependency_binaries/mod.rs @@ -10,11 +10,21 @@ mod manifest; #[cfg(test)] pub use install::MockDependencyBinaryInstaller; pub use install::{ - DependencyArchiveDownloader, DependencyArchiveExtractor, DependencyBinaryInstallError, - DependencyBinaryInstaller, RepositoryDependencyBinaryInstaller, archive_filename, - binary_filename, host_target, provenance_filename, + DependencyArchiveDownloader, + DependencyArchiveExtractor, + DependencyBinaryInstallError, + DependencyBinaryInstaller, + RepositoryDependencyBinaryInstaller, + archive_filename, + binary_filename, + host_target, + provenance_filename, }; pub use manifest::{ - DependencyBinary, ManifestError, find_dependency_binary, manifest_contents, parse_manifest, + DependencyBinary, + ManifestError, + find_dependency_binary, + manifest_contents, + parse_manifest, required_dependency_binaries, }; diff --git a/installer/src/dependency_packaging.rs b/installer/src/dependency_packaging.rs index a8f137c6..ee6be6ce 100644 --- a/installer/src/dependency_packaging.rs +++ b/installer/src/dependency_packaging.rs @@ -4,12 +4,16 @@ //! `cargo-dylint` and `dylint-link` with deterministic names and inner //! directories for each supported target. +use std::{ + fs, + io, + path::{Path, PathBuf}, +}; + +use thiserror::Error; + use crate::dependency_binaries::{DependencyBinary, binary_filename, provenance_filename}; pub use crate::installer_packaging::{ArchiveFormat, TargetTriple}; -use std::fs; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use thiserror::Error; /// Parameters for packaging a dependency binary archive. #[derive(Debug)] @@ -71,8 +75,14 @@ pub fn archive_format(target: &TargetTriple) -> ArchiveFormat { } /// Package a dependency executable into the deterministic repository archive. +/// +/// # Errors +/// +/// Returns [`DependencyPackagingError::BinaryNotFound`] when +/// `params.binary_path` is not a file, and propagates I/O or archive +/// failures encountered while writing the archive. pub fn package_dependency_binary( - params: DependencyPackageParams, + params: &DependencyPackageParams, ) -> Result { if !params.binary_path.is_file() { return Err(DependencyPackagingError::BinaryNotFound( @@ -96,10 +106,10 @@ pub fn package_dependency_binary( } } - let archive_path = archive_path.canonicalize()?; + let canonical_archive_path = archive_path.canonicalize()?; Ok(DependencyPackageOutput { - archive_path, + archive_path: canonical_archive_path, archive_name, }) } @@ -107,21 +117,31 @@ pub fn package_dependency_binary( /// Render the shared dependency-binary provenance and licence document. #[must_use] pub fn render_provenance_markdown(dependencies: &[DependencyBinary]) -> String { - let mut output = String::from("# Dependency binary licences and provenance\n\n"); - output.push_str( - "Whitaker publishes the following third-party dependency binaries from repository releases.\n\n", - ); - for dependency in dependencies { - output.push_str(&format!("## {}\n\n", dependency.package())); - output.push_str(&format!("- Binary: `{}`\n", dependency.binary())); - output.push_str(&format!("- Version: `{}`\n", dependency.version())); - output.push_str(&format!("- Licence: `{}`\n", dependency.license())); - output.push_str(&format!("- Repository: {}\n\n", dependency.repository())); - } - output + let sections: String = dependencies.iter().map(render_dependency_section).collect(); + format!( + "# Dependency binary licences and provenance\n\nWhitaker publishes the following \ + third-party dependency binaries from repository releases.\n\n{sections}" + ) +} + +/// Render the provenance section for a single dependency binary. +fn render_dependency_section(dependency: &DependencyBinary) -> String { + format!( + "## {}\n\n- Binary: `{}`\n- Version: `{}`\n- Licence: `{}`\n- Repository: {}\n\n", + dependency.package(), + dependency.binary(), + dependency.version(), + dependency.license(), + dependency.repository() + ) } /// Write the shared provenance document to `output_dir`. +/// +/// # Errors +/// +/// Returns [`DependencyPackagingError::Io`] when the output directory +/// cannot be created or the document cannot be written. pub fn write_provenance_markdown( output_dir: &Path, dependencies: &[DependencyBinary], @@ -144,8 +164,8 @@ fn create_tgz_archive( let mut archive = tar::Builder::new(gz_encoder); archive.mode(tar::HeaderMode::Deterministic); archive.append_path_with_name(binary_path, format!("{inner_dir}/{binary_name}"))?; - let gz_encoder = archive.into_inner()?; - gz_encoder.finish()?; + let finished_encoder = archive.into_inner()?; + finished_encoder.finish()?; Ok(()) } @@ -163,14 +183,7 @@ fn create_zip_archive( .compression_method(zip::CompressionMethod::Deflated); zip_writer.start_file(format!("{inner_dir}/{binary_name}"), options)?; let mut binary_file = fs::File::open(binary_path)?; - let mut buffer = [0u8; 8_192]; - loop { - let bytes_read = binary_file.read(&mut buffer)?; - if bytes_read == 0 { - break; - } - zip_writer.write_all(&buffer[..bytes_read])?; - } + io::copy(&mut binary_file, &mut zip_writer)?; zip_writer.finish()?; Ok(()) } diff --git a/installer/src/dependency_packaging_tests.rs b/installer/src/dependency_packaging_tests.rs index bb7fa5bf..4cf0fe58 100644 --- a/installer/src/dependency_packaging_tests.rs +++ b/installer/src/dependency_packaging_tests.rs @@ -1,15 +1,25 @@ //! Unit tests for dependency-binary packaging helpers. -use crate::dependency_binaries::find_dependency_binary; -use crate::dependency_packaging::{ - ArchiveFormat, DependencyPackageParams, DependencyPackagingError, archive_format, - inner_dir_name, package_dependency_binary, render_provenance_markdown, - write_provenance_markdown, -}; -use crate::installer_packaging::TargetTriple; -use rstest::{fixture, rstest}; use std::fs; +use rstest::{fixture, rstest}; + +use crate::{ + artefact::error::ArtefactError, + dependency_binaries::find_dependency_binary, + dependency_packaging::{ + ArchiveFormat, + DependencyPackageParams, + DependencyPackagingError, + archive_format, + inner_dir_name, + package_dependency_binary, + render_provenance_markdown, + write_provenance_markdown, + }, + installer_packaging::TargetTriple, +}; + struct PackagingCase<'a> { package: &'a str, binary_name: &'a str, @@ -17,20 +27,15 @@ struct PackagingCase<'a> { should_expect_success: bool, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn linux_target() -> TargetTriple { - TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target") +fn linux_target() -> std::result::Result { + TargetTriple::try_from("x86_64-unknown-linux-gnu") } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn windows_target() -> TargetTriple { - TargetTriple::try_from("x86_64-pc-windows-msvc").expect("valid target") -} - -#[fixture] -fn temp_dir() -> tempfile::TempDir { - tempfile::tempdir().expect("temp dir") -} +fn temp_dir() -> std::io::Result { tempfile::tempdir() } #[test] fn archive_format_matches_target_platform() { @@ -68,10 +73,13 @@ fn inner_dir_name_uses_dependency_version() { should_expect_success: true, })] fn package_dependency_binary_handles_binary_presence( - linux_target: TargetTriple, - temp_dir: tempfile::TempDir, + #[from(linux_target)] linux_target_res: std::result::Result, + #[from(temp_dir)] temp_dir_res: std::io::Result, #[case] case: PackagingCase<'_>, ) { + let linux_target = linux_target_res.expect("linux target triple should validate"); + let temp_dir = temp_dir_res.expect("temporary directory should be created"); + let dependency = find_dependency_binary(case.package) .expect("dependency manifest should load") .expect("dependency should exist"); @@ -80,7 +88,7 @@ fn package_dependency_binary_handles_binary_presence( fs::write(&binary_path, b"binary").expect("write fake binary"); } - let result = package_dependency_binary(DependencyPackageParams { + let result = package_dependency_binary(&DependencyPackageParams { dependency: dependency.clone(), target: linux_target.clone(), binary_path: binary_path.clone(), @@ -147,7 +155,7 @@ fn write_provenance_markdown_writes_expected_file() { .expect("dependency should exist") .clone(), ]; - let temp_dir = tempfile::tempdir().expect("temp dir"); + let temp_dir = tempfile::tempdir().expect("temporary directory should be created"); let path = write_provenance_markdown(temp_dir.path(), &dependencies) .expect("provenance file should be written"); diff --git a/installer/src/deps/install.rs b/installer/src/deps/install.rs index acd5a801..9b13f87a 100644 --- a/installer/src/deps/install.rs +++ b/installer/src/deps/install.rs @@ -1,14 +1,31 @@ //! Install orchestration types and helpers for dependency tools. -use crate::dependency_binaries::{DependencyBinaryInstaller, find_dependency_binary}; -use crate::error::{InstallerError, Result}; -use crate::installer_packaging::TargetTriple; -use std::io::Write; -use std::process::Output; +use std::{io::Write, process::Output}; use super::{ - CARGO_DYLINT_TOOL, CommandExecutor, DEPENDENCY_TOOLS, DYLINT_LINK_TOOL, DependencyTool, - DylintToolStatus, is_binstall_available, is_tool_installed, + CARGO_DYLINT_TOOL, + CommandExecutor, + DEPENDENCY_TOOLS, + DYLINT_LINK_TOOL, + DependencyTool, + DylintToolStatus, + is_binstall_available, + is_tool_installed, +}; +use crate::{ + dependency_binaries::{DependencyBinaryInstaller, find_dependency_binary}, + error::{InstallerError, Result}, + installer_packaging::TargetTriple, +}; + +#[path = "install_repository.rs"] +mod repository; + +use repository::{ + RepositoryInstall, + RepositoryInstallRequest, + attempt_repository_install, + resolve_dependency_binary, }; pub(super) struct RepositoryInstallContext<'a> { @@ -25,14 +42,14 @@ pub(super) struct InstallContext<'a> { pub(super) fn install_missing_tools( executor: &dyn CommandExecutor, - status: &DylintToolStatus, + status: DylintToolStatus, stderr: &mut dyn Write, context: &InstallContext<'_>, ) -> Result<()> { - let mut remaining_status = *status; + let mut remaining_status = status; - for tool in DEPENDENCY_TOOLS.iter() { - if !should_install_tool(&remaining_status, tool) { + for tool in &DEPENDENCY_TOOLS { + if !should_install_tool(remaining_status, tool) { continue; } @@ -49,11 +66,13 @@ pub(super) fn repository_install_context<'a>( target: Option<&'a TargetTriple>, ) -> Option> { match (dirs, installer, target) { - (Some(dirs), Some(installer), Some(target)) => Some(RepositoryInstallContext { - dirs, - installer, - target, - }), + (Some(base_dirs), Some(binary_installer), Some(target_triple)) => { + Some(RepositoryInstallContext { + dirs: base_dirs, + installer: binary_installer, + target: target_triple, + }) + } _ => None, } } @@ -66,7 +85,7 @@ pub(super) fn cargo_fallback_mode(executor: &dyn CommandExecutor) -> InstallMode } } -pub(super) fn should_install_tool(status: &DylintToolStatus, tool: &DependencyTool) -> bool { +pub(super) fn should_install_tool(status: DylintToolStatus, tool: &DependencyTool) -> bool { if tool == &CARGO_DYLINT_TOOL { !status.cargo_dylint } else if tool == &DYLINT_LINK_TOOL { @@ -85,62 +104,19 @@ pub(super) fn install_tool( let mut cargo_install_plan = CargoInstallPlan::new(tool); if let Some(repo) = &context.repo { - let Some(dependency) = find_dependency_binary(tool.package).map_err(|error| { - InstallerError::DependencyInstall { - tool: tool.package, - message: error.to_string(), - } - })? - else { - return Err(InstallerError::DependencyInstall { - tool: tool.package, - message: format!( - "dependency manifest is missing an entry for {}", - tool.package - ), - }); - }; + let dependency = resolve_dependency_binary(tool)?; cargo_install_plan = cargo_install_plan.with_version(dependency.version()); - match repo.installer.install(dependency, repo.target, repo.dirs) { - Ok(_) if repository_install_verified(executor, tool) => { - write_message( - stderr, - context.quiet, - format!("Installed {} from repository release.", tool.package), - ); - return Ok(InstallOutcome::RepositoryRelease); - } - Ok(_) => { - write_message( - stderr, - context.quiet, - format!( - "Repository install for {} failed verification; falling back to Cargo.", - tool.package - ), - ); - } - Err(error) if error.is_not_found() => { + let request = RepositoryInstallRequest { + executor, + tool, + dependency, + }; + match attempt_repository_install(&request, stderr, context, repo) { + RepositoryInstall::Installed => return Ok(InstallOutcome::RepositoryRelease), + RepositoryInstall::FallBackToCargo => {} + RepositoryInstall::FallBackToCargoInstall => { cargo_install_plan = cargo_install_plan.skip_binstall(); - write_message( - stderr, - context.quiet, - format!( - "Repository install for {} unavailable: {error}. Falling back to Cargo.", - tool.package - ), - ); - } - Err(error) => { - write_message( - stderr, - context.quiet, - format!( - "Repository install for {} unavailable: {error}. Falling back to Cargo.", - tool.package - ), - ); } } } @@ -169,7 +145,7 @@ struct CargoInstallPlan<'a> { } impl<'a> CargoInstallPlan<'a> { - fn new(tool: &'a DependencyTool) -> Self { + const fn new(tool: &'a DependencyTool) -> Self { Self { tool, version: None, @@ -177,14 +153,14 @@ impl<'a> CargoInstallPlan<'a> { } } - fn with_version(self, version: &'a str) -> Self { + const fn with_version(self, version: &'a str) -> Self { Self { version: Some(version), ..self } } - fn skip_binstall(self) -> Self { + const fn skip_binstall(self) -> Self { Self { skip_binstall: true, ..self @@ -206,7 +182,7 @@ fn install_tool_with_cargo( write_message( stderr, context.quiet, - format!( + &format!( "cargo binstall failed for {}; falling back to cargo install.", cargo_install_plan.tool.package ), @@ -240,7 +216,7 @@ fn try_binstall( write_message( stderr, quiet, - format!( + &format!( "Installed {} with cargo binstall.", cargo_install_plan.tool.package ), @@ -255,18 +231,21 @@ fn run_cargo_install( quiet: bool, ) -> Result { let mut args = vec!["install"]; - let success_message = if let Some(version) = cargo_install_plan.version { - args.extend(["--locked", "--version", version]); - format!( - "Installed {} from source with cargo install.", - cargo_install_plan.tool.package - ) - } else { - format!( - "Installed {} with cargo install.", - cargo_install_plan.tool.package - ) - }; + let success_message = cargo_install_plan.version.map_or_else( + || { + format!( + "Installed {} with cargo install.", + cargo_install_plan.tool.package + ) + }, + |version| { + args.extend(["--locked", "--version", version]); + format!( + "Installed {} from source with cargo install.", + cargo_install_plan.tool.package + ) + }, + ); args.push(cargo_install_plan.tool.package); let output = executor.run("cargo", &args)?; @@ -288,36 +267,10 @@ fn run_cargo_install( }); } - write_message(stderr, quiet, success_message); + write_message(stderr, quiet, &success_message); Ok(InstallOutcome::CargoInstall) } -/// Verify a repository-release install of `tool`. -/// -/// The trust boundary for a repository install is established entirely by the -/// installer pipeline: the release asset name pins the package and version, -/// the `.sha256` sidecar establishes integrity, extraction confirms the -/// expected archive member, and the permission step establishes launch -/// eligibility. A successful install is therefore sufficient evidence on its -/// own. -/// -/// `dylint-link` is additionally never executed as a health check. It is a -/// linker wrapper that forwards its entire argument list to the underlying -/// linker, so it has no reliable self-reporting subcommand: `--version` exits -/// early and `--help` depends on a usable linker and toolchain in the ambient -/// environment. Probing it rejects valid, verified artefacts and forces a -/// source build that cannot succeed on toolchains older than the crate's -/// rustc floor. -/// -/// `cargo-dylint` keeps the generic check because it reports its own version -/// and must additionally be discoverable by Cargo as a subcommand. -fn repository_install_verified(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { - if tool == &DYLINT_LINK_TOOL { - return true; - } - is_tool_installed(executor, tool) -} - pub(super) fn command_error_message(output: &Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr); let trimmed = stderr.trim(); @@ -328,11 +281,13 @@ pub(super) fn command_error_message(output: &Output) -> String { } } -pub(super) fn write_message(stderr: &mut dyn Write, quiet: bool, message: String) { +pub(super) fn write_message(stderr: &mut dyn Write, quiet: bool, message: &str) { if quiet { return; } - let _ = writeln!(stderr, "{message}"); + // Progress output is best effort; a failed stderr write must not + // abort the installation. + drop(writeln!(stderr, "{message}")); } pub(super) fn command_succeeds(executor: &dyn CommandExecutor, cmd: &str, args: &[&str]) -> bool { @@ -341,7 +296,7 @@ pub(super) fn command_succeeds(executor: &dyn CommandExecutor, cmd: &str, args: .is_ok_and(|output| output.status.success()) } -fn should_refresh_companions(outcome: InstallOutcome, status: &DylintToolStatus) -> bool { +fn should_refresh_companions(outcome: InstallOutcome, status: DylintToolStatus) -> bool { outcome != InstallOutcome::RepositoryRelease && !status.dylint_link } @@ -354,7 +309,7 @@ fn update_status_after_install( if tool == &CARGO_DYLINT_TOOL { status.cargo_dylint = true; - if should_refresh_companions(outcome, status) { + if should_refresh_companions(outcome, *status) { // Installing cargo-dylint locally can also provide dylint-link. status.dylint_link = is_tool_installed(executor, &DYLINT_LINK_TOOL); } diff --git a/installer/src/deps/install_repository.rs b/installer/src/deps/install_repository.rs new file mode 100644 index 00000000..960fa69b --- /dev/null +++ b/installer/src/deps/install_repository.rs @@ -0,0 +1,143 @@ +//! Repository-release installation path for dependency tools. +//! +//! Whitaker publishes prebuilt `cargo-dylint` and `dylint-link` binaries as +//! release assets. This module resolves the manifest entry for a tool, drives +//! the release install, and reports which Cargo fallback (if any) remains +//! viable when the release cannot be used. + +use std::io::Write; + +use super::{ + CommandExecutor, + DYLINT_LINK_TOOL, + DependencyTool, + InstallContext, + InstallerError, + RepositoryInstallContext, + Result, + find_dependency_binary, + is_tool_installed, + write_message, +}; +use crate::dependency_binaries::DependencyBinary; + +/// Outcome of attempting a repository-release install for one dependency tool. +pub(super) enum RepositoryInstall { + /// The release was installed and verified; no Cargo fallback is needed. + Installed, + /// The release was unusable; fall back to the configured Cargo mode. + FallBackToCargo, + /// The release is absent upstream, so `cargo binstall` cannot help either. + FallBackToCargoInstall, +} + +/// Looks up the manifest entry describing the release asset for `tool`. +pub(super) fn resolve_dependency_binary( + tool: &DependencyTool, +) -> Result<&'static DependencyBinary> { + let entry = find_dependency_binary(tool.package).map_err(|error| { + InstallerError::DependencyInstall { + tool: tool.package, + message: error.to_string(), + } + })?; + + entry.ok_or_else(|| InstallerError::DependencyInstall { + tool: tool.package, + message: format!( + "dependency manifest is missing an entry for {}", + tool.package + ), + }) +} + +/// The tool to install from a repository release, together with the +/// collaborators needed to perform and verify that install. +/// +/// Grouped so [`attempt_repository_install`] stays within the workspace +/// argument budget; the three fields are only ever supplied together. +pub(super) struct RepositoryInstallRequest<'a> { + /// Executor used to probe whether the installed binary runs. + pub(super) executor: &'a dyn CommandExecutor, + /// The dependency tool being installed. + pub(super) tool: &'a DependencyTool, + /// Release metadata identifying the artefact to fetch. + pub(super) dependency: &'static DependencyBinary, +} + +/// Installs the requested tool from a repository release, reporting whether +/// Cargo must still run and, if so, which fallback mode remains viable. +pub(super) fn attempt_repository_install( + request: &RepositoryInstallRequest<'_>, + stderr: &mut dyn Write, + context: &InstallContext<'_>, + repo: &RepositoryInstallContext<'_>, +) -> RepositoryInstall { + let tool = request.tool; + match repo + .installer + .install(request.dependency, repo.target, repo.dirs) + { + Ok(_) if repository_install_verified(request.executor, tool) => { + write_message( + stderr, + context.quiet, + &format!("Installed {} from repository release.", tool.package), + ); + RepositoryInstall::Installed + } + Ok(_) => { + write_message( + stderr, + context.quiet, + &format!( + "Repository install for {} failed verification; falling back to Cargo.", + tool.package + ), + ); + RepositoryInstall::FallBackToCargo + } + Err(error) => { + let not_found = error.is_not_found(); + write_message( + stderr, + context.quiet, + &format!( + "Repository install for {} unavailable: {error}. Falling back to Cargo.", + tool.package + ), + ); + if not_found { + RepositoryInstall::FallBackToCargoInstall + } else { + RepositoryInstall::FallBackToCargo + } + } + } +} + +/// Verify a repository-release install of `tool`. +/// +/// The trust boundary for a repository install is established entirely by the +/// installer pipeline: the release asset name pins the package and version, +/// the `.sha256` sidecar establishes integrity, extraction confirms the +/// expected archive member, and the permission step establishes launch +/// eligibility. A successful install is therefore sufficient evidence on its +/// own. +/// +/// `dylint-link` is additionally never executed as a health check. It is a +/// linker wrapper that forwards its entire argument list to the underlying +/// linker, so it has no reliable self-reporting subcommand: `--version` exits +/// early and `--help` depends on a usable linker and toolchain in the ambient +/// environment. Probing it rejects valid, verified artefacts and forces a +/// source build that cannot succeed on toolchains older than the crate's +/// rustc floor. +/// +/// `cargo-dylint` keeps the generic check because it reports its own version +/// and must additionally be discoverable by Cargo as a subcommand. +fn repository_install_verified(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { + if tool == &DYLINT_LINK_TOOL { + return true; + } + is_tool_installed(executor, tool) +} diff --git a/installer/src/deps/install_tests.rs b/installer/src/deps/install_tests.rs index dc41e6ee..5f2ac572 100644 --- a/installer/src/deps/install_tests.rs +++ b/installer/src/deps/install_tests.rs @@ -1,10 +1,12 @@ //! Tests for dependency-install status refresh behaviour. +use rstest::rstest; + use super::*; use crate::test_utils::dependency_binary_helpers::{ - dylint_link_install_list_check, with_fake_binary_on_path, + dylint_link_install_list_check, + with_fake_binary_on_path, }; -use rstest::rstest; #[rstest] #[case(InstallOutcome::CargoBinstall)] @@ -26,7 +28,8 @@ fn update_status_after_install_refreshes_link_for_local_cargo_dylint_installs( assert!(status.cargo_dylint); assert!(status.dylint_link); executor.assert_finished(); - }); + }) + .expect("prepare fake PATH"); } #[test] @@ -65,7 +68,7 @@ fn should_install_tool_returns_expected( dylint_link, }; - assert_eq!(should_install_tool(&status, tool), expected); + assert_eq!(should_install_tool(status, tool), expected); } #[rstest] @@ -89,5 +92,5 @@ fn should_refresh_companions_returns_expected( #[case] status: DylintToolStatus, #[case] expected: bool, ) { - assert_eq!(should_refresh_companions(outcome, &status), expected); + assert_eq!(should_refresh_companions(outcome, status), expected); } diff --git a/installer/src/deps.rs b/installer/src/deps/mod.rs similarity index 87% rename from installer/src/deps.rs rename to installer/src/deps/mod.rs index 242c05c2..8f5d8006 100644 --- a/installer/src/deps.rs +++ b/installer/src/deps/mod.rs @@ -4,19 +4,33 @@ //! available, then installs any missing tools by preferring repository-hosted //! release archives before falling back to `cargo binstall` or `cargo install`. -use crate::dependency_binaries::{ - DependencyBinaryInstaller, RepositoryDependencyBinaryInstaller, find_dependency_binary, - host_target, +use std::{ + io, + io::Write, + path::Path, + process::{Command, Output}, +}; + +use crate::{ + dependency_binaries::{ + DependencyBinary, + DependencyBinaryInstaller, + RepositoryDependencyBinaryInstaller, + find_dependency_binary, + host_target, + }, + dirs::{BaseDirs, SystemBaseDirs}, + error::{InstallerError, Result}, }; -use crate::dirs::{BaseDirs, SystemBaseDirs}; -use crate::error::{InstallerError, Result}; -use std::io; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Output}; mod install; -use install::*; +use install::{ + InstallContext, + cargo_fallback_mode, + command_succeeds, + install_missing_tools, + repository_install_context, +}; /// Abstraction for running external commands. pub trait CommandExecutor { @@ -74,9 +88,7 @@ pub struct DylintToolStatus { impl DylintToolStatus { /// Returns `true` when both tools are installed. #[must_use] - pub fn all_installed(&self) -> bool { - self.cargo_dylint && self.dylint_link - } + pub const fn all_installed(&self) -> bool { self.cargo_dylint && self.dylint_link } } /// Additional install options used by test-support hooks. @@ -102,6 +114,11 @@ pub fn check_dylint_tools(executor: &dyn CommandExecutor) -> DylintToolStatus { } /// Install missing tools without emitting progress output. +/// +/// # Errors +/// +/// Returns an error when any missing tool cannot be installed by the +/// repository, `cargo binstall`, or `cargo install` strategies. pub fn install_dylint_tools( executor: &dyn CommandExecutor, status: &DylintToolStatus, @@ -111,6 +128,11 @@ pub fn install_dylint_tools( } /// Install missing tools while writing progress output to `stderr`. +/// +/// # Errors +/// +/// Returns an error when any missing tool cannot be installed by the +/// repository, `cargo binstall`, or `cargo install` strategies. pub fn install_dylint_tools_with_output( executor: &dyn CommandExecutor, status: &DylintToolStatus, @@ -124,7 +146,7 @@ pub fn install_dylint_tools_with_output( let cargo_fallback_mode = cargo_fallback_mode(executor); install_missing_tools( executor, - status, + *status, stderr, &InstallContext { repo: repository_install_context( @@ -145,12 +167,12 @@ pub fn install_dylint_tools_with_options( executor: &dyn CommandExecutor, status: &DylintToolStatus, stderr: &mut dyn Write, - options: DependencyInstallOptions<'_>, + options: &DependencyInstallOptions<'_>, ) -> Result<()> { let cargo_fallback_mode = cargo_fallback_mode(executor); install_missing_tools( executor, - status, + *status, stderr, &InstallContext { repo: repository_install_context( @@ -174,7 +196,7 @@ fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> b let expected_version = find_dependency_binary(tool.package) .ok() .flatten() - .map(|dependency| dependency.version()); + .map(DependencyBinary::version); if tool == &DYLINT_LINK_TOOL { return is_dylint_link_installed(executor, expected_version); @@ -192,7 +214,7 @@ fn is_dylint_link_installed( if find_binary_on_path(DYLINT_LINK_TOOL.command).is_none() { return false; } - let Some(expected_version) = expected_version else { + let Some(version_to_match) = expected_version else { return true; }; // `dylint-link` is a pure linker wrapper: it forwards its entire argument @@ -201,7 +223,7 @@ fn is_dylint_link_installed( // registry of installed binaries instead, which records the version each // binary was installed at. cargo_installed_version(executor, DYLINT_LINK_TOOL.package) - .is_some_and(|version| version == expected_version) + .is_some_and(|version| version == version_to_match) } fn is_versioned_tool_installed( @@ -209,13 +231,13 @@ fn is_versioned_tool_installed( tool: &DependencyTool, expected_version: Option<&str>, ) -> bool { - let Some(expected_version) = expected_version else { + let Some(version_to_match) = expected_version else { return command_succeeds(executor, tool.command, tool.args); }; executor.run(tool.command, tool.args).is_ok_and(|output| { output.status.success() && first_semver_token(&String::from_utf8_lossy(&output.stdout)) - .is_some_and(|version| version == expected_version) + .is_some_and(|version| version == version_to_match) }) } @@ -261,9 +283,7 @@ fn is_binstall_available(executor: &dyn CommandExecutor) -> bool { } #[cfg(test)] -fn is_binary_on_path(binary_name: &str) -> bool { - find_binary_on_path(binary_name).is_some() -} +fn is_binary_on_path(binary_name: &str) -> bool { find_binary_on_path(binary_name).is_some() } fn find_binary_on_path(binary_name: &str) -> Option { let path_var = std::env::var_os("PATH")?; @@ -327,14 +347,11 @@ fn is_executable_file(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt; std::fs::metadata(path) - .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) } #[cfg(not(unix))] -fn is_executable_file(path: &Path) -> bool { - path.is_file() -} +fn is_executable_file(path: &Path) -> bool { path.is_file() } #[cfg(test)] mod path_tests; diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index efc7506a..6b5aa25d 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -1,14 +1,26 @@ //! Tests for PATH-based dependency-binary discovery helpers. +use temp_env::with_vars_unset; + use super::*; -use crate::test_support::env_test_guard; -use crate::test_utils::dependency_binary_helpers::{ - cargo_dylint_check, cargo_dylint_check_with_result, dylint_link_install_list_check, - dylint_link_install_list_check_with_version, with_fake_binary_on_path, with_fake_path, - write_fake_binary, write_fake_binary_with_status, +use crate::{ + test_support::env_test_guard, + test_utils::{ + ExpectedCall, + StubExecutor, + dependency_binary_helpers::{ + cargo_dylint_check, + cargo_dylint_check_with_result, + dylint_link_install_list_check, + dylint_link_install_list_check_with_version, + with_fake_binary_on_path, + with_fake_path, + write_fake_binary, + write_fake_binary_with_status, + }, + stdout_output, + }, }; -use crate::test_utils::{ExpectedCall, StubExecutor, stdout_output}; -use temp_env::with_vars_unset; #[test] fn check_dylint_tools_reports_installed_tools() { @@ -26,7 +38,8 @@ fn check_dylint_tools_reports_installed_tools() { } ); executor.assert_finished(); - }); + }) + .expect("prepare fake PATH"); } #[rstest::rstest] @@ -35,7 +48,7 @@ fn check_dylint_tools_reports_installed_tools() { fn check_dylint_tools_rejects_unusable_cargo_dylint_output(#[case] version_stdout: &str) { // The fake PATH keeps dylint-link absent so only cargo-dylint is probed. with_fake_path( - |_| {}, + |_| Ok(()), || { let executor = StubExecutor::new(vec![cargo_dylint_check_with_result(Ok( stdout_output(version_stdout), @@ -52,7 +65,8 @@ fn check_dylint_tools_rejects_unusable_cargo_dylint_output(#[case] version_stdou ); executor.assert_finished(); }, - ); + ) + .expect("prepare fake PATH"); } #[rstest::rstest] @@ -76,19 +90,23 @@ fn check_dylint_tools_rejects_unpinned_dylint_link(#[case] install_list_check: E } ); executor.assert_finished(); - }); + }) + .expect("prepare fake PATH"); } #[test] fn check_dylint_tools_rejects_non_invocable_dylint_link_on_path() { with_fake_path( |directories| { + let first_dir = directories.first().ok_or_else(|| { + std::io::Error::other("fake PATH should contain at least one directory") + })?; #[cfg(windows)] - let binary_path = directories[0].join("dylint-link.cmd"); + let binary_path = first_dir.join("dylint-link.cmd"); #[cfg(not(windows))] - let binary_path = directories[0].join("dylint-link"); + let binary_path = first_dir.join("dylint-link"); - write_fake_binary_with_status(&binary_path, true, 1); + write_fake_binary_with_status(&binary_path, true, 1) }, || { let executor = StubExecutor::new(vec![cargo_dylint_check()]); @@ -104,7 +122,8 @@ fn check_dylint_tools_rejects_non_invocable_dylint_link_on_path() { ); executor.assert_finished(); }, - ); + ) + .expect("prepare fake PATH"); } #[test] @@ -126,28 +145,33 @@ fn is_binary_on_path_returns_false_when_path_is_empty() { #[test] fn is_binary_on_path_returns_false_when_binary_is_missing_from_all_directories() { with_fake_path( - |_| {}, + |_| Ok(()), || { assert!(!is_binary_on_path("dylint-link")); }, - ); + ) + .expect("prepare fake PATH"); } #[test] fn is_binary_on_path_checks_multiple_directories() { with_fake_path( |directories| { + let second_dir = directories.get(1).ok_or_else(|| { + std::io::Error::other("fake PATH should contain at least two directories") + })?; #[cfg(windows)] - let binary_path = directories[1].join("dylint-link.exe"); + let binary_path = second_dir.join("dylint-link.exe"); #[cfg(not(windows))] - let binary_path = directories[1].join("dylint-link"); + let binary_path = second_dir.join("dylint-link"); - write_fake_binary(&binary_path, true); + write_fake_binary(&binary_path, true) }, || { assert!(is_binary_on_path("dylint-link")); }, - ); + ) + .expect("prepare fake PATH"); } #[cfg(unix)] @@ -155,7 +179,7 @@ fn is_binary_on_path_checks_multiple_directories() { fn is_executable_file_rejects_non_executable_files() { let temp_dir = tempfile::tempdir().expect("create temp dir"); let binary_path = temp_dir.path().join("dylint-link"); - write_fake_binary(&binary_path, false); + write_fake_binary(&binary_path, false).expect("write fake binary"); assert!(!is_executable_file(&binary_path)); } @@ -165,7 +189,7 @@ fn is_executable_file_rejects_non_executable_files() { fn is_executable_file_accepts_executable_files() { let temp_dir = tempfile::tempdir().expect("create temp dir"); let binary_path = temp_dir.path().join("dylint-link"); - write_fake_binary(&binary_path, true); + write_fake_binary(&binary_path, true).expect("write fake binary"); assert!(is_executable_file(&binary_path)); } @@ -184,7 +208,8 @@ fn is_binary_on_path_handles_windows_executable_suffixes( || { assert_eq!(is_binary_on_path(binary_name), expected); }, - ); + ) + .expect("prepare fake PATH"); } #[cfg(windows)] @@ -211,5 +236,6 @@ fn check_dylint_tools_detects_dylint_link_via_pathext_suffix() { executor.assert_finished(); }); }, - ); + ) + .expect("prepare fake PATH"); } diff --git a/installer/src/deps/tests.rs b/installer/src/deps/tests.rs index 6e69ce5f..78f891ce 100644 --- a/installer/src/deps/tests.rs +++ b/installer/src/deps/tests.rs @@ -1,30 +1,46 @@ //! Tests for Dylint tool dependency installation and fallback behaviour. +use std::path::PathBuf; + use super::*; -use crate::dependency_binaries::{DependencyBinaryInstallError, MockDependencyBinaryInstaller}; -use crate::installer_packaging::TargetTriple; -use crate::test_utils::dependency_binary_helpers::{ - binstall_install, binstall_version_check_with_result, cargo_dylint_check, - cargo_dylint_check_with_result, dylint_link_install_list_check, with_fake_binary_on_path, +use crate::{ + artefact::error::ArtefactError, + dependency_binaries::{DependencyBinaryInstallError, MockDependencyBinaryInstaller}, + installer_packaging::TargetTriple, + test_utils::{ + ExpectedCall, + StubDirs, + StubExecutor, + dependency_binary_helpers::{ + binstall_install, + binstall_version_check_with_result, + cargo_dylint_check, + cargo_dylint_check_with_result, + dylint_link_install_list_check, + with_fake_binary_on_path, + }, + failure_output, + success_output, + }, }; -use crate::test_utils::{ExpectedCall, StubDirs, StubExecutor, failure_output, success_output}; -use std::path::PathBuf; -fn install_options<'a>( - repository_installer: &'a dyn DependencyBinaryInstaller, +const OPTIONS_MSG: &str = "dependency install options should build"; + +fn install_options( + repository_installer: &dyn DependencyBinaryInstaller, quiet: bool, -) -> DependencyInstallOptions<'a> { +) -> std::result::Result, ArtefactError> { let dirs = StubDirs { bin_dir: Some(PathBuf::from("/tmp/bin")), }; - let target = TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"); - DependencyInstallOptions { + let target = TargetTriple::try_from("x86_64-unknown-linux-gnu")?; + Ok(DependencyInstallOptions { // Intentional leak in tests to extend lifetime for trait object; acceptable here. dirs: Box::leak(Box::new(dirs)), repository_installer, target: Some(target), quiet, - } + }) } #[test] @@ -57,7 +73,7 @@ fn install_dylint_tools_uses_repository_release_first() { dylint_link: true, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect("repository install should succeed"); @@ -89,7 +105,7 @@ fn install_dylint_tools_falls_back_to_binstall_when_repository_unavailable() { dylint_link: true, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect("cargo binstall fallback should succeed"); @@ -123,7 +139,7 @@ fn install_dylint_tools_falls_back_to_cargo_install_when_binstall_missing() { dylint_link: true, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect("cargo install fallback should succeed"); @@ -153,7 +169,7 @@ fn install_dylint_tools_falls_back_when_repository_verification_fails() { dylint_link: true, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect("fallback after verification failure should succeed"); @@ -186,7 +202,7 @@ fn install_dylint_tools_reports_total_failure_after_all_fallbacks() { dylint_link: true, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect_err("install should fail after all fallbacks"); @@ -226,7 +242,7 @@ fn install_dylint_tools_builds_from_source_when_repository_asset_is_missing() { dylint_link: true, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect("source build should succeed"); @@ -270,10 +286,11 @@ fn install_dylint_tools_skips_dylint_link_when_cargo_dylint_source_build_install dylint_link: false, }, &mut stderr, - install_options(&repository_installer, false), + &install_options(&repository_installer, false).expect(OPTIONS_MSG), ) .expect("cargo-dylint source build should satisfy both tools"); - }); + }) + .expect("prepare fake PATH"); let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); assert!(output.contains("Installed cargo-dylint from source with cargo install.")); @@ -281,144 +298,5 @@ fn install_dylint_tools_skips_dylint_link_when_cargo_dylint_source_build_install executor.assert_finished(); } -/// Writes a fake `dylint-link` into a temporary directory and returns the -/// directory guard alongside the binary path. -/// -/// The fake is staged with a failing exit status so that any attempt to -/// execute it as a health check would be observable as a verification -/// failure. -fn staged_unrunnable_dylint_link() -> std::io::Result<(tempfile::TempDir, PathBuf)> { - let dir = tempfile::tempdir()?; - let path = crate::test_utils::dependency_binary_helpers::path_binary_location( - dir.path(), - "dylint-link", - ); - crate::test_utils::dependency_binary_helpers::write_fake_binary_with_status(&path, true, 1); - Ok((dir, path)) -} - -#[test] -fn install_dylint_tools_accepts_repository_dylint_link_without_executing_it() { - // `dylint-link` forwards its argument list to the underlying linker and - // has no reliable self-reporting subcommand, so a successful repository - // install is accepted on the strength of the download, checksum, - // extraction, and permission steps alone. Staging a fake that exits - // non-zero proves the binary is never executed as a health check: any - // such probe would reject it and fall back to Cargo. - let (_dir, binary_path) = staged_unrunnable_dylint_link().expect("stage fake dylint-link"); - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer - .expect_install() - .once() - .returning(move |_, _, _| Ok(binary_path.clone())); - let executor = StubExecutor::new(vec![binstall_version_check_with_result(Ok( - success_output(), - ))]); - let mut stderr = Vec::new(); - - install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: true, - dylint_link: false, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - .expect("repository install should satisfy dylint-link"); - - let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); - assert!(output.contains("Installed dylint-link from repository release.")); - assert!(!output.contains("failed verification")); - // No Cargo command beyond the binstall-availability probe may run: a - // source build of dylint-link cannot succeed on toolchains below the - // crate's rustc floor. - executor.assert_finished(); -} - -#[test] -fn install_dylint_tools_falls_back_when_repository_dylint_link_install_fails() { - // Genuine repository failures — missing asset, checksum mismatch, - // extraction failure, or an unwritable executable — must still fall back - // to Cargo. - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer.expect_install().returning(|_, _, _| { - Err(DependencyBinaryInstallError::Install { - binary: "dylint-link".to_owned(), - reason: "checksum mismatch".to_owned(), - }) - }); - let executor = StubExecutor::new(vec![ - binstall_version_check_with_result(Ok(success_output())), - binstall_install("dylint-link", Ok(success_output())), - // The post-binstall check resolves the PATH binary and then confirms - // the version against Cargo's installed-binary registry. - dylint_link_install_list_check(), - ]); - let mut stderr = Vec::new(); - - with_fake_binary_on_path("dylint-link", || { - install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: true, - dylint_link: false, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - .expect("binstall fallback should succeed"); - }); - - let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); - assert!(output.contains("Repository install for dylint-link unavailable")); - assert!(output.contains("Installed dylint-link with cargo binstall.")); - executor.assert_finished(); -} - -#[test] -fn install_tool_errors_when_dependency_manifest_entry_is_missing() { - let missing_tool = DependencyTool { - package: "missing-tool", - command: "missing-tool", - args: &["--version"], - }; - let executor = StubExecutor::new(vec![]); - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer.expect_install().never(); - let dirs = StubDirs { - bin_dir: Some(PathBuf::from("/tmp/bin")), - }; - let target = TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"); - let mut stderr = Vec::new(); - - let error = install_tool( - &executor, - &missing_tool, - &mut stderr, - &InstallContext { - repo: repository_install_context( - Some(&dirs), - Some(&repository_installer as &dyn DependencyBinaryInstaller), - Some(&target), - ), - cargo_fallback_mode: InstallMode::Binstall, - quiet: false, - }, - ) - .expect_err("missing dependency manifest entry should be an install error"); - - match error { - InstallerError::DependencyInstall { tool, message } => { - assert_eq!(tool, "missing-tool"); - assert_eq!( - message, - "dependency manifest is missing an entry for missing-tool" - ); - } - other => panic!("unexpected error: {other}"), - } - - assert!(stderr.is_empty()); - executor.assert_finished(); -} +#[path = "tests_repository.rs"] +mod repository; diff --git a/installer/src/deps/tests_repository.rs b/installer/src/deps/tests_repository.rs new file mode 100644 index 00000000..1ffe88d5 --- /dev/null +++ b/installer/src/deps/tests_repository.rs @@ -0,0 +1,172 @@ +//! Tests for repository-release installation of the Dylint dependency tools. + +use std::path::PathBuf; + +use super::{ + DependencyBinaryInstaller, + DependencyTool, + DylintToolStatus, + OPTIONS_MSG, + install::{InstallContext, InstallMode, install_tool, repository_install_context}, + install_dylint_tools_with_options, + install_options, +}; +use crate::{ + dependency_binaries::{DependencyBinaryInstallError, MockDependencyBinaryInstaller}, + error::InstallerError, + installer_packaging::TargetTriple, + test_utils::{ + StubDirs, + StubExecutor, + dependency_binary_helpers::{ + binstall_install, + binstall_version_check_with_result, + dylint_link_install_list_check, + with_fake_binary_on_path, + }, + success_output, + }, +}; + +/// Writes a fake `dylint-link` into a temporary directory and returns the +/// directory guard alongside the binary path. +/// +/// The fake is staged with a failing exit status so that any attempt to +/// execute it as a health check would be observable as a verification +/// failure. +fn staged_unrunnable_dylint_link() -> std::io::Result<(tempfile::TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + let path = crate::test_utils::dependency_binary_helpers::path_binary_location( + dir.path(), + "dylint-link", + ); + crate::test_utils::dependency_binary_helpers::write_fake_binary_with_status(&path, true, 1)?; + Ok((dir, path)) +} + +#[test] +fn install_dylint_tools_accepts_repository_dylint_link_without_executing_it() { + // `dylint-link` forwards its argument list to the underlying linker and + // has no reliable self-reporting subcommand, so a successful repository + // install is accepted on the strength of the download, checksum, + // extraction, and permission steps alone. Staging a fake that exits + // non-zero proves the binary is never executed as a health check: any + // such probe would reject it and fall back to Cargo. + let (_dir, binary_path) = staged_unrunnable_dylint_link().expect("stage fake dylint-link"); + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .once() + .returning(move |_, _, _| Ok(binary_path.clone())); + let executor = StubExecutor::new(vec![binstall_version_check_with_result(Ok( + success_output(), + ))]); + let mut stderr = Vec::new(); + + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: true, + dylint_link: false, + }, + &mut stderr, + &install_options(&repository_installer, false).expect(OPTIONS_MSG), + ) + .expect("repository install should satisfy dylint-link"); + + let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); + assert!(output.contains("Installed dylint-link from repository release.")); + assert!(!output.contains("failed verification")); + // No Cargo command beyond the binstall-availability probe may run: a + // source build of dylint-link cannot succeed on toolchains below the + // crate's rustc floor. + executor.assert_finished(); +} + +#[test] +fn install_dylint_tools_falls_back_when_repository_dylint_link_install_fails() { + // Genuine repository failures — missing asset, checksum mismatch, + // extraction failure, or an unwritable executable — must still fall back + // to Cargo. + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer.expect_install().returning(|_, _, _| { + Err(DependencyBinaryInstallError::Install { + binary: "dylint-link".to_owned(), + reason: "checksum mismatch".to_owned(), + }) + }); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(success_output())), + binstall_install("dylint-link", Ok(success_output())), + // The post-binstall check resolves the PATH binary and then confirms + // the version against Cargo's installed-binary registry. + dylint_link_install_list_check(), + ]); + let mut stderr = Vec::new(); + + with_fake_binary_on_path("dylint-link", || { + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: true, + dylint_link: false, + }, + &mut stderr, + &install_options(&repository_installer, false).expect(OPTIONS_MSG), + ) + .expect("binstall fallback should succeed"); + }) + .expect("prepare fake PATH"); + + let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); + assert!(output.contains("Repository install for dylint-link unavailable")); + assert!(output.contains("Installed dylint-link with cargo binstall.")); + executor.assert_finished(); +} + +#[test] +fn install_tool_errors_when_dependency_manifest_entry_is_missing() { + let missing_tool = DependencyTool { + package: "missing-tool", + command: "missing-tool", + args: &["--version"], + }; + let executor = StubExecutor::new(vec![]); + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer.expect_install().never(); + let dirs = StubDirs { + bin_dir: Some(PathBuf::from("/tmp/bin")), + }; + let target = TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"); + let mut stderr = Vec::new(); + + let error = install_tool( + &executor, + &missing_tool, + &mut stderr, + &InstallContext { + repo: repository_install_context( + Some(&dirs), + Some(&repository_installer as &dyn DependencyBinaryInstaller), + Some(&target), + ), + cargo_fallback_mode: InstallMode::Binstall, + quiet: false, + }, + ) + .expect_err("missing dependency manifest entry should be an install error"); + + match error { + InstallerError::DependencyInstall { tool, message } => { + assert_eq!(tool, "missing-tool"); + assert_eq!( + message, + "dependency manifest is missing an entry for missing-tool" + ); + } + other => panic!("unexpected error: {other}"), + } + + assert!(stderr.is_empty()); + executor.assert_finished(); +} diff --git a/installer/src/dirs.rs b/installer/src/dirs.rs index 7139ea0b..79caf4ba 100644 --- a/installer/src/dirs.rs +++ b/installer/src/dirs.rs @@ -33,7 +33,7 @@ fn xdg_bin_home() -> Option { /// use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; /// /// let dirs = SystemBaseDirs::new().expect("failed to initialize directories"); -/// if let Some(bin_dir) = dirs.bin_dir() { +/// if let Some(bin_dir) = dirs.executables() { /// println!("Executables go in: {}", bin_dir.display()); /// } /// ``` @@ -43,21 +43,21 @@ pub trait BaseDirs { /// /// - Unix: `$HOME` or `/home/` /// - Windows: `%USERPROFILE%` or `C:\Users\` - fn home_dir(&self) -> Option; + fn home(&self) -> Option; /// Returns the directory for user executables. /// /// On Unix, respects `XDG_BIN_HOME` if set, otherwise falls back to /// `~/.local/bin`. On Windows, returns `~/.local/bin`. This follows the /// XDG Base Directory Specification convention. - fn bin_dir(&self) -> Option; + fn executables(&self) -> Option; /// Returns the directory for cloning the Whitaker repository. /// /// - Linux: `~/.local/share/whitaker` /// - macOS: `~/Library/Application Support/whitaker` /// - Windows: `%LOCALAPPDATA%\whitaker` - fn whitaker_data_dir(&self) -> Option; + fn whitaker_data(&self) -> Option; } /// Real implementation of [`BaseDirs`] using the `directories-next` crate. @@ -73,7 +73,8 @@ pub trait BaseDirs { /// use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; /// /// let dirs = SystemBaseDirs::new().expect("failed to initialize directories"); -/// let data_dir = dirs.whitaker_data_dir() +/// let data_dir = dirs +/// .whitaker_data() /// .expect("could not determine data directory"); /// println!("Whitaker data at: {}", data_dir.display()); /// ``` @@ -101,25 +102,23 @@ impl SystemBaseDirs { } impl BaseDirs for SystemBaseDirs { - fn home_dir(&self) -> Option { - Some(self.user_dirs.home_dir().to_owned()) - } + fn home(&self) -> Option { Some(self.user_dirs.home_dir().to_owned()) } - fn bin_dir(&self) -> Option { + fn executables(&self) -> Option { #[cfg(unix)] if let Some(path) = xdg_bin_home() { return Some(path); } - self.home_dir().map(|h| h.join(".local").join("bin")) + self.home().map(|h| h.join(".local").join("bin")) } - fn whitaker_data_dir(&self) -> Option { - Some(self.project_dirs.data_dir().to_owned()) - } + fn whitaker_data(&self) -> Option { Some(self.project_dirs.data_dir().to_owned()) } } #[cfg(test)] mod tests { + //! Tests for base-directory resolution. + use super::*; use crate::test_support::env_test_guard; @@ -127,16 +126,13 @@ mod tests { fn system_base_dirs_returns_some_on_supported_platforms() { let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - assert!( - dirs.home_dir().is_some(), - "expected home_dir to return Some" - ); + assert!(dirs.home().is_some(), "expected home_dir to return Some"); } #[test] fn whitaker_data_dir_contains_whitaker() { let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - let data_dir = dirs.whitaker_data_dir(); + let data_dir = dirs.whitaker_data(); assert!( data_dir.is_some(), @@ -156,7 +152,7 @@ mod tests { // Temporarily unset XDG_BIN_HOME to test the fallback temp_env::with_var_unset("XDG_BIN_HOME", || { let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - let bin_dir = dirs.bin_dir(); + let bin_dir = dirs.executables(); assert!(bin_dir.is_some(), "expected bin_dir to return Some"); assert!( @@ -174,7 +170,7 @@ mod tests { let _guard = env_test_guard(); temp_env::with_var("XDG_BIN_HOME", Some("/custom/bin"), || { let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - let bin_dir = dirs.bin_dir().expect("expected bin_dir to return Some"); + let bin_dir = dirs.executables().expect("expected bin_dir to return Some"); assert_eq!(bin_dir, PathBuf::from("/custom/bin")); }); @@ -186,7 +182,7 @@ mod tests { let _guard = env_test_guard(); temp_env::with_var("XDG_BIN_HOME", Some("relative/path"), || { let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - let bin_dir = dirs.bin_dir().expect("expected bin_dir to return Some"); + let bin_dir = dirs.executables().expect("expected bin_dir to return Some"); // Should fall back to ~/.local/bin since XDG_BIN_HOME is relative assert!( diff --git a/installer/src/error.rs b/installer/src/error.rs index 93e45cdc..c8b207a4 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -4,10 +4,11 @@ //! to users when installation fails. Each error includes recovery hints where //! applicable. -use crate::crate_name::CrateName; use camino::Utf8PathBuf; use thiserror::Error; +use crate::crate_name::CrateName; + /// Errors that can occur during the installation process. #[derive(Debug, Error)] pub enum InstallerError { @@ -251,14 +252,18 @@ impl Clone for InstallerError { Self::WriteFailed { source } => Self::WriteFailed { source: clone_io_error(source), }, + // These variants are cloned by `clone_toolchain_variant` before + // this match runs. Should that helper ever regress, degrade to a + // detection error carrying the formatted message rather than + // panicking inside `clone`. Self::ToolchainDetection { .. } | Self::ToolchainFileNotFound { .. } | Self::InvalidToolchainFile { .. } | Self::ToolchainNotInstalled { .. } | Self::ToolchainInstallFailed { .. } - | Self::ToolchainComponentInstallFailed { .. } => { - unreachable!("handled by clone_toolchain_variant") - } + | Self::ToolchainComponentInstallFailed { .. } => Self::ToolchainDetection { + reason: self.to_string(), + }, #[cfg(any(test, feature = "test-support"))] Self::StubMismatch { message } => Self::StubMismatch { message: message.clone(), @@ -272,6 +277,8 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { + //! Tests for installer error construction and display. + use super::*; #[test] diff --git a/installer/src/git.rs b/installer/src/git.rs index f59a00e1..5dd8a960 100644 --- a/installer/src/git.rs +++ b/installer/src/git.rs @@ -4,15 +4,23 @@ //! including initial cloning and subsequent updates. Operations have a //! configurable timeout to prevent hangs on network issues. -use crate::error::{InstallerError, Result}; -use crate::workspace::WHITAKER_REPO_URL; +use std::{ + io::Read, + process::{Child, Command, Output, Stdio}, + thread::JoinHandle, + time::Duration, +}; + use camino::Utf8Path; -use std::process::{Command, Output, Stdio}; -use std::time::Duration; use wait_timeout::ChildExt; +use crate::{ + error::{InstallerError, Result}, + workspace::WHITAKER_REPO_URL, +}; + /// Default timeout for git operations (5 minutes). -const GIT_TIMEOUT: Duration = Duration::from_secs(300); +const GIT_TIMEOUT: Duration = Duration::from_mins(5); /// Clones the Whitaker repository to the specified target directory. /// @@ -79,6 +87,31 @@ fn run_git_with_timeout( working_dir: Option<&Utf8Path>, operation: &'static str, ) -> Result { + let mut child = spawn_git_child(args, working_dir)?; + + // Take ownership of pipes before spawning threads to avoid blocking. + // If either pipe is missing, use empty readers. + let stdout_reader = spawn_pipe_reader(child.stdout.take()); + let stderr_reader = spawn_pipe_reader(child.stderr.take()); + + let Some(status) = child.wait_timeout(GIT_TIMEOUT)? else { + return Err(abandon_timed_out_git( + child, + stdout_reader, + stderr_reader, + operation, + )); + }; + + Ok(Output { + status, + stdout: join_pipe_reader(stdout_reader, operation, "stdout")?.into_bytes(), + stderr: join_pipe_reader(stderr_reader, operation, "stderr")?.into_bytes(), + }) +} + +/// Spawns the git child process with both standard streams piped. +fn spawn_git_child(args: &[&str], working_dir: Option<&Utf8Path>) -> Result { let mut cmd = Command::new("git"); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -86,70 +119,63 @@ fn run_git_with_timeout( cmd.current_dir(dir.as_std_path()); } - let mut child = cmd.spawn()?; - - // Take ownership of pipes before spawning threads to avoid blocking. - // If either pipe is missing, use empty readers. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); + Ok(cmd.spawn()?) +} - // Spawn threads to read pipes concurrently whilst the process runs. - let stdout_thread = std::thread::spawn(move || -> std::io::Result { - stdout_pipe - .map(std::io::read_to_string) - .transpose() - .map(|opt| opt.unwrap_or_default()) - }); - let stderr_thread = std::thread::spawn(move || -> std::io::Result { - stderr_pipe - .map(std::io::read_to_string) +/// Handle for a thread draining one of the child's output pipes. +type PipeReader = JoinHandle>; + +/// Drains an optional child pipe on its own thread, yielding an empty string +/// when the pipe is absent. +fn spawn_pipe_reader(pipe: Option) -> PipeReader +where + R: Read + Send + 'static, +{ + std::thread::spawn(move || { + pipe.map(std::io::read_to_string) .transpose() - .map(|opt| opt.unwrap_or_default()) - }); - - match child.wait_timeout(GIT_TIMEOUT)? { - Some(status) => { - // Command completed within timeout - collect output from threads - let stdout = stdout_thread - .join() - .map_err(|_| InstallerError::Git { - operation, - message: "failed to read stdout".to_owned(), - })? - .unwrap_or_default(); - let stderr = stderr_thread - .join() - .map_err(|_| InstallerError::Git { - operation, - message: "failed to read stderr".to_owned(), - })? - .unwrap_or_default(); - - Ok(Output { - status, - stdout: stdout.into_bytes(), - stderr: stderr.into_bytes(), - }) - } - None => { - // Timeout - kill the process and wait for threads to finish - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - Err(InstallerError::Git { - operation, - message: format!( - "operation timed out after {} seconds", - GIT_TIMEOUT.as_secs() - ), - }) - } + .map(Option::unwrap_or_default) + }) +} + +/// Joins a pipe reader, reporting a git error when the reader thread panicked. +fn join_pipe_reader(reader: PipeReader, operation: &'static str, stream: &str) -> Result { + Ok(reader + .join() + .map_err(|_| InstallerError::Git { + operation, + message: format!("failed to read {stream}"), + })? + .unwrap_or_default()) +} + +/// Kills a timed-out git child and reaps its reader threads before reporting. +/// +/// Each cleanup result is discarded deliberately: the operation has already +/// failed, so cleanup is best effort. +fn abandon_timed_out_git( + mut child: Child, + stdout_reader: PipeReader, + stderr_reader: PipeReader, + operation: &'static str, +) -> InstallerError { + drop(child.kill()); + drop(child.wait()); + drop(stdout_reader.join()); + drop(stderr_reader.join()); + InstallerError::Git { + operation, + message: format!( + "operation timed out after {} seconds", + GIT_TIMEOUT.as_secs() + ), } } #[cfg(test)] mod tests { + //! Tests for git clone and update helpers. + use super::*; #[test] diff --git a/installer/src/hex.rs b/installer/src/hex.rs index a21c6304..50a005e6 100644 --- a/installer/src/hex.rs +++ b/installer/src/hex.rs @@ -14,15 +14,25 @@ /// allocation. #[must_use] pub(crate) fn to_lower_hex(bytes: &[u8]) -> String { - const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut hex = String::with_capacity(bytes.len() * 2); for &byte in bytes { - hex.push(char::from(HEX_DIGITS[usize::from(byte >> 4)])); - hex.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)])); + hex.push(nibble_to_hex(byte >> 4)); + hex.push(nibble_to_hex(byte & 0x0f)); } hex } +/// Map the low four bits of `nibble` to a lowercase hexadecimal digit. +fn nibble_to_hex(nibble: u8) -> char { + let masked = nibble & 0x0f; + let ascii = if masked < 10 { + b'0' + masked + } else { + b'a' + (masked - 10) + }; + char::from(ascii) +} + #[cfg(test)] mod tests { //! Tests for the lowercase hexadecimal digest formatter. diff --git a/installer/src/install_flow.rs b/installer/src/install_flow/mod.rs similarity index 86% rename from installer/src/install_flow.rs rename to installer/src/install_flow/mod.rs index 54bf69a1..595346df 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow/mod.rs @@ -3,28 +3,24 @@ //! This module keeps prebuilt-download fallback and metrics recording logic //! separate from CLI orchestration in `main.rs`. -use camino::Utf8Path; -use camino::Utf8PathBuf; -use std::collections::HashSet; -use std::fs; -use std::io; -use std::io::Write; -use std::time::Duration; -use whitaker_installer::builder::{library_extension, library_prefix}; -use whitaker_installer::cli::InstallArgs; -use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::{ - CommandExecutor, check_dylint_tools, install_dylint_tools_with_output, -}; +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::dirs::BaseDirs; -use whitaker_installer::error::{InstallerError, Result}; -use whitaker_installer::install_metrics::{InstallMode, RecordOutcome, record_install}; -use whitaker_installer::output::write_stderr_line; -use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt}; -use whitaker_installer::prebuilt_path::prebuilt_library_dir; -use whitaker_installer::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}; +use whitaker_installer::{ + builder::{library_extension, library_prefix}, + cli::InstallArgs, + crate_name::CrateName, + deps::{CommandExecutor, check_dylint_tools, install_dylint_tools_with_output}, + dirs::BaseDirs, + error::{InstallerError, Result}, + install_metrics::{InstallMode, RecordOutcome, record_install}, + output::write_stderr_line, + prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt}, + prebuilt_path::prebuilt_library_dir, + resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}, +}; pub(crate) fn ensure_dylint_tools_core( quiet: bool, @@ -56,8 +52,8 @@ pub(crate) fn ensure_dylint_tools_with_executor( stderr: &mut dyn Write, ) -> Result<()> { let status = check_dylint_tools(executor); - ensure_dylint_tools_core(quiet, stderr, status.all_installed(), |stderr| { - install_dylint_tools_with_output(executor, &status, quiet, stderr) + ensure_dylint_tools_core(quiet, stderr, status.all_installed(), |sink| { + install_dylint_tools_with_output(executor, &status, quiet, sink) }) } @@ -65,11 +61,11 @@ pub(crate) fn ensure_dylint_tools_with_executor( pub(crate) fn ensure_dylint_tools_with_options( executor: &dyn CommandExecutor, stderr: &mut dyn Write, - options: DependencyInstallOptions<'_>, + options: &DependencyInstallOptions<'_>, ) -> Result<()> { let status = check_dylint_tools(executor); - ensure_dylint_tools_core(options.quiet, stderr, status.all_installed(), |stderr| { - install_dylint_tools_with_options(executor, &status, stderr, options) + ensure_dylint_tools_core(options.quiet, stderr, status.all_installed(), |sink| { + install_dylint_tools_with_options(executor, &status, sink, options) }) } @@ -112,14 +108,17 @@ pub(crate) fn write_prebuilt_fallback_message( } /// Attempt prebuilt installation and return staged path when successful. +/// +/// Returns `None` when prebuilt installation is skipped or fails; every failure +/// mode is reported on `stderr` and falls back to local compilation. pub(crate) fn try_prebuilt_installation( context: &PrebuiltInstallationContext<'_>, stderr: &mut dyn Write, -) -> Result> { +) -> Option { try_prebuilt_installation_with( context, stderr, - PrebuiltInstallationHooks { + &PrebuiltInstallationHooks { detect_host_target, resolve_destination_dir: prebuilt_library_dir, attempt_prebuilt, @@ -133,6 +132,7 @@ type ResolveDestinationDirFn = fn(&dyn BaseDirs, &str, &str) -> Result, &mut dyn Write) -> PrebuiltResult; type PruneLibrariesFn = fn(&Utf8Path, &str, &[CrateName]) -> Result<()>; +#[derive(Clone, Copy)] struct PrebuiltInstallationHooks { detect_host_target: DetectHostTargetFn, resolve_destination_dir: ResolveDestinationDirFn, @@ -143,27 +143,27 @@ struct PrebuiltInstallationHooks { fn try_prebuilt_installation_with( context: &PrebuiltInstallationContext<'_>, stderr: &mut dyn Write, - hooks: PrebuiltInstallationHooks, -) -> Result> { + hooks: &PrebuiltInstallationHooks, +) -> Option { let PrebuiltInstallationHooks { detect_host_target, resolve_destination_dir, attempt_prebuilt, prune_prebuilt_libraries, - } = hooks; + } = *hooks; if !context .args .should_attempt_prebuilt(context.requested_crates) { - return Ok(None); + return None; } let host_target = match detect_host_target() { Ok(target) => target, Err(error) => { write_prebuilt_fallback_message(context.args.quiet, &error, stderr); - return Ok(None); + return None; } }; @@ -172,7 +172,7 @@ fn try_prebuilt_installation_with( Ok(destination) => destination, Err(error) => { write_prebuilt_fallback_message(context.args.quiet, &error, stderr); - return Ok(None); + return None; } }; @@ -185,7 +185,7 @@ fn try_prebuilt_installation_with( let PrebuiltResult::Success { staging_path } = attempt_prebuilt(&prebuilt_config, stderr) else { - return Ok(None); + return None; }; if let Err(error) = prune_prebuilt_libraries( &staging_path, @@ -193,9 +193,9 @@ fn try_prebuilt_installation_with( context.requested_crates, ) { write_prebuilt_fallback_message(context.args.quiet, &error, stderr); - return Ok(None); + return None; } - Ok(Some(staging_path)) + Some(staging_path) } fn requested_crate_names(requested_crates: &[CrateName]) -> HashSet<&str> { diff --git a/installer/src/install_flow/tests.rs b/installer/src/install_flow/tests.rs index c520829a..0b3c2b67 100644 --- a/installer/src/install_flow/tests.rs +++ b/installer/src/install_flow/tests.rs @@ -1,10 +1,14 @@ //! Unit tests for install-flow prebuilt staging and fallback behaviour. -use super::*; +use std::{ + path::PathBuf, + sync::atomic::{AtomicBool, Ordering}, +}; + use camino::Utf8PathBuf; use rstest::{fixture, rstest}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; + +use super::*; struct StagingFixture { _temp_dir: tempfile::TempDir, @@ -17,30 +21,19 @@ struct TestBaseDirs { } impl BaseDirs for TestBaseDirs { - fn home_dir(&self) -> Option { - None - } - fn bin_dir(&self) -> Option { - None - } - fn whitaker_data_dir(&self) -> Option { - self.data_dir.clone() - } + fn home(&self) -> Option { None } + fn executables(&self) -> Option { None } + fn whitaker_data(&self) -> Option { self.data_dir.clone() } } static PRUNE_HOOK_CALLED: AtomicBool = AtomicBool::new(false); -fn stub_detect_host_target() -> Result { - Ok("x86_64-unknown-linux-gnu".to_owned()) -} +/// Host-target detection stub that always resolves to a fixed Linux target. +const STUB_DETECT_HOST_TARGET: DetectHostTargetFn = || Ok("x86_64-unknown-linux-gnu".to_owned()); -fn stub_resolve_destination_dir( - _dirs: &dyn BaseDirs, - _toolchain_channel: &str, - _host_target: &str, -) -> Result { - Ok(Utf8PathBuf::from("/tmp/whitaker-test-data/lints")) -} +/// Destination resolution stub that always yields a fixed library directory. +const STUB_RESOLVE_DESTINATION_DIR: ResolveDestinationDirFn = + |_dirs, _channel, _host_target| Ok(Utf8PathBuf::from("/tmp/whitaker-test-data/lints")); fn stub_attempt_prebuilt(_config: &PrebuiltConfig<'_>, _stderr: &mut dyn Write) -> PrebuiltResult { PrebuiltResult::Success { @@ -60,27 +53,26 @@ fn stub_prune_prebuilt_libraries( } #[fixture] -fn staging_fixture() -> StagingFixture { - let temp_dir = tempfile::tempdir().expect("tempdir should be available"); +fn staging_fixture() -> std::io::Result { + let temp_dir = tempfile::tempdir()?; let staging_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()) - .expect("tempdir path should be utf-8"); - fs::create_dir_all(staging_path.as_std_path()).expect("staging path should be creatable"); - StagingFixture { + .map_err(|_| std::io::Error::other("temporary directory path must be UTF-8"))?; + fs::create_dir_all(staging_path.as_std_path())?; + Ok(StagingFixture { _temp_dir: temp_dir, staging_path, toolchain: "nightly-2026-05-28", - } + }) } fn create_staged_library( staging_path: &Utf8Path, crate_name: &str, toolchain: &str, -) -> Utf8PathBuf { +) -> std::io::Result { let library_path = staging_path.join(staged_library_filename(crate_name, toolchain)); - fs::write(library_path.as_std_path(), b"fake prebuilt library") - .expect("test setup should write staged library"); - library_path + fs::write(library_path.as_std_path(), b"fake prebuilt library")?; + Ok(library_path) } #[rstest] @@ -100,15 +92,16 @@ fn create_staged_library( &[SUITE_CRATE, "no_expect_outside_tests"] )] fn prune_prebuilt_libraries_keeps_only_requested_crates( - staging_fixture: StagingFixture, + #[from(staging_fixture)] staging_res: std::io::Result, #[case] requested: &[&str], #[case] retained: &[&str], #[case] removed: &[&str], ) { + let staging_fixture = staging_res.expect("staging fixture should be created"); let StagingFixture { - _temp_dir: _, staging_path, toolchain, + .. } = staging_fixture; let foreign_path = staging_path.join("libforeign_lint@nightly-2026-05-28.so"); @@ -117,7 +110,8 @@ fn prune_prebuilt_libraries_keeps_only_requested_crates( let mut staged = Vec::new(); for crate_name in retained.iter().chain(removed.iter()) { - let path = create_staged_library(&staging_path, crate_name, toolchain); + let path = create_staged_library(&staging_path, crate_name, toolchain) + .expect("test setup should write staged library"); staged.push(((*crate_name).to_owned(), path)); } @@ -180,29 +174,29 @@ fn try_prebuilt_installation_prune_error_falls_back_to_local_build() { let result = try_prebuilt_installation_with( &context, &mut stderr, - PrebuiltInstallationHooks { - detect_host_target: stub_detect_host_target, - resolve_destination_dir: stub_resolve_destination_dir, + &PrebuiltInstallationHooks { + detect_host_target: STUB_DETECT_HOST_TARGET, + resolve_destination_dir: STUB_RESOLVE_DESTINATION_DIR, attempt_prebuilt: stub_attempt_prebuilt, prune_prebuilt_libraries: stub_prune_prebuilt_libraries, }, ); assert!( - matches!(result, Ok(None)), + result.is_none(), "prune failure should trigger fallback to local compilation" ); assert!( PRUNE_HOOK_CALLED.load(Ordering::SeqCst), "prune hook should be invoked" ); - let stderr = String::from_utf8(stderr).expect("stderr should be utf-8"); + let stderr_text = String::from_utf8(stderr).expect("stderr should be utf-8"); assert!( - stderr.contains("Prebuilt download unavailable: staging failed: forced prune failure"), - "fallback reason should include prune error, stderr: {stderr}" + stderr_text.contains("Prebuilt download unavailable: staging failed: forced prune failure"), + "fallback reason should include prune error, stderr: {stderr_text}" ); assert!( - stderr.contains("Falling back to local compilation."), - "fallback message should be emitted, stderr: {stderr}" + stderr_text.contains("Falling back to local compilation."), + "fallback message should be emitted, stderr: {stderr_text}" ); } diff --git a/installer/src/install_metrics.rs b/installer/src/install_metrics.rs index c05fcb81..9d1be414 100644 --- a/installer/src/install_metrics.rs +++ b/installer/src/install_metrics.rs @@ -4,13 +4,17 @@ //! Metrics are stored in Whitaker's data directory at: //! `/metrics/install_metrics.json`. -use crate::dirs::BaseDirs; +use std::{ + fs::{File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + use fs2::FileExt; use serde::{Deserialize, Serialize}; -use std::fs::{File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::time::Duration; + +use crate::dirs::BaseDirs; const METRICS_DIRNAME: &str = "metrics"; const METRICS_FILENAME: &str = "install_metrics.json"; @@ -42,6 +46,7 @@ impl InstallMetrics { /// /// ``` /// use std::time::Duration; + /// /// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode}; /// /// let mut metrics = InstallMetrics::default(); @@ -49,9 +54,7 @@ impl InstallMetrics { /// assert_eq!(metrics.total_installs(), 1); /// ``` #[must_use] - pub fn total_installs(&self) -> u64 { - self.total_installs - } + pub const fn total_installs(&self) -> u64 { self.total_installs } /// Returns the number of successful prebuilt-download installs. /// @@ -59,6 +62,7 @@ impl InstallMetrics { /// /// ``` /// use std::time::Duration; + /// /// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode}; /// /// let mut metrics = InstallMetrics::default(); @@ -66,9 +70,7 @@ impl InstallMetrics { /// assert_eq!(metrics.download_installs(), 1); /// ``` #[must_use] - pub fn download_installs(&self) -> u64 { - self.download_installs - } + pub const fn download_installs(&self) -> u64 { self.download_installs } /// Returns the number of successful local-build installs. /// @@ -76,6 +78,7 @@ impl InstallMetrics { /// /// ``` /// use std::time::Duration; + /// /// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode}; /// /// let mut metrics = InstallMetrics::default(); @@ -83,9 +86,7 @@ impl InstallMetrics { /// assert_eq!(metrics.build_installs(), 1); /// ``` #[must_use] - pub fn build_installs(&self) -> u64 { - self.build_installs - } + pub const fn build_installs(&self) -> u64 { self.build_installs } /// Returns total cumulative install duration. /// @@ -93,6 +94,7 @@ impl InstallMetrics { /// /// ``` /// use std::time::Duration; + /// /// use whitaker_installer::install_metrics::InstallMetrics; /// /// assert_eq!( @@ -101,36 +103,36 @@ impl InstallMetrics { /// ); /// ``` #[must_use] - pub fn total_install_duration(&self) -> Duration { + pub const fn total_install_duration(&self) -> Duration { Duration::from_millis(self.total_install_millis) } - /// Returns `download_installs / total_installs`. + /// Returns `download_installs / total_installs` in permille (0–1000). /// /// # Examples /// /// ``` /// use whitaker_installer::install_metrics::InstallMetrics; /// - /// assert_eq!(InstallMetrics::default().download_rate(), 0.0); + /// assert_eq!(InstallMetrics::default().download_rate_permille(), 0); /// ``` #[must_use] - pub fn download_rate(&self) -> f64 { - rate(self.download_installs, self.total_installs) + pub const fn download_rate_permille(&self) -> u64 { + rate_permille(self.download_installs, self.total_installs) } - /// Returns `build_installs / total_installs`. + /// Returns `build_installs / total_installs` in permille (0–1000). /// /// # Examples /// /// ``` /// use whitaker_installer::install_metrics::InstallMetrics; /// - /// assert_eq!(InstallMetrics::default().build_rate(), 0.0); + /// assert_eq!(InstallMetrics::default().build_rate_permille(), 0); /// ``` #[must_use] - pub fn build_rate(&self) -> f64 { - rate(self.build_installs, self.total_installs) + pub const fn build_rate_permille(&self) -> u64 { + rate_permille(self.build_installs, self.total_installs) } /// Records one successful install event. @@ -139,6 +141,7 @@ impl InstallMetrics { /// /// ``` /// use std::time::Duration; + /// /// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode}; /// /// let mut metrics = InstallMetrics::default(); @@ -169,6 +172,7 @@ impl InstallMetrics { /// /// ``` /// use std::time::Duration; + /// /// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode}; /// /// let mut metrics = InstallMetrics::default(); @@ -179,17 +183,19 @@ impl InstallMetrics { /// ``` #[must_use] pub fn summary_line(&self) -> String { + let download_percent = format_permille_as_percent(self.download_rate_permille()); + let build_percent = format_permille_as_percent(self.build_rate_permille()); format!( concat!( - "Install metrics: download {}/{} ({:.1}%), build {}/{} ({:.1}%), ", + "Install metrics: download {}/{} ({}%), build {}/{} ({}%), ", "total installation time {}" ), self.download_installs, self.total_installs, - self.download_rate() * 100.0, + download_percent, self.build_installs, self.total_installs, - self.build_rate() * 100.0, + build_percent, format_duration(self.total_install_duration()), ) } @@ -204,18 +210,19 @@ pub struct RecordOutcome { impl RecordOutcome { /// Returns the updated aggregate metrics. #[must_use] - pub fn metrics(&self) -> &InstallMetrics { - &self.metrics - } + pub const fn metrics(&self) -> &InstallMetrics { &self.metrics } /// Returns true when a malformed metrics file was reset to defaults. #[must_use] - pub fn recovered_from_corrupt_file(&self) -> bool { - self.recovered_from_corrupt_file - } + pub const fn recovered_from_corrupt_file(&self) -> bool { self.recovered_from_corrupt_file } } /// Records one successful install in Whitaker's metrics store. +/// +/// # Errors +/// +/// Returns an [`InstallMetricsError`] when the data directory is missing or +/// the metrics file cannot be created, locked, read, or written. pub fn record_install( dirs: &dyn BaseDirs, mode: InstallMode, @@ -226,6 +233,11 @@ pub fn record_install( } /// Records one successful install at an explicit metrics file path. +/// +/// # Errors +/// +/// Returns an [`InstallMetricsError`] when the metrics directory or file +/// cannot be created, or the file cannot be locked, read, or written. pub fn record_install_at_path( metrics_path: &Path, mode: InstallMode, @@ -254,7 +266,7 @@ pub fn record_install_at_path( fn metrics_path(dirs: &dyn BaseDirs) -> Result { let data_dir = dirs - .whitaker_data_dir() + .whitaker_data() .ok_or(InstallMetricsError::MissingDataDirectory)?; Ok(data_dir.join(METRICS_DIRNAME).join(METRICS_FILENAME)) } @@ -309,10 +321,10 @@ fn load_metrics( return Ok((InstallMetrics::default(), false)); } - match serde_json::from_str::(&content) { - Ok(metrics) => Ok((metrics, false)), - Err(_) => Ok((InstallMetrics::default(), true)), - } + serde_json::from_str::(&content).map_or_else( + |_| Ok((InstallMetrics::default(), true)), + |metrics| Ok((metrics, false)), + ) } fn persist_metrics( @@ -333,14 +345,21 @@ fn persist_metrics( }) } -fn rate(part: u64, whole: u64) -> f64 { +/// Returns `part / whole` in permille (parts per thousand), or zero when +/// `whole` is zero. The product saturates rather than overflowing. +const fn rate_permille(part: u64, whole: u64) -> u64 { if whole == 0 { - 0.0 + 0 } else { - part as f64 / whole as f64 + part.saturating_mul(1000).div_euclid(whole) } } +/// Renders a permille value as a percentage with one decimal place. +fn format_permille_as_percent(permille: u64) -> String { + format!("{}.{}", permille.div_euclid(10), permille.rem_euclid(10)) +} + fn duration_to_millis(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } @@ -348,9 +367,9 @@ fn duration_to_millis(duration: Duration) -> u64 { fn format_duration(duration: Duration) -> String { let total_seconds = duration.as_secs(); let millis = duration.subsec_millis(); - let hours = total_seconds / 3600; - let minutes = (total_seconds % 3600) / 60; - let seconds = total_seconds % 60; + let hours = total_seconds.div_euclid(3600); + let minutes = total_seconds.rem_euclid(3600).div_euclid(60); + let seconds = total_seconds.rem_euclid(60); if should_format_with_hours(hours) { return format!("{hours}h {minutes}m {seconds}.{millis:03}s"); @@ -361,10 +380,6 @@ fn format_duration(duration: Duration) -> String { format!("{seconds}.{millis:03}s") } -fn should_format_with_hours(hours: u64) -> bool { - hours > 0 -} +const fn should_format_with_hours(hours: u64) -> bool { hours > 0 } -fn should_format_with_minutes(minutes: u64) -> bool { - minutes > 0 -} +const fn should_format_with_minutes(minutes: u64) -> bool { minutes > 0 } diff --git a/installer/src/install_metrics_tests.rs b/installer/src/install_metrics_tests.rs index bf9760d8..484e1c7d 100644 --- a/installer/src/install_metrics_tests.rs +++ b/installer/src/install_metrics_tests.rs @@ -1,12 +1,15 @@ //! Unit tests for installer metrics persistence and aggregation. +use std::{io::ErrorKind, path::PathBuf, time::Duration}; + +use rstest::{fixture, rstest}; + use crate::install_metrics::{ - InstallMetrics, InstallMetricsError, InstallMode, record_install_at_path, + InstallMetrics, + InstallMetricsError, + InstallMode, + record_install_at_path, }; -use rstest::{fixture, rstest}; -use std::io::ErrorKind; -use std::path::PathBuf; -use std::time::Duration; struct MetricsPathFixture { _temp_dir: tempfile::TempDir, @@ -14,20 +17,24 @@ struct MetricsPathFixture { } #[fixture] -fn metrics_path_fixture() -> MetricsPathFixture { - let temp_dir = tempfile::tempdir().expect("create tempdir"); +fn metrics_path_fixture() -> std::io::Result { + let temp_dir = tempfile::tempdir()?; let metrics_path = temp_dir.path().join("metrics").join("install_metrics.json"); - MetricsPathFixture { + Ok(MetricsPathFixture { _temp_dir: temp_dir, metrics_path, - } + }) } #[test] fn zero_state_rates_are_zero() { let metrics = InstallMetrics::default(); - assert_eq!(metrics.download_rate(), 0.0); - assert_eq!(metrics.build_rate(), 0.0); + assert_eq!( + metrics.download_rate_permille(), + 0, + "zero-state download rate" + ); + assert_eq!(metrics.build_rate_permille(), 0, "zero-state build rate"); } #[test] @@ -40,12 +47,20 @@ fn record_install_updates_counts_and_duration() { assert_eq!(metrics.download_installs(), 1); assert_eq!(metrics.build_installs(), 1); assert_eq!(metrics.total_install_duration(), Duration::from_secs(2)); - assert!((metrics.download_rate() - 0.5).abs() < f64::EPSILON); - assert!((metrics.build_rate() - 0.5).abs() < f64::EPSILON); + assert_eq!( + metrics.download_rate_permille(), + 500, + "download rate permille" + ); + assert_eq!(metrics.build_rate_permille(), 500, "build rate permille"); } #[rstest] -fn record_install_at_path_creates_metrics_file(metrics_path_fixture: MetricsPathFixture) { +fn record_install_at_path_creates_metrics_file( + #[from(metrics_path_fixture)] metrics_path_res: std::io::Result, +) { + let metrics_path_fixture = metrics_path_res.expect("metrics fixture should be created"); + let result = record_install_at_path( &metrics_path_fixture.metrics_path, InstallMode::Download, @@ -58,7 +73,11 @@ fn record_install_at_path_creates_metrics_file(metrics_path_fixture: MetricsPath } #[rstest] -fn malformed_metrics_file_is_reset_and_recovered(metrics_path_fixture: MetricsPathFixture) { +fn malformed_metrics_file_is_reset_and_recovered( + #[from(metrics_path_fixture)] metrics_path_res: std::io::Result, +) { + let metrics_path_fixture = metrics_path_res.expect("metrics fixture should be created"); + std::fs::create_dir_all( metrics_path_fixture .metrics_path @@ -82,7 +101,11 @@ fn malformed_metrics_file_is_reset_and_recovered(metrics_path_fixture: MetricsPa } #[rstest] -fn persistence_failure_is_reported(metrics_path_fixture: MetricsPathFixture) { +fn persistence_failure_is_reported( + #[from(metrics_path_fixture)] metrics_path_res: std::io::Result, +) { + let metrics_path_fixture = metrics_path_res.expect("metrics fixture should be created"); + std::fs::create_dir_all(&metrics_path_fixture.metrics_path).expect("create blocking directory"); let error = record_install_at_path( @@ -110,7 +133,11 @@ fn persistence_failure_is_reported(metrics_path_fixture: MetricsPathFixture) { } #[rstest] -fn summary_line_includes_rates_and_total_time(metrics_path_fixture: MetricsPathFixture) { +fn summary_line_includes_rates_and_total_time( + #[from(metrics_path_fixture)] metrics_path_res: std::io::Result, +) { + let metrics_path_fixture = metrics_path_res.expect("metrics fixture should be created"); + record_install_at_path( &metrics_path_fixture.metrics_path, InstallMode::Download, @@ -131,7 +158,11 @@ fn summary_line_includes_rates_and_total_time(metrics_path_fixture: MetricsPathF } #[rstest] -fn long_durations_saturate_total_install_time(metrics_path_fixture: MetricsPathFixture) { +fn long_durations_saturate_total_install_time( + #[from(metrics_path_fixture)] metrics_path_res: std::io::Result, +) { + let metrics_path_fixture = metrics_path_res.expect("metrics fixture should be created"); + std::fs::create_dir_all( metrics_path_fixture .metrics_path @@ -168,16 +199,24 @@ fn long_durations_saturate_total_install_time(metrics_path_fixture: MetricsPathF } #[rstest] -fn concurrent_records_do_not_lose_updates(metrics_path_fixture: MetricsPathFixture) { +fn concurrent_records_do_not_lose_updates( + #[from(metrics_path_fixture)] metrics_path_res: std::io::Result, +) { + let metrics_path_fixture = metrics_path_res.expect("metrics fixture should be created"); + let path = metrics_path_fixture.metrics_path; let mut threads = Vec::new(); for _ in 0..4 { - let path = path.clone(); + let writer_path = path.clone(); threads.push(std::thread::spawn(move || { for _ in 0..20 { - record_install_at_path(&path, InstallMode::Download, Duration::from_millis(1)) - .expect("record install from concurrent writer"); + record_install_at_path( + &writer_path, + InstallMode::Download, + Duration::from_millis(1), + ) + .expect("record install from concurrent writer"); } })); } diff --git a/installer/src/installer_packaging.rs b/installer/src/installer_packaging.rs index 4ad2b606..2f6253ca 100644 --- a/installer/src/installer_packaging.rs +++ b/installer/src/installer_packaging.rs @@ -16,15 +16,17 @@ //! //! Windows archives use `.zip` format and the `.exe` suffix. -pub use crate::artefact::target::TargetTriple; -pub use crate::version::Version; +use std::{ + fs, + io, + path::{Path, PathBuf}, +}; -use crate::binstall_metadata::{DEFAULT_PKG_FMT, WINDOWS_PKG_FMT}; -use std::fs; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; use thiserror::Error; +use crate::binstall_metadata::{DEFAULT_PKG_FMT, WINDOWS_PKG_FMT}; +pub use crate::{artefact::target::TargetTriple, version::Version}; + /// The crate name used in archive and directory names. const CRATE_NAME: &str = "whitaker-installer"; @@ -82,12 +84,15 @@ pub enum InstallerPackagingError { /// # Examples /// /// ``` -/// use whitaker_installer::installer_packaging::{archive_filename, Version, TargetTriple}; +/// use whitaker_installer::installer_packaging::{TargetTriple, Version, archive_filename}; /// /// let v = Version::new("0.2.1"); /// let t = TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"); /// let name = archive_filename(&v, &t); -/// assert_eq!(name, "whitaker-installer-x86_64-unknown-linux-gnu-v0.2.1.tgz"); +/// assert_eq!( +/// name, +/// "whitaker-installer-x86_64-unknown-linux-gnu-v0.2.1.tgz" +/// ); /// ``` #[must_use] pub fn archive_filename(version: &Version, target: &TargetTriple) -> String { @@ -111,7 +116,7 @@ pub fn archive_filename(version: &Version, target: &TargetTriple) -> String { /// # Examples /// /// ``` -/// use whitaker_installer::installer_packaging::{inner_dir_name, Version, TargetTriple}; +/// use whitaker_installer::installer_packaging::{TargetTriple, Version, inner_dir_name}; /// /// let v = Version::new("0.2.1"); /// let t = TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target"); @@ -131,7 +136,7 @@ pub fn inner_dir_name(version: &Version, target: &TargetTriple) -> String { /// # Examples /// /// ``` -/// use whitaker_installer::installer_packaging::{binary_filename, TargetTriple}; +/// use whitaker_installer::installer_packaging::{TargetTriple, binary_filename}; /// /// assert_eq!( /// binary_filename(&TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid")), @@ -159,7 +164,7 @@ pub fn binary_filename(target: &TargetTriple) -> String { /// # Examples /// /// ``` -/// use whitaker_installer::installer_packaging::{archive_format, ArchiveFormat, TargetTriple}; +/// use whitaker_installer::installer_packaging::{ArchiveFormat, TargetTriple, archive_format}; /// /// assert_eq!( /// archive_format(&TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid")), @@ -191,7 +196,7 @@ pub fn archive_format(target: &TargetTriple) -> ArchiveFormat { /// not exist, or [`InstallerPackagingError::Io`] / /// [`InstallerPackagingError::Zip`] on archive creation failures. pub fn package_installer( - params: InstallerPackageParams, + params: &InstallerPackageParams, ) -> Result { if !params.binary_path.is_file() { return Err(InstallerPackagingError::BinaryNotFound( @@ -234,8 +239,8 @@ fn create_tgz_archive( let archive_entry_path = format!("{inner_dir}/{bin_name}"); archive.append_path_with_name(binary_path, &archive_entry_path)?; - let gz_encoder = archive.into_inner()?; - gz_encoder.finish()?; + let finished_encoder = archive.into_inner()?; + finished_encoder.finish()?; Ok(()) } @@ -256,14 +261,7 @@ fn create_zip_archive( zip_writer.start_file(&archive_entry_path, options)?; let mut binary_file = fs::File::open(binary_path)?; - let mut buffer = [0u8; 8192]; - loop { - let bytes_read = binary_file.read(&mut buffer)?; - if bytes_read == 0 { - break; - } - zip_writer.write_all(&buffer[..bytes_read])?; - } + io::copy(&mut binary_file, &mut zip_writer)?; zip_writer.finish()?; Ok(()) diff --git a/installer/src/installer_packaging_tests.rs b/installer/src/installer_packaging_tests.rs index f3f0118d..8bd9350f 100644 --- a/installer/src/installer_packaging_tests.rs +++ b/installer/src/installer_packaging_tests.rs @@ -1,15 +1,26 @@ //! Unit tests for installer binary archive packaging. -use super::*; -use crate::binstall_metadata; +use std::{fs, io::Read}; + use rstest::rstest; -use std::fs; -use std::io::Read; + +use super::*; +use crate::{artefact::error::ArtefactError, binstall_metadata}; // --------------------------------------------------------------------------- // Shared fixtures // --------------------------------------------------------------------------- +/// Reports whether `name` ends in `extension`, ignoring ASCII case. +/// +/// Archive names are generated by the installer, but comparing case-insensitively +/// keeps the assertion honest on case-preserving file systems. +fn has_extension(name: &str, extension: &str) -> bool { + std::path::Path::new(name) + .extension() + .is_some_and(|actual| actual.eq_ignore_ascii_case(extension)) +} + /// A temporary directory with a fake binary suitable for packaging. struct PackagingFixture { temp: tempfile::TempDir, @@ -18,12 +29,12 @@ struct PackagingFixture { /// Create a [`PackagingFixture`] for the given target, writing a fake binary /// with the provided content into a fresh temporary directory. -fn packaging_fixture(target: &str, content: &[u8]) -> PackagingFixture { - let temp = tempfile::tempdir().expect("temp dir"); - let bin_name = binary_filename(&TargetTriple::try_from(target).expect("valid target")); - let binary_path = temp.path().join(bin_name); - fs::write(&binary_path, content).expect("write fake binary"); - PackagingFixture { temp, binary_path } +fn packaging_fixture(target: &str, content: &[u8]) -> std::io::Result { + let temp = tempfile::tempdir()?; + let triple = TargetTriple::try_from(target).map_err(std::io::Error::other)?; + let binary_path = temp.path().join(binary_filename(&triple)); + fs::write(&binary_path, content)?; + Ok(PackagingFixture { temp, binary_path }) } /// Build [`InstallerPackageParams`] from a fixture, version, and target. @@ -31,44 +42,42 @@ fn params_from_fixture( fixture: &PackagingFixture, version: &str, target: &str, -) -> InstallerPackageParams { - InstallerPackageParams { +) -> std::result::Result { + Ok(InstallerPackageParams { version: Version::new(version), - target: TargetTriple::try_from(target).expect("valid target"), + target: TargetTriple::try_from(target)?, binary_path: fixture.binary_path.clone(), output_dir: fixture.temp.path().to_path_buf(), - } + }) } /// Read entry paths from a `.tgz` archive, failing explicitly on errors. -fn read_tgz_entry_paths(archive_path: &std::path::Path) -> Vec { - let file = fs::File::open(archive_path).expect("open archive"); +fn read_tgz_entry_paths(archive_path: &std::path::Path) -> std::io::Result> { + let file = fs::File::open(archive_path)?; let gz = flate2::read::GzDecoder::new(file); let mut tar_archive = tar::Archive::new(gz); tar_archive - .entries() - .expect("entries") - .map(|e| { - let entry = e.expect("valid tar entry"); - entry - .path() - .expect("valid entry path") - .to_string_lossy() - .into_owned() + .entries()? + .map(|entry_result| { + let entry = entry_result?; + let path = entry.path()?; + Ok(path.to_string_lossy().into_owned()) }) .collect() } /// Read entry names from a `.zip` archive, failing explicitly on errors. -fn read_zip_entry_names(archive_path: &std::path::Path) -> Vec { - let file = fs::File::open(archive_path).expect("open archive"); - let zip_archive = zip::ZipArchive::new(file).expect("open zip"); +fn read_zip_entry_names( + archive_path: &std::path::Path, +) -> std::result::Result, zip::result::ZipError> { + let file = fs::File::open(archive_path)?; + let zip_archive = zip::ZipArchive::new(file)?; (0..zip_archive.len()) - .map(|i| { + .map(|index| { zip_archive - .name_for_index(i) - .expect("valid zip entry name") - .to_owned() + .name_for_index(index) + .map(str::to_owned) + .ok_or(zip::result::ZipError::FileNotFound) }) .collect() } @@ -86,7 +95,10 @@ fn archive_filename_tgz_for_non_windows(#[case] target: &str) { let v = Version::new("0.2.1"); let t = TargetTriple::try_from(target).expect("valid target"); let name = archive_filename(&v, &t); - assert!(name.ends_with(".tgz"), "expected .tgz suffix, got {name}"); + assert!( + has_extension(&name, "tgz"), + "expected .tgz suffix, got {name}" + ); assert!(name.contains(target), "expected target in name, got {name}"); assert!( name.contains("v0.2.1"), @@ -186,19 +198,21 @@ fn package_installer_creates_archive( #[case] expected_name: &str, #[case] expected_entry: &str, ) { - let fixture = packaging_fixture(target, content); - let params = params_from_fixture(&fixture, "0.2.1", target); - let output = package_installer(params).expect("packaging should succeed"); + let fixture = packaging_fixture(target, content).expect("packaging fixture should be staged"); + let params = + params_from_fixture(&fixture, "0.2.1", target).expect("packaging params should build"); + let output = package_installer(¶ms).expect("packaging should succeed"); assert!(output.archive_path.exists(), "archive should exist"); assert_eq!(output.archive_name, expected_name); - let entries = if expected_name.ends_with(".tgz") { - read_tgz_entry_paths(&output.archive_path) + let entries = if has_extension(expected_name, "tgz") { + read_tgz_entry_paths(&output.archive_path).expect("tgz archive should list entries") } else { - read_zip_entry_names(&output.archive_path) + read_zip_entry_names(&output.archive_path).expect("zip archive should list entries") }; assert_eq!(entries.len(), 1, "expected 1 entry, got {entries:?}"); - assert_eq!(entries[0], expected_entry); + let entry = entries.first().expect("archive should contain one entry"); + assert_eq!(entry, expected_entry); } #[test] @@ -213,7 +227,7 @@ fn package_installer_rejects_missing_binary() { output_dir: temp.path().to_path_buf(), }; - let err = package_installer(params).expect_err("should fail"); + let err = package_installer(¶ms).expect_err("should fail"); assert!( matches!(err, InstallerPackagingError::BinaryNotFound(ref p) if *p == missing), "expected BinaryNotFound, got {err:?}" @@ -238,7 +252,7 @@ fn package_installer_returns_io_error_for_unwritable_output() { output_dir: unwritable, }; - let err = package_installer(params).expect_err("should fail on unwritable output dir"); + let err = package_installer(¶ms).expect_err("should fail on unwritable output dir"); assert!( matches!(err, InstallerPackagingError::Io(_)), "expected Io error, got {err:?}" @@ -271,10 +285,12 @@ fn archive_name_matches_binstall_template(#[case] target: &str, #[case] version: #[test] fn tgz_archive_preserves_binary_content() { let content = b"binary-payload-12345"; - let fixture = packaging_fixture("aarch64-unknown-linux-gnu", content); - let params = params_from_fixture(&fixture, "0.2.1", "aarch64-unknown-linux-gnu"); + let fixture = packaging_fixture("aarch64-unknown-linux-gnu", content) + .expect("packaging fixture should be staged"); + let params = params_from_fixture(&fixture, "0.2.1", "aarch64-unknown-linux-gnu") + .expect("packaging params should build"); - let output = package_installer(params).expect("packaging"); + let output = package_installer(¶ms).expect("packaging"); let file = fs::File::open(&output.archive_path).expect("open"); let gz = flate2::read::GzDecoder::new(file); let mut tar_archive = tar::Archive::new(gz); diff --git a/installer/src/lib.rs b/installer/src/lib.rs index 94e14c53..2e0c1afc 100644 --- a/installer/src/lib.rs +++ b/installer/src/lib.rs @@ -7,8 +7,7 @@ //! # Modules //! //! - [`artefact`] - Artefact naming, manifest schema, and verification policy -//! - [`binstall_metadata`] - Cargo-binstall metadata constants and template -//! expansion +//! - [`binstall_metadata`] - Cargo-binstall metadata constants and template expansion //! - [`builder`] - Cargo build orchestration for lint crates //! - [`cli`] - Command-line argument definitions //! - [`crate_name`] - Semantic wrapper for lint crate names @@ -17,8 +16,7 @@ //! - [`error`] - Semantic error types with recovery hints //! - [`git`] - Repository cloning and updating //! - [`install_metrics`] - Local installer metrics persistence and summaries -//! - [`installer_packaging`] - Installer binary archive packaging for release -//! distribution +//! - [`installer_packaging`] - Installer binary archive packaging for release distribution //! - [`list`] - List command implementation //! - [`list_output`] - Output formatting for lint listing //! - [`output`] - Shell snippet generation for environment configuration @@ -28,8 +26,8 @@ //! - [`resolution`] - Crate resolution and validation //! - [`scanner`] - Lint scanner for discovering installed libraries //! - [`stager`] - File staging with platform-specific naming conventions -//! - [`test_support`] - Hidden test-only hooks shared by installer behavioural -//! and integration tests +//! - [`test_support`] - Hidden test-only hooks shared by installer behavioural and integration +//! tests //! - [`toolchain`] - Rust toolchain detection and validation //! - [`version`] - Semantic crate version wrapper //! - [`workspace`] - Workspace detection and path resolution diff --git a/installer/src/list.rs b/installer/src/list.rs index 0ba73eb9..c148ab8f 100644 --- a/installer/src/list.rs +++ b/installer/src/list.rs @@ -3,17 +3,20 @@ //! This module provides the `run_list` command handler and supporting functions //! for querying and displaying installed lint libraries. +use std::io::Write; + use camino::{Utf8Path, Utf8PathBuf}; use log::trace; -use std::io::Write; -use crate::cli::ListArgs; -use crate::dirs::{BaseDirs, SystemBaseDirs}; -use crate::error::{InstallerError, Result}; -use crate::list_output::{format_human, format_json}; -use crate::scanner::{InstalledLints, scan_installed}; -use crate::stager::default_target_dir; -use crate::toolchain::Toolchain; +use crate::{ + cli::ListArgs, + dirs::{BaseDirs, SystemBaseDirs}, + error::{InstallerError, Result}, + list_output::{format_human, format_json}, + scanner::{InstalledLints, scan_installed}, + stager::default_target_dir, + toolchain::Toolchain, +}; /// Lists installed lint libraries and their associated lints. /// @@ -74,7 +77,7 @@ fn sort_installed_libraries(installed: &mut InstalledLints) { fn default_prebuilt_target_dir() -> Option { SystemBaseDirs::new() - .and_then(|dirs| dirs.whitaker_data_dir()) + .and_then(|dirs| dirs.whitaker_data()) .and_then(|path| Utf8PathBuf::from_path_buf(path).ok()) .map(|path| path.join("lints")) } @@ -109,6 +112,7 @@ fn determine_scan_roots(cli_target: Option<&Utf8Path>) -> Result Option { let cwd = match std::env::current_dir() { Ok(path) => path, @@ -175,253 +179,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use rstest::{fixture, rstest}; - use std::fs; - use tempfile::TempDir; - - // ------------------------------------------------------------------------- - // Fixtures - // ------------------------------------------------------------------------- - - /// A temporary directory converted to a UTF-8 path for test isolation. - struct TempTarget { - _temp: TempDir, - path: Utf8PathBuf, - } - - #[fixture] - fn temp_target() -> TempTarget { - let temp = TempDir::new().expect("failed to create temp dir"); - let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path"); - TempTarget { _temp: temp, path } - } - - /// A Write implementation that always fails, for testing error paths. - struct FailingWriter; - - impl std::io::Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> std::io::Result { - Err(std::io::Error::other("simulated write failure")) - } - - fn flush(&mut self) -> std::io::Result<()> { - Err(std::io::Error::other("simulated flush failure")) - } - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - #[derive(Debug, Clone, Copy)] - enum MockLibraryKind { - Local, - Prebuilt { target: &'static str }, - } - - impl MockLibraryKind { - fn library_dir(&self, target_dir: &Utf8Path, toolchain: &str) -> Utf8PathBuf { - match self { - Self::Local => target_dir.join(toolchain).join("release"), - Self::Prebuilt { target } => target_dir.join(toolchain).join(target).join("lib"), - } - } - - fn content(&self) -> &'static [u8] { - match self { - Self::Local => b"mock library", - Self::Prebuilt { .. } => b"mock prebuilt library", - } - } - } - - fn create_mock_library_internal(target_dir: &Utf8Path, toolchain: &str, kind: MockLibraryKind) { - use crate::builder::{library_extension, library_prefix}; - - let lib_dir = kind.library_dir(target_dir, toolchain); - fs::create_dir_all(&lib_dir).expect("failed to create target library directory"); - - let filename = format!( - "{}whitaker_suite@{toolchain}{}", - library_prefix(), - library_extension() - ); - - let error_msg = match kind { - MockLibraryKind::Local => "failed to create mock library", - MockLibraryKind::Prebuilt { .. } => "failed to create prebuilt mock library", - }; - fs::write(lib_dir.join(filename), kind.content()).expect(error_msg); - } - - /// Helper to create a mock installed library in the target directory for tests. - fn create_mock_library(target_dir: &Utf8Path, toolchain: &str) { - create_mock_library_internal(target_dir, toolchain, MockLibraryKind::Local); - } - - fn create_mock_prebuilt_library(target_dir: &Utf8Path, toolchain: &str, target: &'static str) { - create_mock_library_internal(target_dir, toolchain, MockLibraryKind::Prebuilt { target }); - } - - // ------------------------------------------------------------------------- - // run_list tests - // ------------------------------------------------------------------------- - - #[rstest] - fn run_list_outputs_human_readable_format(temp_target: TempTarget) { - let args = ListArgs { - json: false, - target_dir: Some(temp_target.path.clone()), - }; - let mut stdout = Vec::new(); - - let result = run_list_with(&args, &mut stdout, || None); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - let output = String::from_utf8_lossy(&stdout); - assert!(output.contains("No lints installed"), "got: {output}"); - } - - #[rstest] - #[case::json_format(true, &["toolchains", "\"active\""])] - #[case::human_format(false, &["nightly-2026-05-28", "whitaker_suite"])] - fn run_list_with_installed_library_includes_expected_output( - temp_target: TempTarget, - #[case] json: bool, - #[case] expected: &[&str], - ) { - create_mock_library(&temp_target.path, "nightly-2026-05-28"); - let args = ListArgs { - json, - target_dir: Some(temp_target.path.clone()), - }; - let mut stdout = Vec::new(); - - let result = run_list_with(&args, &mut stdout, || Some("nightly-2026-05-28".to_owned())); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - let output = String::from_utf8_lossy(&stdout); - for needle in expected { - assert!( - output.contains(needle), - "expected '{needle}' in output: {output}" - ); - } - } - - #[rstest] - fn run_list_finds_prebuilt_layout_libraries(temp_target: TempTarget) { - create_mock_prebuilt_library( - &temp_target.path, - "nightly-2026-05-28", - "x86_64-unknown-linux-gnu", - ); - let args = ListArgs { - json: false, - target_dir: Some(temp_target.path.clone()), - }; - let mut stdout = Vec::new(); - - let result = run_list_with(&args, &mut stdout, || Some("nightly-2026-05-28".to_owned())); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - let output = String::from_utf8_lossy(&stdout); - assert!(output.contains("nightly-2026-05-28"), "got: {output}"); - assert!(output.contains("whitaker_suite"), "got: {output}"); - } - - #[rstest] - fn run_list_returns_write_failed_on_stdout_error(temp_target: TempTarget) { - let args = ListArgs { - json: false, - target_dir: Some(temp_target.path.clone()), - }; - let mut failing_stdout = FailingWriter; - - let result = run_list_with(&args, &mut failing_stdout, || None); - - let err = result.expect_err("expected error on write failure"); - assert!( - matches!(err, InstallerError::WriteFailed { .. }), - "expected WriteFailed error, got: {err:?}" - ); - } - - // ------------------------------------------------------------------------- - // detect_active_toolchain_in tests - // ------------------------------------------------------------------------- - - #[rstest] - fn detect_active_toolchain_in_returns_none_when_no_toolchain_file(temp_target: TempTarget) { - let result = detect_active_toolchain_in(&temp_target.path); - assert!( - result.is_none(), - "expected None for directory without rust-toolchain.toml" - ); - } - - #[rstest] - fn detect_active_toolchain_in_returns_channel_when_toolchain_file_exists( - temp_target: TempTarget, - ) { - // Create a rust-toolchain.toml file - let toolchain_content = r#"[toolchain] -channel = "nightly-2026-05-28" -"#; - fs::write( - temp_target.path.join("rust-toolchain.toml"), - toolchain_content, - ) - .expect("failed to write rust-toolchain.toml"); - - let result = detect_active_toolchain_in(&temp_target.path); - - assert_eq!(result, Some("nightly-2026-05-28".to_owned())); - } - - // ------------------------------------------------------------------------- - // determine_target_dir tests - // ------------------------------------------------------------------------- - - #[rstest] - fn determine_target_dir_returns_cli_value_when_provided(temp_target: TempTarget) { - let result = determine_target_dir_with(Some(&temp_target.path), || None); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - assert_eq!(result.expect("already checked"), temp_target.path); - } - - #[rstest] - fn determine_target_dir_falls_back_to_default_when_cli_is_none(temp_target: TempTarget) { - let default_path = temp_target.path.clone(); - - let result = determine_target_dir_with(None, || Some(default_path.clone())); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - assert_eq!(result.expect("already checked"), default_path); - } - - #[test] - fn determine_target_dir_returns_error_when_no_default_available() { - let result = determine_target_dir_with(None, || None); - - let err = result.expect_err("expected error when no default"); - assert!( - matches!(err, InstallerError::StagingFailed { .. }), - "expected StagingFailed error, got: {err:?}" - ); - } - - #[rstest] - fn determine_target_dir_prefers_cli_over_default(temp_target: TempTarget) { - let cli_path = temp_target.path.clone(); - let default_path = temp_target.path.join("should_not_be_used"); - - let result = determine_target_dir_with(Some(&cli_path), || Some(default_path)); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - assert_eq!(result.expect("already checked"), cli_path); - } -} +#[path = "list_tests.rs"] +mod tests; diff --git a/installer/src/list_output.rs b/installer/src/list_output.rs index 9f9c3a15..2e244a84 100644 --- a/installer/src/list_output.rs +++ b/installer/src/list_output.rs @@ -5,15 +5,14 @@ use serde::Serialize; -use crate::scanner::{InstalledLints, lints_for_library}; +use crate::scanner::{InstalledLibrary, InstalledLints, lints_for_library}; /// Format installed lints for human-readable output. /// /// # Examples /// /// ``` -/// use whitaker_installer::list_output::format_human; -/// use whitaker_installer::scanner::InstalledLints; +/// use whitaker_installer::{list_output::format_human, scanner::InstalledLints}; /// /// let lints = InstalledLints::default(); /// let output = format_human(&lints, None); @@ -31,34 +30,51 @@ pub fn format_human(lints: &InstalledLints, active_toolchain: Option<&str>) -> S for (toolchain, libraries) in &lints.by_toolchain { output.push('\n'); - - let active_marker = active_toolchain - .filter(|active| *active == toolchain) - .map_or(String::new(), |_| " (active)".to_owned()); - - output.push_str(&format!("Toolchain: {toolchain}{active_marker}\n")); - output.push_str(" Libraries:\n"); - - for library in libraries { - output.push_str(&format!(" {}\n", library.crate_name)); - - let lint_names = lints_for_library(&library.crate_name); - for lint in lint_names { - output.push_str(&format!(" - {lint}\n")); - } - } + output.push_str(&format_toolchain_section( + toolchain, + libraries, + active_toolchain, + )); } output } +/// Render one toolchain section of the human-readable listing. +fn format_toolchain_section( + toolchain: &str, + libraries: &[InstalledLibrary], + active_toolchain: Option<&str>, +) -> String { + let active_marker = if active_toolchain == Some(toolchain) { + " (active)" + } else { + "" + }; + let library_lines: String = libraries.iter().map(format_library_lines).collect(); + format!("Toolchain: {toolchain}{active_marker}\n Libraries:\n{library_lines}") +} + +/// Render the listing lines for one installed library and its lints. +fn format_library_lines(library: &InstalledLibrary) -> String { + let lint_lines: String = + lints_for_library(&library.crate_name) + .iter() + .fold(String::new(), |mut output, lint| { + output.push_str(" - "); + output.push_str(lint); + output.push('\n'); + output + }); + format!(" {}\n{lint_lines}", library.crate_name) +} + /// Format installed lints as JSON. /// /// # Examples /// /// ``` -/// use whitaker_installer::list_output::format_json; -/// use whitaker_installer::scanner::InstalledLints; +/// use whitaker_installer::{list_output::format_json, scanner::InstalledLints}; /// /// let lints = InstalledLints::default(); /// let json = format_json(&lints, None); @@ -133,12 +149,15 @@ pub struct LibraryEntry { #[cfg(test)] mod tests { - use super::*; - use crate::builder::CrateName; - use crate::scanner::InstalledLibrary; - use camino::Utf8PathBuf; + //! Tests for human-readable and JSON list formatting. + use std::collections::BTreeMap; + use camino::Utf8PathBuf; + + use super::*; + use crate::{builder::CrateName, scanner::InstalledLibrary}; + fn sample_lints() -> InstalledLints { let mut by_toolchain = BTreeMap::new(); by_toolchain.insert( diff --git a/installer/src/list_tests.rs b/installer/src/list_tests.rs new file mode 100644 index 00000000..d02ad12f --- /dev/null +++ b/installer/src/list_tests.rs @@ -0,0 +1,291 @@ +//! Tests for the `list` command handler and its path resolution helpers. + +use std::fs; + +use rstest::{fixture, rstest}; +use tempfile::TempDir; + +use super::*; + +// ------------------------------------------------------------------------- +// Fixtures +// ------------------------------------------------------------------------- + +/// A temporary directory converted to a UTF-8 path for test isolation. +struct TempTarget { + _temp: TempDir, + path: Utf8PathBuf, +} + +#[fixture] +fn temp_target() -> std::io::Result { + let temp = TempDir::new()?; + let path = Utf8PathBuf::try_from(temp.path().to_owned()) + .map_err(|_| std::io::Error::other("temporary directory path must be UTF-8"))?; + Ok(TempTarget { _temp: temp, path }) +} + +/// A Write implementation that always fails, for testing error paths. +struct FailingWriter; + +impl std::io::Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other("simulated write failure")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("simulated flush failure")) + } +} + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy)] +enum MockLibraryKind { + Local, + Prebuilt { target: &'static str }, +} + +impl MockLibraryKind { + fn library_dir(&self, target_dir: &Utf8Path, toolchain: &str) -> Utf8PathBuf { + match self { + Self::Local => target_dir.join(toolchain).join("release"), + Self::Prebuilt { target } => target_dir.join(toolchain).join(target).join("lib"), + } + } + + fn content(&self) -> &'static [u8] { + match self { + Self::Local => b"mock library", + Self::Prebuilt { .. } => b"mock prebuilt library", + } + } +} + +fn create_mock_library_internal( + target_dir: &Utf8Path, + toolchain: &str, + kind: MockLibraryKind, +) -> std::io::Result<()> { + use crate::builder::{library_extension, library_prefix}; + + let lib_dir = kind.library_dir(target_dir, toolchain); + fs::create_dir_all(&lib_dir)?; + + let filename = format!( + "{}whitaker_suite@{toolchain}{}", + library_prefix(), + library_extension() + ); + + fs::write(lib_dir.join(filename), kind.content()) +} + +/// Helper to create a mock installed library in the target directory for tests. +fn create_mock_library(target_dir: &Utf8Path, toolchain: &str) -> std::io::Result<()> { + create_mock_library_internal(target_dir, toolchain, MockLibraryKind::Local) +} + +fn create_mock_prebuilt_library( + target_dir: &Utf8Path, + toolchain: &str, + target: &'static str, +) -> std::io::Result<()> { + create_mock_library_internal(target_dir, toolchain, MockLibraryKind::Prebuilt { target }) +} + +// ------------------------------------------------------------------------- +// run_list tests +// ------------------------------------------------------------------------- + +#[rstest] +fn run_list_outputs_human_readable_format( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + let args = ListArgs { + json: false, + target_dir: Some(temp_target.path.clone()), + }; + let mut stdout = Vec::new(); + + let result = run_list_with(&args, &mut stdout, || None); + + assert!(result.is_ok(), "expected success, got: {result:?}"); + let output = String::from_utf8_lossy(&stdout); + assert!(output.contains("No lints installed"), "got: {output}"); +} + +#[rstest] +#[case::json_format(true, &["toolchains", "\"active\""])] +#[case::human_format(false, &["nightly-2026-05-28", "whitaker_suite"])] +fn run_list_with_installed_library_includes_expected_output( + #[from(temp_target)] temp_target_res: std::io::Result, + #[case] json: bool, + #[case] expected: &[&str], +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + create_mock_library(&temp_target.path, "nightly-2026-05-28") + .expect("mock library should be staged"); + let args = ListArgs { + json, + target_dir: Some(temp_target.path.clone()), + }; + let mut stdout = Vec::new(); + + let result = run_list_with(&args, &mut stdout, || Some("nightly-2026-05-28".to_owned())); + + assert!(result.is_ok(), "expected success, got: {result:?}"); + let output = String::from_utf8_lossy(&stdout); + for needle in expected { + assert!( + output.contains(needle), + "expected '{needle}' in output: {output}" + ); + } +} + +#[rstest] +fn run_list_finds_prebuilt_layout_libraries( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + create_mock_prebuilt_library( + &temp_target.path, + "nightly-2026-05-28", + "x86_64-unknown-linux-gnu", + ) + .expect("mock prebuilt library should be staged"); + let args = ListArgs { + json: false, + target_dir: Some(temp_target.path.clone()), + }; + let mut stdout = Vec::new(); + + let result = run_list_with(&args, &mut stdout, || Some("nightly-2026-05-28".to_owned())); + + assert!(result.is_ok(), "expected success, got: {result:?}"); + let output = String::from_utf8_lossy(&stdout); + assert!(output.contains("nightly-2026-05-28"), "got: {output}"); + assert!(output.contains("whitaker_suite"), "got: {output}"); +} + +#[rstest] +fn run_list_returns_write_failed_on_stdout_error( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + let args = ListArgs { + json: false, + target_dir: Some(temp_target.path.clone()), + }; + let mut failing_stdout = FailingWriter; + + let result = run_list_with(&args, &mut failing_stdout, || None); + + let err = result.expect_err("expected error on write failure"); + assert!( + matches!(err, InstallerError::WriteFailed { .. }), + "expected WriteFailed error, got: {err:?}" + ); +} + +// ------------------------------------------------------------------------- +// detect_active_toolchain_in tests +// ------------------------------------------------------------------------- + +#[rstest] +fn detect_active_toolchain_in_returns_none_when_no_toolchain_file( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + let result = detect_active_toolchain_in(&temp_target.path); + assert!( + result.is_none(), + "expected None for directory without rust-toolchain.toml" + ); +} + +#[rstest] +fn detect_active_toolchain_in_returns_channel_when_toolchain_file_exists( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + // Create a rust-toolchain.toml file + let toolchain_content = r#"[toolchain] +channel = "nightly-2026-05-28" +"#; + fs::write( + temp_target.path.join("rust-toolchain.toml"), + toolchain_content, + ) + .expect("failed to write rust-toolchain.toml"); + + let result = detect_active_toolchain_in(&temp_target.path); + + assert_eq!(result, Some("nightly-2026-05-28".to_owned())); +} + +// ------------------------------------------------------------------------- +// determine_target_dir tests +// ------------------------------------------------------------------------- + +#[rstest] +fn determine_target_dir_returns_cli_value_when_provided( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + let result = determine_target_dir_with(Some(&temp_target.path), || None); + + assert!(result.is_ok(), "expected success, got: {result:?}"); + assert_eq!(result.expect("already checked"), temp_target.path); +} + +#[rstest] +fn determine_target_dir_falls_back_to_default_when_cli_is_none( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + let default_path = temp_target.path.clone(); + + let result = determine_target_dir_with(None, || Some(default_path.clone())); + + assert!(result.is_ok(), "expected success, got: {result:?}"); + assert_eq!(result.expect("already checked"), default_path); +} + +#[test] +fn determine_target_dir_returns_error_when_no_default_available() { + let result = determine_target_dir_with(None, || None); + + let err = result.expect_err("expected error when no default"); + assert!( + matches!(err, InstallerError::StagingFailed { .. }), + "expected StagingFailed error, got: {err:?}" + ); +} + +#[rstest] +fn determine_target_dir_prefers_cli_over_default( + #[from(temp_target)] temp_target_res: std::io::Result, +) { + let temp_target = temp_target_res.expect("temporary target directory should be created"); + + let cli_path = temp_target.path.clone(); + let default_path = temp_target.path.join("should_not_be_used"); + + let result = determine_target_dir_with(Some(&cli_path), || Some(default_path)); + + assert!(result.is_ok(), "expected success, got: {result:?}"); + assert_eq!(result.expect("already checked"), cli_path); +} diff --git a/installer/src/main.rs b/installer/src/main.rs index 1e9c2665..c9688ed6 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -7,31 +7,36 @@ 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, + MetricsWriteContext, + PrebuiltInstallationContext, + detect_host_target, + ensure_dylint_tools_with_executor, + try_prebuilt_installation, + write_install_metrics, }; -use camino::{Utf8Path, Utf8PathBuf}; -use clap::Parser; -use std::io::Write; -use std::time::Instant; -use whitaker_installer::cli::{Cli, Command, InstallArgs}; -use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::SystemCommandExecutor; -use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; -use whitaker_installer::error::{InstallerError, Result}; -use whitaker_installer::install_metrics::InstallMode; -use whitaker_installer::list::{determine_target_dir, run_list}; -use whitaker_installer::output::{DryRunInfo, ShellSnippet, write_stderr_line}; -use whitaker_installer::pipeline::{PipelineContext, perform_build, stage_libraries}; -use whitaker_installer::prebuilt_path::prebuilt_library_dir; -use whitaker_installer::resolution::{ - CrateResolutionOptions, resolve_crates, validate_crate_names, -}; -use whitaker_installer::toolchain::Toolchain; -use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; fn main() { let cli = Cli::parse(); @@ -54,8 +59,8 @@ fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> } /// Returns the set of additional rustup components requested by the CLI flags. -fn resolve_additional_components(args: &InstallArgs) -> &'static [&'static str] { - if args.cranelift { +const fn resolve_additional_components(args: &InstallArgs) -> &'static [&'static str] { + if args.execution.cranelift { &["rustc-codegen-cranelift"] } else { &[] @@ -76,7 +81,7 @@ fn try_fast_path_installation( requested_crates: context.requested_crates, toolchain_channel: context.toolchain.channel(), }; - if let Some(staging_path) = try_prebuilt_installation(&prebuilt_context, stderr)? { + 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( @@ -102,12 +107,12 @@ 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.dry_run { + 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_deps { + if !args.skip.skip_deps { ensure_dylint_tools(args.quiet, stderr)?; } // Step 2: Ensure workspace is available (clone if needed) @@ -148,7 +153,7 @@ fn run_install(args: &InstallArgs, stderr: &mut dyn Write) -> Result<()> { target_dir: &target_dir, jobs: args.jobs, verbosity: args.verbosity, - experimental: args.experimental, + experimental: args.lint_selection.experimental, quiet: args.quiet, }; // Step 4: Build and stage @@ -180,9 +185,11 @@ fn run_dry(args: &InstallArgs, dirs: &dyn BaseDirs, stderr: &mut dyn Write) -> R target_dir: &target_dir, verbosity: args.verbosity, quiet: args.quiet, - skip_deps: args.skip_deps, - skip_wrapper: args.skip_wrapper, - no_update: args.no_update, + skips: DryRunSkips { + deps: args.skip.skip_deps, + wrapper: args.skip.skip_wrapper, + update: args.skip.no_update, + }, jobs: args.jobs, crates: &requested_crates, }; @@ -219,21 +226,24 @@ fn ensure_whitaker_workspace( stderr: &mut dyn Write, ) -> Result { use whitaker_installer::workspace::{ - WorkspaceAction, clone_directory, decide_workspace_action, ensure_workspace, + WorkspaceAction, + clone_directory, + decide_workspace_action, + ensure_workspace, }; if !args.quiet && let Some(clone_dir) = clone_directory(dirs) { - let cwd = std::env::current_dir() + let utf8_cwd = std::env::current_dir() .ok() .and_then(|p| Utf8PathBuf::try_from(p).ok()); - let Some(cwd) = cwd else { - return ensure_workspace(dirs, !args.no_update); + let Some(cwd) = utf8_cwd else { + return ensure_workspace(dirs, !args.skip.no_update); }; - match decide_workspace_action(&cwd, &clone_dir, !args.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}...")); } @@ -244,7 +254,7 @@ fn ensure_whitaker_workspace( } } - ensure_workspace(dirs, !args.no_update) + ensure_workspace(dirs, !args.skip.no_update) } /// Detects or overrides the toolchain, then verifies it is installed. @@ -252,10 +262,10 @@ fn resolve_toolchain( workspace_root: &Utf8Path, override_channel: Option<&str>, ) -> Result { - match override_channel { - Some(channel) => Ok(Toolchain::with_override(workspace_root, channel)), - None => Toolchain::detect(workspace_root), - } + override_channel.map_or_else( + || Toolchain::detect(workspace_root), + |channel| Ok(Toolchain::with_override(workspace_root, channel)), + ) } fn ensure_toolchain_installed( @@ -282,7 +292,7 @@ fn finish_install( staging_path: &Utf8Path, stderr: &mut dyn Write, ) -> Result<()> { - if args.skip_wrapper { + if args.skip.skip_wrapper { write_stderr_line(stderr, ""); write_stderr_line(stderr, ShellSnippet::new(staging_path).display_text()); } else { @@ -334,8 +344,8 @@ fn resolve_requested_crates(args: &InstallArgs) -> Result> { .collect(); let options = CrateResolutionOptions { - individual_lints: args.individual_lints, - experimental: args.experimental, + individual_lints: args.lint_selection.individual_lints, + experimental: args.lint_selection.experimental, }; if !lint_crates.is_empty() { validate_crate_names(&lint_crates, &options)?; diff --git a/installer/src/output.rs b/installer/src/output.rs index 53bc593f..c14172ff 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -4,10 +4,12 @@ //! that users can add to their shell profile to enable Dylint library discovery, //! as well as dry-run information formatting. -use crate::crate_name::CrateName; -use camino::Utf8Path; use std::io::Write; +use camino::Utf8Path; + +use crate::crate_name::CrateName; + /// Write a line to stderr, ignoring write failures. /// /// This is a best-effort logging helper that silently ignores write errors. @@ -39,9 +41,7 @@ impl ShellSnippet { /// use camino::Utf8PathBuf; /// use whitaker_installer::output::ShellSnippet; /// - /// let path = Utf8PathBuf::from( - /// "/home/user/.local/share/dylint/lib/nightly-2025-01-15/release" - /// ); + /// let path = Utf8PathBuf::from("/home/user/.local/share/dylint/lib/nightly-2025-01-15/release"); /// let snippet = ShellSnippet::new(&path); /// /// assert!(snippet.bash.contains("DYLINT_LIBRARY_PATH")); @@ -86,8 +86,7 @@ pub fn success_message(count: usize, target_dir: &Utf8Path) -> String { /// /// ``` /// use camino::Utf8PathBuf; -/// use whitaker_installer::crate_name::CrateName; -/// use whitaker_installer::output::DryRunInfo; +/// use whitaker_installer::{crate_name::CrateName, output::DryRunInfo}; /// /// let workspace = Utf8PathBuf::from("/home/user/whitaker"); /// let target = Utf8PathBuf::from("/home/user/.local/share/dylint/lib"); @@ -99,9 +98,11 @@ pub fn success_message(count: usize, target_dir: &Utf8Path) -> String { /// target_dir: &target, /// verbosity: 0, /// quiet: false, -/// skip_deps: false, -/// skip_wrapper: false, -/// no_update: false, +/// skips: whitaker_installer::output::DryRunSkips { +/// deps: false, +/// wrapper: false, +/// update: false, +/// }, /// jobs: None, /// crates: &crates, /// }; @@ -122,18 +123,25 @@ pub struct DryRunInfo<'a> { pub verbosity: u8, /// Whether quiet mode is enabled. pub quiet: bool, - /// Whether dependency installation is skipped. - pub skip_deps: bool, - /// Whether wrapper script generation is skipped. - pub skip_wrapper: bool, - /// Whether repository updates are disabled. - pub no_update: bool, + /// Which installation steps are skipped. + pub skips: DryRunSkips, /// Optional parallel job count. pub jobs: Option, /// Crates to be built. pub crates: &'a [CrateName], } +/// Step-skipping flags reported in dry-run output. +#[derive(Debug, Clone, Copy, Default)] +pub struct DryRunSkips { + /// Whether dependency installation is skipped. + pub deps: bool, + /// Whether wrapper script generation is skipped. + pub wrapper: bool, + /// Whether repository updates are disabled. + pub update: bool, +} + impl DryRunInfo<'_> { /// Format the dry-run information for display. #[must_use] @@ -146,9 +154,9 @@ impl DryRunInfo<'_> { format!("Target directory: {}", self.target_dir), format!("Verbosity level: {}", self.verbosity), format!("Quiet: {}", self.quiet), - format!("Skip deps: {}", self.skip_deps), - format!("Skip wrapper: {}", self.skip_wrapper), - format!("No update: {}", self.no_update), + format!("Skip deps: {}", self.skips.deps), + format!("Skip wrapper: {}", self.skips.wrapper), + format!("No update: {}", self.skips.update), ]; if let Some(jobs) = self.jobs { @@ -167,10 +175,13 @@ impl DryRunInfo<'_> { #[cfg(test)] mod tests { - use super::*; + //! Tests for installer output rendering. + use camino::Utf8PathBuf; use rstest::{fixture, rstest}; + use super::*; + /// Shared fixture providing a test library path. #[fixture] fn test_path() -> Utf8PathBuf { @@ -178,10 +189,9 @@ mod tests { } /// Shared fixture providing a shell snippet for the test path. + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] - fn test_snippet(test_path: Utf8PathBuf) -> ShellSnippet { - ShellSnippet::new(&test_path) - } + fn test_snippet(test_path: Utf8PathBuf) -> ShellSnippet { ShellSnippet::new(&test_path) } #[rstest] fn snippet_contains_path(test_snippet: ShellSnippet, test_path: Utf8PathBuf) { @@ -214,7 +224,7 @@ mod tests { #[rstest] #[case::singular(1, "1 lint library")] #[case::plural(5, "5 lint libraries")] - fn success_message_pluralises_correctly(#[case] count: usize, #[case] expected: &str) { + fn success_message_pluralizes_correctly(#[case] count: usize, #[case] expected: &str) { let path = Utf8PathBuf::from("/tmp"); let msg = success_message(count, &path); assert!(msg.contains(expected)); diff --git a/installer/src/pipeline.rs b/installer/src/pipeline.rs index 148ee8ca..997b1f9e 100644 --- a/installer/src/pipeline.rs +++ b/installer/src/pipeline.rs @@ -4,16 +4,20 @@ //! staging the resulting libraries. It coordinates between the builder, stager, //! and output modules to provide a complete build pipeline. -use crate::builder::{BuildConfig, BuildResult, Builder, CrateBuilder}; -use crate::crate_name::CrateName; -use crate::error::Result; -use crate::output::{success_message, write_stderr_line}; -use crate::scanner::lints_for_library_with_experimental; -use crate::stager::Stager; -use crate::toolchain::Toolchain; -use camino::{Utf8Path, Utf8PathBuf}; use std::io::Write; +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::{ + builder::{BuildConfig, BuildResult, Builder, CrateBuilder}, + crate_name::CrateName, + error::Result, + output::{success_message, write_stderr_line}, + scanner::lints_for_library_with_experimental, + stager::Stager, + toolchain::Toolchain, +}; + /// Creates a [`BuildConfig`] from the pipeline context. /// /// This extracts the build configuration parameters from the pipeline context @@ -24,9 +28,11 @@ use std::io::Write; /// # Example /// /// ``` -/// use whitaker_installer::pipeline::{build_config_from_context, PipelineContext}; -/// use whitaker_installer::toolchain::Toolchain; /// use camino::{Utf8Path, Utf8PathBuf}; +/// use whitaker_installer::{ +/// pipeline::{PipelineContext, build_config_from_context}, +/// toolchain::Toolchain, +/// }; /// /// let workspace = Utf8PathBuf::from("/workspace"); /// let target = Utf8PathBuf::from("/staging"); @@ -69,9 +75,8 @@ pub fn build_config_from_context(context: &PipelineContext<'_>) -> BuildConfig { /// # Example /// /// ``` -/// use whitaker_installer::pipeline::PipelineContext; -/// use whitaker_installer::toolchain::Toolchain; /// use camino::Utf8PathBuf; +/// use whitaker_installer::{pipeline::PipelineContext, toolchain::Toolchain}; /// /// let workspace = Utf8PathBuf::from("/workspace"); /// let target = Utf8PathBuf::from("/staging"); diff --git a/installer/src/pipeline_staging_tests.rs b/installer/src/pipeline_staging_tests.rs index 1a468ff3..87d9c488 100644 --- a/installer/src/pipeline_staging_tests.rs +++ b/installer/src/pipeline_staging_tests.rs @@ -1,13 +1,16 @@ //! Staging-focused tests for pipeline orchestration. -use crate::builder::BuildResult; -use crate::crate_name::CrateName; -use crate::pipeline::stage_libraries; -use crate::toolchain::Toolchain; use camino::{Utf8Path, Utf8PathBuf}; use rstest::{fixture, rstest}; use tempfile::TempDir; +use crate::{ + builder::BuildResult, + crate_name::CrateName, + pipeline::stage_libraries, + toolchain::Toolchain, +}; + /// Fixture providing a temporary directory for staging tests. /// /// Contains its own fields for real file system operations during staging @@ -25,15 +28,15 @@ struct StagingTestContext { } impl StagingTestContext { - fn new() -> Self { + fn new() -> std::io::Result { use std::fs; - let temp_dir = TempDir::new().expect("failed to create temp dir"); - let target_dir = - Utf8PathBuf::try_from(temp_dir.path().to_owned()).expect("non-UTF8 temp path"); + let temp_dir = TempDir::new()?; + let target_dir = Utf8PathBuf::try_from(temp_dir.path().to_owned()) + .map_err(|_| std::io::Error::other("temporary directory path must be UTF-8"))?; let workspace_root = target_dir.join("workspace"); - fs::create_dir_all(&workspace_root).expect("failed to create workspace root"); - Self { + fs::create_dir_all(&workspace_root)?; + Ok(Self { _temp_dir: temp_dir, target_dir, toolchain: Toolchain::with_override(&workspace_root, "nightly-2026-05-28"), @@ -42,12 +45,10 @@ impl StagingTestContext { verbosity: 0, experimental: false, quiet: false, - } + }) } - fn target_dir(&self) -> &Utf8Path { - &self.target_dir - } + fn target_dir(&self) -> &Utf8Path { &self.target_dir } fn with_quiet(mut self, quiet: bool) -> Self { self.quiet = quiet; @@ -72,60 +73,70 @@ impl StagingTestContext { } } -fn create_mock_library(target_dir: &Utf8Path, crate_name: &str) -> BuildResult { - use crate::builder::{library_extension, library_prefix}; +fn create_mock_library(target_dir: &Utf8Path, crate_name: &str) -> std::io::Result { use std::fs; + use crate::builder::{library_extension, library_prefix}; + let source_dir = target_dir.join("source"); - fs::create_dir_all(&source_dir).expect("failed to create source directory"); + fs::create_dir_all(&source_dir)?; let filename = format!("{}{}{}", library_prefix(), crate_name, library_extension()); let library_path = source_dir.join(&filename); - fs::write(&library_path, b"mock library content").expect("failed to write mock library"); + fs::write(&library_path, b"mock library content")?; - BuildResult { + Ok(BuildResult { crate_name: CrateName::from(crate_name), library_path, - } + }) } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn staging_ctx() -> StagingTestContext { - StagingTestContext::new() -} +fn staging_ctx() -> std::io::Result { StagingTestContext::new() } -fn assert_bumpy_road_lint_in_staging_output(experimental: bool) { - let staging_ctx = StagingTestContext::new().with_experimental(experimental); - let context = staging_ctx.pipeline_context(); - let build_results = vec![create_mock_library( - staging_ctx.target_dir(), - "whitaker_suite", - )]; - let mut stderr = Vec::new(); +/// Asserts that staging output lists the stable `bumpy_road_function` lint. +/// +/// Expressed as a macro so the fallible setup stays inside the calling test +/// body and failures report the caller's line number. +macro_rules! assert_bumpy_road_lint_in_staging_output { + ($experimental:expr) => {{ + let staging_ctx = StagingTestContext::new() + .expect("staging context should be created") + .with_experimental($experimental); + let context = staging_ctx.pipeline_context(); + let build_results = vec![ + create_mock_library(staging_ctx.target_dir(), "whitaker_suite") + .expect("mock library should be staged"), + ]; + let mut stderr = Vec::new(); - stage_libraries(&context, &build_results, &mut stderr).expect("staging should succeed"); + stage_libraries(&context, &build_results, &mut stderr).expect("staging should succeed"); - let output = String::from_utf8_lossy(&stderr); - assert!( - output.contains("bumpy_road_function"), - "expected stable bumpy_road_function lint in output, got: {output}" - ); + let output = String::from_utf8_lossy(&stderr); + assert!( + output.contains("bumpy_road_function"), + "expected stable bumpy_road_function lint in output, got: {output}" + ); + }}; } #[rstest] -fn stage_libraries_returns_correct_staging_path(staging_ctx: StagingTestContext) { - let staging_ctx = staging_ctx.with_quiet(true); - let context = staging_ctx.pipeline_context(); +fn stage_libraries_returns_correct_staging_path( + #[from(staging_ctx)] staging_ctx_res: std::io::Result, +) { + let staging_ctx = staging_ctx_res.expect("staging context should be created"); + + let quiet_ctx = staging_ctx.with_quiet(true); + let context = quiet_ctx.pipeline_context(); let build_results = vec![]; let mut stderr = Vec::new(); - let result = stage_libraries(&context, &build_results, &mut stderr); - - assert!(result.is_ok(), "expected success, got: {result:?}"); - let staging_path = result.expect("already checked"); + let staging_path = + stage_libraries(&context, &build_results, &mut stderr).expect("staging should succeed"); // Keep this contract explicit so staged artefacts remain discoverable by // toolchain and profile when scanner logic depends on path layout. - let expected_path = staging_ctx + let expected_path = quiet_ctx .target_dir() .join("nightly-2026-05-28") .join("release"); @@ -138,9 +149,13 @@ fn stage_libraries_returns_correct_staging_path(staging_ctx: StagingTestContext) #[rstest] #[case::quiet_mode(true)] #[case::verbose_mode(false)] -fn stage_libraries_respects_quiet_flag(staging_ctx: StagingTestContext, #[case] quiet: bool) { - let staging_ctx = staging_ctx.with_quiet(quiet); - let context = staging_ctx.pipeline_context(); +fn stage_libraries_respects_quiet_flag( + #[from(staging_ctx)] staging_ctx_res: std::io::Result, + #[case] quiet: bool, +) { + let staging_ctx = staging_ctx_res.expect("staging context should be created"); + let quiet_ctx = staging_ctx.with_quiet(quiet); + let context = quiet_ctx.pipeline_context(); let build_results = vec![]; let mut stderr = Vec::new(); @@ -152,22 +167,25 @@ fn stage_libraries_respects_quiet_flag(staging_ctx: StagingTestContext, #[case] } else { assert!( output.contains("Staging libraries to"), - "expected progress message, got: {}", - output + "expected progress message, got: {output}" ); } } #[rstest] -fn stage_libraries_stages_build_results(staging_ctx: StagingTestContext) { +fn stage_libraries_stages_build_results( + #[from(staging_ctx)] staging_ctx_res: std::io::Result, +) { use crate::builder::{library_extension, library_prefix}; - let staging_ctx = staging_ctx.with_quiet(true); - let context = staging_ctx.pipeline_context(); - let build_results = vec![create_mock_library( - staging_ctx.target_dir(), - "whitaker_suite", - )]; + let staging_ctx = staging_ctx_res.expect("staging context should be created"); + + let quiet_ctx = staging_ctx.with_quiet(true); + let context = quiet_ctx.pipeline_context(); + let build_results = vec![ + create_mock_library(quiet_ctx.target_dir(), "whitaker_suite") + .expect("mock library should be staged"), + ]; let mut stderr = Vec::new(); let staging_path = @@ -188,7 +206,11 @@ fn stage_libraries_stages_build_results(staging_ctx: StagingTestContext) { } #[rstest] -fn stage_libraries_logs_installed_lints_when_not_quiet(staging_ctx: StagingTestContext) { +fn stage_libraries_logs_installed_lints_when_not_quiet( + #[from(staging_ctx)] staging_ctx_res: std::io::Result, +) { + let staging_ctx = staging_ctx_res.expect("staging context should be created"); + let context = staging_ctx.pipeline_context(); let build_results = vec![]; let mut stderr = Vec::new(); @@ -206,5 +228,5 @@ fn stage_libraries_logs_installed_lints_when_not_quiet(staging_ctx: StagingTestC #[case::without_experimental(false)] #[case::with_experimental(true)] fn stage_libraries_lists_bumpy_road_lint(#[case] experimental: bool) { - assert_bumpy_road_lint_in_staging_output(experimental); + assert_bumpy_road_lint_in_staging_output!(experimental); } diff --git a/installer/src/pipeline_tests.rs b/installer/src/pipeline_tests.rs index bbca11d0..75f1a100 100644 --- a/installer/src/pipeline_tests.rs +++ b/installer/src/pipeline_tests.rs @@ -5,13 +5,16 @@ //! correctly invokes the builder with the provided crates, and that //! `stage_libraries` correctly stages build results. -use super::{PipelineContext, build_config_from_context, perform_build_with}; -use crate::builder::{BuildResult, MockCrateBuilder}; -use crate::crate_name::CrateName; -use crate::toolchain::Toolchain; use camino::{Utf8Path, Utf8PathBuf}; use rstest::{fixture, rstest}; +use super::{PipelineContext, build_config_from_context, perform_build_with}; +use crate::{ + builder::{BuildResult, MockCrateBuilder}, + crate_name::CrateName, + toolchain::Toolchain, +}; + // ------------------------------------------------------------------------- // Common trait for test context providers // ------------------------------------------------------------------------- @@ -86,11 +89,11 @@ impl PipelineContextProvider for TestContext { } } -/// Returns a default TestContext with owned paths and default settings for pipeline unit tests. +/// Returns a default `TestContext` with owned paths and default settings for +/// pipeline unit tests. +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn test_ctx() -> TestContext { - TestContext::new() -} +fn test_ctx() -> TestContext { TestContext::new() } // ------------------------------------------------------------------------- // build_config_from_context tests @@ -161,7 +164,10 @@ fn perform_build_with_calls_build_all_with_provided_crates(test_ctx: TestContext let mut mock = MockCrateBuilder::new(); mock.expect_build_all() .withf(|c| { - c.len() == 2 && c[0].as_str() == "whitaker_suite" && c[1].as_str() == "module_max_lines" + let [suite, lint] = c else { + return false; + }; + suite.as_str() == "whitaker_suite" && lint.as_str() == "module_max_lines" }) .times(1) .returning(|_| Ok(vec![])); @@ -187,7 +193,8 @@ fn perform_build_with_returns_builder_results(test_ctx: TestContext) { let results = perform_build_with(&ctx.pipeline_context(), &crates, &mock, &mut stderr) .expect("build should succeed"); assert_eq!(results.len(), 1); - assert_eq!(results[0].crate_name.as_str(), "whitaker_suite"); + let built = results.first().expect("build result should be recorded"); + assert_eq!(built.crate_name.as_str(), "whitaker_suite"); } #[rstest] diff --git a/installer/src/prebuilt.rs b/installer/src/prebuilt.rs index 3e4fab24..fc29777c 100644 --- a/installer/src/prebuilt.rs +++ b/installer/src/prebuilt.rs @@ -7,20 +7,24 @@ //! directory. On any failure the caller receives [`PrebuiltResult::Fallback`] //! and should proceed with local compilation. +use std::{io::Write, path::Path}; + use camino::{Utf8Path, Utf8PathBuf}; -use std::io::Write; -use std::path::Path; - -use crate::artefact::download::{ArtefactDownloader, DownloadError, HttpDownloader}; -use crate::artefact::extraction::{ArtefactExtractor, ZstdExtractor}; -use crate::artefact::manifest::Manifest; -use crate::artefact::manifest_parser::{ManifestParseError, parse_manifest}; -use crate::artefact::naming::ArtefactName; -use crate::artefact::packaging::compute_sha256; -use crate::artefact::packaging_error::PackagingError; -use crate::artefact::verification::VerificationPolicy; -use crate::builder::{library_extension, library_prefix}; -use crate::output::write_stderr_line; + +use crate::{ + artefact::{ + download::{ArtefactDownloader, DownloadError, HttpDownloader}, + extraction::{ArtefactExtractor, ZstdExtractor}, + manifest::Manifest, + manifest_parser::{ManifestParseError, parse_manifest}, + naming::ArtefactName, + packaging::compute_sha256, + packaging_error::PackagingError, + verification::VerificationPolicy, + }, + builder::{library_extension, library_prefix}, + output::write_stderr_line, +}; /// The outcome of a prebuilt download attempt. /// diff --git a/installer/src/prebuilt_path.rs b/installer/src/prebuilt_path.rs index 31a88e6b..bcf10bf0 100644 --- a/installer/src/prebuilt_path.rs +++ b/installer/src/prebuilt_path.rs @@ -7,8 +7,10 @@ use camino::Utf8PathBuf; -use crate::dirs::BaseDirs; -use crate::error::{InstallerError, Result}; +use crate::{ + dirs::BaseDirs, + error::{InstallerError, Result}, +}; /// Build the canonical prebuilt library destination directory. /// @@ -24,13 +26,13 @@ pub fn prebuilt_library_dir( toolchain: &str, target: &str, ) -> Result { - let base_dir = dirs - .whitaker_data_dir() + let data_dir = dirs + .whitaker_data() .ok_or_else(|| InstallerError::StagingFailed { reason: "could not determine Whitaker data directory".to_owned(), })?; let base_dir = - Utf8PathBuf::from_path_buf(base_dir).map_err(|path| InstallerError::StagingFailed { + Utf8PathBuf::from_path_buf(data_dir).map_err(|path| InstallerError::StagingFailed { reason: format!( "Whitaker data directory is not valid UTF-8: {}", path.display() @@ -45,15 +47,19 @@ pub fn prebuilt_library_dir( #[cfg(test)] mod tests { + //! Tests for prebuilt artefact path resolution. + + use std::path::PathBuf; + + use rstest::rstest; + use super::*; use crate::dirs::MockBaseDirs; - use rstest::rstest; - use std::path::PathBuf; #[test] fn prebuilt_library_dir_builds_expected_path() { let mut dirs = MockBaseDirs::new(); - dirs.expect_whitaker_data_dir() + dirs.expect_whitaker_data() .returning(|| Some(PathBuf::from("/home/test/.local/share/whitaker"))); let result = prebuilt_library_dir(&dirs, "nightly-2026-05-28", "x86_64-unknown-linux-gnu") @@ -74,7 +80,7 @@ mod tests { #[case] expected_reason: &str, ) { let mut dirs = MockBaseDirs::new(); - dirs.expect_whitaker_data_dir() + dirs.expect_whitaker_data() .return_once(move || data_dir.clone()); let err = prebuilt_library_dir(&dirs, "nightly-2026-05-28", "x86_64-unknown-linux-gnu") @@ -88,11 +94,10 @@ mod tests { #[cfg(unix)] #[test] fn prebuilt_library_dir_rejects_non_utf8_data_dir() { - use std::ffi::OsString; - use std::os::unix::ffi::OsStringExt; + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; let mut dirs = MockBaseDirs::new(); - dirs.expect_whitaker_data_dir().return_once(|| { + dirs.expect_whitaker_data().return_once(|| { Some(PathBuf::from(OsString::from_vec(vec![ b'/', b't', b'm', b'p', b'/', 0xff, ]))) diff --git a/installer/src/prebuilt_tests.rs b/installer/src/prebuilt_tests.rs index 7dd75a04..11031fa0 100644 --- a/installer/src/prebuilt_tests.rs +++ b/installer/src/prebuilt_tests.rs @@ -1,11 +1,13 @@ //! Unit tests for prebuilt artefact orchestration. -use super::*; -use crate::artefact::download::MockArtefactDownloader; -use crate::artefact::extraction::MockArtefactExtractor; -use crate::test_utils::{prebuilt_manifest_json, sha256_hex}; use rstest::rstest; +use super::*; +use crate::{ + artefact::{download::MockArtefactDownloader, extraction::MockArtefactExtractor}, + test_utils::{prebuilt_manifest_json, sha256_hex}, +}; + const FAKE_ARCHIVE: &[u8] = b"fake archive content"; const TARGET: &str = "x86_64-unknown-linux-gnu"; const TOOLCHAIN: &str = "nightly-2026-05-28"; @@ -19,43 +21,51 @@ fn base_config(destination_dir: &Utf8Path) -> PrebuiltConfig<'_> { } } -fn destination_dir() -> (tempfile::TempDir, Utf8PathBuf) { - let temp = tempfile::tempdir().expect("temp dir"); - let root = Utf8PathBuf::try_from(temp.path().to_path_buf()).expect("UTF-8 path"); +fn destination_dir() -> std::io::Result<(tempfile::TempDir, Utf8PathBuf)> { + let temp = tempfile::tempdir()?; + let root = Utf8PathBuf::try_from(temp.path().to_path_buf()) + .map_err(|_| std::io::Error::other("temporary directory path must be UTF-8"))?; let path = root.join("lints").join(TOOLCHAIN).join(TARGET).join("lib"); - (temp, path) + Ok((temp, path)) } -/// Run a fallback scenario: set up mocks via `setup_mocks`, call the +/// Run a fallback scenario: set up mocks via `$setup_mocks`, call the /// orchestrator, and assert `Fallback` whose reason contains -/// `expected_reason_substring`. -fn test_fallback_scenario( - setup_mocks: impl FnOnce(&mut MockArtefactDownloader, &mut MockArtefactExtractor), - expected_reason_substring: &str, -) { - let (_temp, destination_dir) = destination_dir(); - let config = base_config(&destination_dir); - - let mut downloader = MockArtefactDownloader::new(); - let mut extractor = MockArtefactExtractor::new(); - setup_mocks(&mut downloader, &mut extractor); - - let mut stderr = Vec::new(); - let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); - match result { - PrebuiltResult::Fallback { reason } => { - assert!( - reason.contains(expected_reason_substring), - "reason: {reason}" - ); +/// `$expected_reason_substring`. +/// +/// Expressed as a macro so the fallible setup stays inside the calling test +/// body and failures report the caller's line number. +macro_rules! test_fallback_scenario { + ($setup_mocks:expr, $expected_reason_substring:expr $(,)?) => {{ + let setup_mocks: &dyn Fn(&mut MockArtefactDownloader, &mut MockArtefactExtractor) = + &$setup_mocks; + let expected_reason_substring: &str = $expected_reason_substring; + let (_temp, destination_dir) = + destination_dir().expect("destination directory should be created"); + let config = base_config(&destination_dir); + + let mut downloader = MockArtefactDownloader::new(); + let mut extractor = MockArtefactExtractor::new(); + setup_mocks(&mut downloader, &mut extractor); + + let mut stderr = Vec::new(); + let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); + match result { + PrebuiltResult::Fallback { reason } => { + assert!( + reason.contains(expected_reason_substring), + "reason: {reason}" + ); + } + other @ PrebuiltResult::Success { .. } => panic!("expected Fallback, got {other:?}"), } - other => panic!("expected Fallback, got {other:?}"), - } + }}; } #[test] fn happy_path_returns_success() { - let (_temp, destination_dir) = destination_dir(); + let (_temp, destination_dir) = + destination_dir().expect("destination directory should be created"); let config = base_config(&destination_dir); let fake_sha = sha256_hex(FAKE_ARCHIVE); let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha); @@ -79,7 +89,7 @@ fn happy_path_returns_success() { let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); match result { PrebuiltResult::Success { staging_path } => assert_eq!(staging_path, destination_dir), - other => panic!("expected Success, got {other:?}"), + other @ PrebuiltResult::Fallback { .. } => panic!("expected Success, got {other:?}"), } } @@ -90,7 +100,7 @@ fn manifest_download_errors_return_fallback( #[case] make_error: fn() -> DownloadError, #[case] expected_substring: &str, ) { - test_fallback_scenario( + test_fallback_scenario!( |downloader, _extractor| { downloader .expect_download_manifest() @@ -116,22 +126,12 @@ fn make_not_found_error() -> DownloadError { #[test] fn manifest_validation_errors_return_fallback() { let test_cases = vec![ - ( - "toolchain mismatch", - "nightly-2025-01-01", - TARGET, - "toolchain mismatch", - ), - ( - "target mismatch", - TOOLCHAIN, - "aarch64-apple-darwin", - "target mismatch", - ), + ("nightly-2025-01-01", TARGET, "toolchain mismatch"), + (TOOLCHAIN, "aarch64-apple-darwin", "target mismatch"), ]; - for (case_name, toolchain, target, expected_reason_substring) in test_cases { - test_fallback_scenario( + for (toolchain, target, expected_reason_substring) in test_cases { + test_fallback_scenario!( |downloader, _extractor| { let manifest_json = prebuilt_manifest_json(toolchain, target, "a".repeat(64)); downloader @@ -140,13 +140,12 @@ fn manifest_validation_errors_return_fallback() { }, expected_reason_substring, ); - eprintln!("manifest validation scenario passed: {case_name}"); } } #[test] fn checksum_mismatch_returns_fallback() { - test_fallback_scenario( + test_fallback_scenario!( |downloader, _extractor| { // Manifest claims SHA = "aaa...a" but the file will hash differently. let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, "a".repeat(64)); @@ -165,7 +164,7 @@ fn checksum_mismatch_returns_fallback() { #[test] fn extraction_failure_returns_fallback() { - test_fallback_scenario( + test_fallback_scenario!( |downloader, extractor| { let fake_sha = sha256_hex(FAKE_ARCHIVE); let manifest_json = prebuilt_manifest_json(TOOLCHAIN, TARGET, &fake_sha); @@ -213,6 +212,6 @@ fn destination_creation_failure_returns_fallback() { reason.contains("download failed"), "unexpected fallback reason: {reason}" ), - other => panic!("expected Fallback, got {other:?}"), + other @ PrebuiltResult::Success { .. } => panic!("expected Fallback, got {other:?}"), } } diff --git a/installer/src/resolution.rs b/installer/src/resolution.rs index 37144385..0116cff1 100644 --- a/installer/src/resolution.rs +++ b/installer/src/resolution.rs @@ -3,10 +3,13 @@ //! This module determines which lint crates to build based on CLI options and //! validates that requested crate names are known. -use crate::crate_name::CrateName; -use crate::error::{InstallerError, Result}; use log::debug; +use crate::{ + crate_name::CrateName, + error::{InstallerError, Result}, +}; + /// Static list of lint crates available for building. /// /// This list includes all individual lint crates. The aggregated suite is @@ -48,13 +51,12 @@ pub struct CrateResolutionOptions { /// cherry-pick particular lints. /// /// The `experimental` flag has different effects depending on the mode: -/// - In `individual_lints` mode, experimental lint crates from -/// `EXPERIMENTAL_LINT_CRATES` are included in the returned crate list. -/// - In suite mode (default), the `experimental` flag is used by `BuildConfig` -/// to enable experimental features when building the suite crate. -/// - When `specific_lints` are provided, the returned list is exactly the -/// requested crate list after validation. Experimental crates still require -/// the `experimental` flag during validation. +/// - In `individual_lints` mode, experimental lint crates from `EXPERIMENTAL_LINT_CRATES` are +/// included in the returned crate list. +/// - In suite mode (default), the `experimental` flag is used by `BuildConfig` to enable +/// experimental features when building the suite crate. +/// - When `specific_lints` are provided, the returned list is exactly the requested crate list +/// after validation. Experimental crates still require the `experimental` flag during validation. /// /// Note: This function assumes that `specific_lints` have been validated via /// `validate_crate_names()` prior to calling. Callers must validate inputs @@ -68,8 +70,7 @@ pub fn resolve_crates( // Assumes names have been validated via validate_crate_names(). debug!( target: "whitaker_installer::resolution", - "using explicit lint crate selection: {:?}", - specific_lints + "using explicit lint crate selection: {specific_lints:?}" ); return specific_lints.to_vec(); } @@ -147,52 +148,71 @@ pub fn validate_crate_names(names: &[CrateName], options: &CrateResolutionOption #[cfg(test)] mod tests { - use super::*; + //! Tests for release resolution. + use rstest::rstest; - /// Test configuration for resolve_crates variants. + use super::*; + + /// Test configuration for `resolve_crates` variants. struct ResolveCratesCase { - individual_lints: bool, - experimental: bool, - expect_lint: bool, - expect_suite: bool, - expect_bumpy_road: bool, - expect_experimental_lint: bool, + /// Resolution flags under test. + options: CrateResolutionOptions, + /// Crate names that must appear in the resolved set. + expected_present: &'static [&'static str], + /// Crate names that must be absent from the resolved set. + expected_absent: &'static [&'static str], } - /// Parameterized tests for resolve_crates variants. + /// Parameterized tests for `resolve_crates` variants. #[rstest] - #[case::default_suite_only(ResolveCratesCase { individual_lints: false, experimental: false, expect_lint: false, expect_suite: true, expect_bumpy_road: false, expect_experimental_lint: false })] - #[case::individual_lints(ResolveCratesCase { individual_lints: true, experimental: false, expect_lint: true, expect_suite: false, expect_bumpy_road: true, expect_experimental_lint: false })] - #[case::individual_with_experimental(ResolveCratesCase { individual_lints: true, experimental: true, expect_lint: true, expect_suite: false, expect_bumpy_road: true, expect_experimental_lint: true })] - #[case::suite_with_experimental(ResolveCratesCase { individual_lints: false, experimental: true, expect_lint: false, expect_suite: true, expect_bumpy_road: false, expect_experimental_lint: false })] + #[case::default_suite_only(ResolveCratesCase { + options: CrateResolutionOptions { individual_lints: false, experimental: false }, + expected_present: &[SUITE_CRATE], + expected_absent: &[ + "module_max_lines", + "bumpy_road_function", + "rstest_helper_should_be_fixture", + ], + })] + #[case::individual_lints(ResolveCratesCase { + options: CrateResolutionOptions { individual_lints: true, experimental: false }, + expected_present: &["module_max_lines", "bumpy_road_function"], + expected_absent: &[SUITE_CRATE, "rstest_helper_should_be_fixture"], + })] + #[case::individual_with_experimental(ResolveCratesCase { + options: CrateResolutionOptions { individual_lints: true, experimental: true }, + expected_present: &[ + "module_max_lines", + "bumpy_road_function", + "rstest_helper_should_be_fixture", + ], + expected_absent: &[SUITE_CRATE], + })] + #[case::suite_with_experimental(ResolveCratesCase { + options: CrateResolutionOptions { individual_lints: false, experimental: true }, + expected_present: &[SUITE_CRATE], + expected_absent: &[ + "module_max_lines", + "bumpy_road_function", + "rstest_helper_should_be_fixture", + ], + })] fn resolve_crates_variants(#[case] case: ResolveCratesCase) { - let options = CrateResolutionOptions { - individual_lints: case.individual_lints, - experimental: case.experimental, - }; - let crates = resolve_crates(&[], &options); + let crates = resolve_crates(&[], &case.options); - assert_eq!( - crates.contains(&CrateName::from("module_max_lines")), - case.expect_lint, - "lint crate inclusion mismatch" - ); - assert_eq!( - crates.contains(&CrateName::from(SUITE_CRATE)), - case.expect_suite, - "suite crate inclusion mismatch" - ); - assert_eq!( - crates.contains(&CrateName::from("bumpy_road_function")), - case.expect_bumpy_road, - "bumpy_road_function inclusion mismatch" - ); - assert_eq!( - crates.contains(&CrateName::from("rstest_helper_should_be_fixture")), - case.expect_experimental_lint, - "rstest_helper_should_be_fixture inclusion mismatch" - ); + for name in case.expected_present { + assert!( + crates.contains(&CrateName::from(*name)), + "{name} should be resolved, got {crates:?}" + ); + } + for name in case.expected_absent { + assert!( + !crates.contains(&CrateName::from(*name)), + "{name} should not be resolved, got {crates:?}" + ); + } } #[test] @@ -223,12 +243,15 @@ mod tests { assert!(res.is_ok()); } else { let err = res.expect_err("expected validation failure"); + let expected_name = crate_names + .first() + .expect("case should supply at least one crate name"); assert!( matches!( &err, InstallerError::LintCrateNotFound { name } | InstallerError::ExperimentalLintRequiresFlag { name } - if *name == crate_names[0] + if name == expected_name ), "unexpected error: {err:?}" ); diff --git a/installer/src/scanner.rs b/installer/src/scanner.rs index 4f26b660..c49f3da0 100644 --- a/installer/src/scanner.rs +++ b/installer/src/scanner.rs @@ -3,14 +3,15 @@ //! This module provides utilities to scan the staging directory and parse //! library filenames to extract lint metadata. -use std::collections::BTreeMap; -use std::io; +use std::{collections::BTreeMap, io}; use camino::{Utf8Path, Utf8PathBuf}; -use crate::builder::{library_extension, library_prefix}; -use crate::crate_name::CrateName; -use crate::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}; +use crate::{ + builder::{library_extension, library_prefix}, + crate_name::CrateName, + resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}, +}; /// Metadata about an installed lint library. #[derive(Debug, Clone, PartialEq, Eq)] @@ -33,9 +34,7 @@ pub struct InstalledLints { impl InstalledLints { /// Returns true if no lints are installed. #[must_use] - pub fn is_empty(&self) -> bool { - self.by_toolchain.is_empty() - } + pub fn is_empty(&self) -> bool { self.by_toolchain.is_empty() } } /// Scan the staging directory for installed libraries. @@ -57,8 +56,8 @@ pub fn scan_installed(target_dir: &Utf8Path) -> io::Result { } // Iterate over toolchain subdirectories - for entry in target_dir.read_dir_utf8()? { - let entry = entry?; + for entry_result in target_dir.read_dir_utf8()? { + let entry = entry_result?; let toolchain_path = entry.path(); if !toolchain_path.is_dir() { @@ -84,8 +83,8 @@ fn scan_toolchain_layouts( if release_path.is_dir() { libraries.extend(scan_toolchain_release(&release_path, toolchain)?); } - for entry in toolchain_path.read_dir_utf8()? { - let entry = entry?; + for entry_result in toolchain_path.read_dir_utf8()? { + let entry = entry_result?; if !entry.path().is_dir() || entry.file_name() == "release" { continue; } @@ -99,8 +98,8 @@ fn scan_toolchain_layouts( } fn contains_libraries_in_layout(lib_path: &Utf8Path) -> io::Result { - for entry in lib_path.read_dir_utf8()? { - let entry = entry?; + for entry_result in lib_path.read_dir_utf8()? { + let entry = entry_result?; if entry.path().is_file() && parse_library_filename(entry.file_name()).is_some() { return Ok(true); } @@ -115,8 +114,8 @@ fn scan_toolchain_release( ) -> io::Result> { let mut libraries = Vec::new(); - for entry in release_path.read_dir_utf8()? { - let entry = entry?; + for entry_result in release_path.read_dir_utf8()? { + let entry = entry_result?; let file_name = entry.file_name(); if let Some((crate_name, parsed_toolchain)) = parse_library_filename(file_name) { @@ -161,9 +160,7 @@ pub fn parse_library_filename(filename: &str) -> Option<(CrateName, String)> { let without_ext = without_prefix.strip_suffix(extension)?; // Split on @ to get crate name and toolchain - let at_pos = without_ext.find('@')?; - let crate_name = &without_ext[..at_pos]; - let toolchain = &without_ext[at_pos + 1..]; + let (crate_name, toolchain) = without_ext.split_once('@')?; if crate_name.is_empty() || toolchain.is_empty() { return None; @@ -185,8 +182,7 @@ pub fn parse_library_filename(filename: &str) -> Option<(CrateName, String)> { /// # Examples /// /// ``` -/// use whitaker_installer::scanner::lints_for_library; -/// use whitaker_installer::crate_name::CrateName; +/// use whitaker_installer::{crate_name::CrateName, scanner::lints_for_library}; /// /// let suite_lints = lints_for_library(&CrateName::from("whitaker_suite")); /// assert!(suite_lints.len() > 1); @@ -208,8 +204,7 @@ pub fn lints_for_library(crate_name: &CrateName) -> Vec<&'static str> { /// # Examples /// /// ``` -/// use whitaker_installer::scanner::lints_for_library_with_experimental; -/// use whitaker_installer::crate_name::CrateName; +/// use whitaker_installer::{crate_name::CrateName, scanner::lints_for_library_with_experimental}; /// /// let lints = lints_for_library_with_experimental(&CrateName::from("whitaker_suite"), true); /// assert!(lints.contains(&"bumpy_road_function")); @@ -241,10 +236,13 @@ pub fn lints_for_library_with_experimental( #[cfg(test)] mod tests { - use super::*; + //! Tests for scanning installed lint libraries. + use rstest::rstest; use tempfile::TempDir; + use super::*; + /// Skip test execution on non-Linux platforms where library extensions differ. macro_rules! skip_unless_linux { () => { @@ -395,7 +393,8 @@ mod tests { .get(toolchain) .expect("toolchain should exist"); assert_eq!(libs.len(), 1); - assert_eq!(libs[0].crate_name.as_str(), "whitaker_suite"); - assert_eq!(libs[0].toolchain, toolchain); + let library = libs.first().expect("library should be recorded"); + assert_eq!(library.crate_name.as_str(), "whitaker_suite"); + assert_eq!(library.toolchain, toolchain); } } diff --git a/installer/src/staged_suite.rs b/installer/src/staged_suite.rs index 777b56b3..ccc09a57 100644 --- a/installer/src/staged_suite.rs +++ b/installer/src/staged_suite.rs @@ -4,14 +4,17 @@ //! helper exists only so debug-built test binaries can stage a cheap synthetic //! artefact instead of recursively rebuilding the workspace inside nextest. -use camino::{Utf8Path, Utf8PathBuf}; use std::fs; -use whitaker_installer::crate_name::CrateName; -use whitaker_installer::error::{InstallerError, Result}; -use whitaker_installer::resolution::SUITE_CRATE; -use whitaker_installer::stager::Stager; -use whitaker_installer::test_support::TEST_STAGE_SUITE_ENV; -use whitaker_installer::toolchain::Toolchain; + +use camino::{Utf8Path, Utf8PathBuf}; +use whitaker_installer::{ + crate_name::CrateName, + error::{InstallerError, Result}, + resolution::SUITE_CRATE, + stager::Stager, + test_support::TEST_STAGE_SUITE_ENV, + toolchain::Toolchain, +}; pub(crate) fn try_test_staged_suite_installation( requested_crates: &[CrateName], @@ -58,12 +61,13 @@ mod tests { //! temporary environment-variable helpers to exercise the debug-only staged //! suite shortcuts without leaking process-wide state between cases. - use super::*; 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::*; + struct StagedSuiteSetup { _guard: std::sync::MutexGuard<'static, ()>, _temp_dir: TempDir, @@ -72,26 +76,21 @@ mod tests { } impl StagedSuiteSetup { - fn requested_suite_crates(&self) -> Vec { - vec![CrateName::from(SUITE_CRATE)] - } + fn requested_suite_crates() -> Vec { vec![CrateName::from(SUITE_CRATE)] } fn stager(&self) -> Stager { Stager::new(self.target_dir.clone(), self.toolchain.channel()) } - fn create_blocked_suite_output(&self) -> Utf8PathBuf { + fn create_blocked_suite_output(&self) -> Result { let stager = self.stager(); - stager - .prepare() - .expect("expected staging directory to be writable for test setup"); + stager.prepare()?; let blocked_path = stager .staging_path() .join(stager.staged_filename(&CrateName::from(SUITE_CRATE))); - std::fs::create_dir_all(blocked_path.as_std_path()) - .expect("expected to pre-create staged filename as a directory"); - blocked_path + std::fs::create_dir_all(blocked_path.as_std_path())?; + Ok(blocked_path) } } @@ -99,24 +98,24 @@ mod tests { Toolchain::with_override(Utf8Path::new("."), "nightly-2026-05-28") } - fn utf8_temp_dir(temp_dir: &TempDir) -> Utf8PathBuf { + fn utf8_temp_dir(temp_dir: &TempDir) -> std::io::Result { Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()) - .expect("expected UTF-8 temp path for staged suite tests") + .map_err(|_| std::io::Error::other("temporary directory path must be UTF-8")) } #[fixture] - fn staged_suite_setup() -> StagedSuiteSetup { + fn staged_suite_setup() -> std::io::Result { let guard = env_test_guard(); - let temp_dir = tempfile::tempdir().expect("expected temp dir for staged suite tests"); - let target_dir = utf8_temp_dir(&temp_dir); + let temp_dir = tempfile::tempdir()?; + let target_dir = utf8_temp_dir(&temp_dir)?; let toolchain = test_toolchain(); - StagedSuiteSetup { + Ok(StagedSuiteSetup { _guard: guard, _temp_dir: temp_dir, toolchain, target_dir, - } + }) } #[rstest] @@ -139,9 +138,11 @@ mod tests { #[case::env_disabled((vec![CrateName::from(SUITE_CRATE)], Some("0")))] #[case::non_suite((vec![CrateName::from("module_max_lines")], Some("1")))] fn staged_suite_installation_skips_non_staging_requests( - staged_suite_setup: StagedSuiteSetup, + #[from(staged_suite_setup)] setup_res: std::io::Result, #[case] case: (Vec, Option<&str>), ) { + let staged_suite_setup = setup_res.expect("staged suite setup should be created"); + let (requested_crates, env_val) = case; let run = || { let result = try_test_staged_suite_installation( @@ -170,9 +171,11 @@ mod tests { #[cfg(debug_assertions)] #[rstest] fn staged_suite_installation_writes_placeholder_library_for_suite_requests( - staged_suite_setup: StagedSuiteSetup, + #[from(staged_suite_setup)] setup_res: std::io::Result, ) { - let requested_crates = staged_suite_setup.requested_suite_crates(); + let staged_suite_setup = setup_res.expect("staged suite setup should be created"); + + let requested_crates = StagedSuiteSetup::requested_suite_crates(); let stager = staged_suite_setup.stager(); with_var(TEST_STAGE_SUITE_ENV, Some("1"), || { @@ -195,9 +198,15 @@ mod tests { #[cfg(debug_assertions)] #[rstest] - fn staged_suite_installation_surfaces_write_failures(staged_suite_setup: StagedSuiteSetup) { - let requested_crates = staged_suite_setup.requested_suite_crates(); - let blocked_path = staged_suite_setup.create_blocked_suite_output(); + fn staged_suite_installation_surfaces_write_failures( + #[from(staged_suite_setup)] setup_res: std::io::Result, + ) { + let staged_suite_setup = setup_res.expect("staged suite setup should be created"); + + let requested_crates = StagedSuiteSetup::requested_suite_crates(); + let blocked_path = staged_suite_setup + .create_blocked_suite_output() + .expect("blocked staged output should be pre-created"); with_var(TEST_STAGE_SUITE_ENV, Some("1"), || { let err = try_test_staged_suite_installation( @@ -218,9 +227,11 @@ mod tests { #[cfg(not(debug_assertions))] #[rstest] fn staged_suite_installation_is_disabled_in_release_builds( - staged_suite_setup: StagedSuiteSetup, + #[from(staged_suite_setup)] setup_res: std::io::Result, ) { - let requested_crates = staged_suite_setup.requested_suite_crates(); + let staged_suite_setup = setup_res.expect("staged suite setup should be created"); + + let requested_crates = StagedSuiteSetup::requested_suite_crates(); let staging_dir = staged_suite_setup .target_dir .join(staged_suite_setup.toolchain.channel()) diff --git a/installer/src/stager.rs b/installer/src/stager.rs index fd677277..b554f11b 100644 --- a/installer/src/stager.rs +++ b/installer/src/stager.rs @@ -3,12 +3,16 @@ //! This module handles copying built libraries to the target directory with //! the toolchain-specific naming convention required by Dylint. -use crate::builder::{BuildResult, library_extension, library_prefix}; -use crate::crate_name::CrateName; -use crate::error::{InstallerError, Result}; -use camino::{Utf8Path, Utf8PathBuf}; use std::fs; +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::{ + builder::{BuildResult, library_extension, library_prefix}, + crate_name::CrateName, + error::{InstallerError, Result}, +}; + /// Handles staging of built libraries to the target directory. pub struct Stager { target_dir: Utf8PathBuf, @@ -39,7 +43,9 @@ impl Stager { let test_path = staging_dir.join(".whitaker-installer-test"); match fs::write(&test_path, b"test") { Ok(()) => { - let _ = fs::remove_file(&test_path); + // Removing the probe file is best effort; a leftover probe + // does not affect staging correctness. + drop(fs::remove_file(&test_path)); Ok(()) } Err(e) => Err(InstallerError::TargetNotWritable { @@ -90,9 +96,7 @@ impl Stager { /// Return the target directory root. #[must_use] - pub fn target_dir(&self) -> &Utf8Path { - &self.target_dir - } + pub fn target_dir(&self) -> &Utf8Path { &self.target_dir } /// Compute the staged filename with toolchain suffix. /// @@ -123,7 +127,7 @@ impl Stager { /// directory. This function creates a `directories_next::BaseDirs` instance and /// calls its `data_local_dir()` method to obtain the base path (for example, /// `~/.local/share` on many Linux distributions, `~/Library/Application Support` -/// on macOS, and the Local AppData directory on Windows). The installer appends +/// on macOS, and the local `AppData` directory on Windows). The installer appends /// `dylint/lib` under that directory. #[must_use] pub fn default_target_dir() -> Option { @@ -135,6 +139,8 @@ pub fn default_target_dir() -> Option { #[cfg(test)] mod tests { + //! Tests for staging lint libraries into the target directory. + use super::*; #[test] diff --git a/installer/src/test_utils/dependency_binary_helpers.rs b/installer/src/test_utils/dependency_binary_helpers.rs index ddf5fdff..8c1c98ae 100644 --- a/installer/src/test_utils/dependency_binary_helpers.rs +++ b/installer/src/test_utils/dependency_binary_helpers.rs @@ -1,123 +1,37 @@ //! Test helpers for dependency binary installation tests. -use crate::dependency_binaries::find_dependency_binary; -#[cfg(any(test, feature = "test-support"))] -use crate::dependency_binaries::{ - DependencyBinary, DependencyBinaryInstallError, DependencyBinaryInstaller, -}; -#[cfg(any(test, feature = "test-support"))] -use crate::dirs::BaseDirs; -use crate::error::Result; -#[cfg(any(test, feature = "test-support"))] -use crate::installer_packaging::TargetTriple; -#[cfg(any(test, feature = "test-support"))] -use crate::test_support::env_test_guard; -use crate::test_utils::{ExpectedCall, failure_output, stdout_output, success_output}; -#[cfg(any(test, feature = "test-support"))] -use std::fs; -#[cfg(all(any(test, feature = "test-support"), unix))] -use std::os::unix::fs::PermissionsExt; -#[cfg(any(test, feature = "test-support"))] -use std::path::{Path, PathBuf}; use std::process::Output; -/// Repository installer test double that always reports a missing archive. -#[cfg(any(test, feature = "test-support"))] -pub struct AlwaysNotFoundRepositoryInstaller; - -#[cfg(any(test, feature = "test-support"))] -impl DependencyBinaryInstaller for AlwaysNotFoundRepositoryInstaller { - fn install( - &self, - dependency: &DependencyBinary, - target: &TargetTriple, - _dirs: &dyn BaseDirs, - ) -> std::result::Result { - Err(DependencyBinaryInstallError::NotFound { - url: format!( - "https://example.test/{}-{}-v{}.tgz", - dependency.package(), - target, - dependency.version() - ), - }) - } -} - -/// Writes a fake binary at `path` that exits successfully. -#[cfg(any(test, feature = "test-support"))] -pub fn write_fake_binary(path: &Path, is_executable: bool) { - write_fake_binary_with_status(path, is_executable, 0); -} - -/// Writes a fake binary at `path` that exits with the supplied status code. -#[cfg(any(test, feature = "test-support"))] -pub fn write_fake_binary_with_status(path: &Path, is_executable: bool, exit_code: i32) { - fs::write(path, fake_binary_contents(exit_code)).expect("write fake binary"); - #[cfg(unix)] - { - let mode = if is_executable { 0o755 } else { 0o644 }; - let mut permissions = fs::metadata(path) - .expect("read fake binary metadata") - .permissions(); - permissions.set_mode(mode); - fs::set_permissions(path, permissions).expect("set fake binary permissions"); - } - #[cfg(not(unix))] - let _ = is_executable; -} - -#[cfg(any(test, feature = "test-support"))] -fn fake_binary_contents(exit_code: i32) -> Vec { - #[cfg(windows)] - { - format!("@echo off\r\nexit /b {exit_code}\r\n").into_bytes() - } - #[cfg(not(windows))] - { - format!("#!/bin/sh\nexit {exit_code}\n").into_bytes() - } -} +use crate::{ + dependency_binaries::find_dependency_binary, + error::Result, + test_utils::{ExpectedCall, failure_output, stdout_output, success_output}, +}; -/// Runs a closure with `PATH` pointing at one or more fake directories. -#[cfg(any(test, feature = "test-support"))] -pub fn with_fake_path(setup: impl FnOnce(&[PathBuf]), run: impl FnOnce() -> T) -> T { - let _guard = env_test_guard(); - let temp_dirs = [ - tempfile::tempdir().expect("create temp dir"), - tempfile::tempdir().expect("create temp dir"), - ]; - let path_dirs = temp_dirs - .iter() - .map(|dir| dir.path().to_path_buf()) - .collect::>(); - setup(&path_dirs); - let path = std::env::join_paths(path_dirs.iter().map(PathBuf::as_path)) - .expect("join fake PATH directories"); - temp_env::with_var("PATH", Some(path), run) -} +#[path = "dependency_binary_helpers_fakes.rs"] +mod fakes; -/// Runs a closure with `PATH` containing a fake executable in the first entry. -#[cfg(any(test, feature = "test-support"))] -pub fn with_fake_binary_on_path(binary_name: &str, run: impl FnOnce() -> T) -> T { - with_fake_path( - |directories| write_fake_binary(&path_binary_location(&directories[0], binary_name), true), - run, - ) -} +pub use fakes::{ + AlwaysNotFoundRepositoryInstaller, + path_binary_location, + with_fake_binary_on_path, + with_fake_path, + write_fake_binary, + write_fake_binary_with_status, +}; -/// Joins `binary_name` onto `directory` with the platform executable suffix, -/// so directly probed fakes are runnable on Windows as well as Unix. -#[cfg(any(test, feature = "test-support"))] -pub fn path_binary_location(directory: &Path, binary_name: &str) -> PathBuf { - #[cfg(windows)] - { - directory.join(format!("{binary_name}.cmd")) - } - #[cfg(not(windows))] - { - directory.join(binary_name) - } +/// Expected outcome of repository-install verification in a test scenario. +/// +/// Collapses the "should verification run" and "does verification fail" +/// questions into a single three-state value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepositoryVerification { + /// Repository verification is not expected to run. + Skip, + /// Repository verification runs and succeeds. + Succeeds, + /// Repository verification runs and fails. + Fails, } /// Configuration for generating expected calls in dependency binary tests. @@ -128,10 +42,8 @@ pub struct ExpectedCallConfig<'a> { pub has_repository_context: bool, /// Whether the repository failure is a missing asset. pub is_repository_asset_missing: bool, - /// Whether to verify repository installation. - pub should_verify_repository_install: bool, - /// Whether repository verification should fail. - pub is_repository_verification_failing: bool, + /// Expected repository-install verification outcome. + pub repository_verification: RepositoryVerification, /// Error message for cargo binstall failure (None if succeeds). pub cargo_binstall_failure: Option<&'a str>, /// Error message for cargo install failure (None if succeeds). @@ -139,6 +51,7 @@ pub struct ExpectedCallConfig<'a> { } /// Creates an expected call for checking cargo-binstall availability. +#[must_use] pub fn binstall_version_check(is_binstall_available: bool) -> ExpectedCall { ExpectedCall { cmd: "cargo", @@ -152,6 +65,7 @@ pub fn binstall_version_check(is_binstall_available: bool) -> ExpectedCall { } /// Creates an expected call for checking cargo-binstall with a fixed result. +#[must_use] pub fn binstall_version_check_with_result(result: Result) -> ExpectedCall { ExpectedCall { cmd: "cargo", @@ -161,6 +75,7 @@ pub fn binstall_version_check_with_result(result: Result) -> ExpectedCal } /// Creates an expected call for installing a tool with cargo-binstall. +#[must_use] pub fn binstall_install(tool: &'static str, result: Result) -> ExpectedCall { let version = dependency_version(tool); ExpectedCall { @@ -171,6 +86,7 @@ pub fn binstall_install(tool: &'static str, result: Result) -> ExpectedC } /// Creates an expected call for installing a tool with cargo install. +#[must_use] pub fn cargo_install(tool: &'static str, result: Result) -> ExpectedCall { ExpectedCall { cmd: "cargo", @@ -196,14 +112,17 @@ fn cargo_source_install( /// # Panics /// /// Panics when the manifest cannot be parsed or the tool is unknown. +#[must_use] pub fn dependency_version(tool: &str) -> &'static str { - find_dependency_binary(tool) - .expect("dependency manifest should parse") - .map(|dependency| dependency.version()) - .unwrap_or_else(|| panic!("unexpected tool: {tool}")) + match find_dependency_binary(tool) { + Ok(Some(dependency)) => dependency.version(), + Ok(None) => panic!("unexpected tool: {tool}"), + Err(error) => panic!("dependency manifest should parse: {error}"), + } } /// Successful `cargo dylint --version` output reporting the manifest version. +#[must_use] pub fn cargo_dylint_version_output() -> Output { stdout_output(format!( "cargo-dylint {}\n", @@ -213,12 +132,14 @@ pub fn cargo_dylint_version_output() -> Output { /// Expected `cargo install --list` call reporting the manifest-pinned /// `dylint-link` version. +#[must_use] pub fn dylint_link_install_list_check() -> ExpectedCall { dylint_link_install_list_check_with_version(dependency_version("dylint-link")) } /// Expected `cargo install --list` call reporting the given `dylint-link` /// version. +#[must_use] pub fn dylint_link_install_list_check_with_version(version: &str) -> ExpectedCall { ExpectedCall { cmd: "cargo", @@ -230,6 +151,7 @@ pub fn dylint_link_install_list_check_with_version(version: &str) -> ExpectedCal } /// Creates an expected call for verifying repository installation. +#[must_use] pub fn repository_verification_call(tool: &str, verification_fails: bool) -> Option { match tool { "cargo-dylint" => Some(ExpectedCall { @@ -313,16 +235,15 @@ fn post_primary_calls(cfg: &PostPrimaryConfig) -> Vec { return calls; } // binstall failed and cargo install also fails - if let Some(message) = cfg.cargo_install_failure.as_deref() { - let cargo_call = repo_aware_cargo_install( - cfg.tool_static, - cfg.has_repository_context, - Ok(failure_output(message)), - ); - vec![cargo_call] - } else { - vec![] - } + cfg.cargo_install_failure + .as_deref() + .map_or_else(Vec::new, |message| { + vec![repo_aware_cargo_install( + cfg.tool_static, + cfg.has_repository_context, + Ok(failure_output(message)), + )] + }) } fn source_install_fallback_calls( @@ -383,10 +304,7 @@ pub fn cargo_fallback_calls(tool: &str, config: &ExpectedCallConfig<'_>) -> Vec< let install_call = ExpectedCall { cmd: "cargo", args, - result: Ok(match failure_message { - Some(message) => failure_output(message), - None => success_output(), - }), + result: Ok(failure_message.map_or_else(success_output, failure_output)), }; let mut calls = vec![install_call]; @@ -403,24 +321,27 @@ pub fn cargo_fallback_calls(tool: &str, config: &ExpectedCallConfig<'_>) -> Vec< } /// Builds the complete list of expected calls for a dependency binary test scenario. -pub fn expected_calls(tool: &str, config: ExpectedCallConfig<'_>) -> Vec { +#[must_use] +pub fn expected_calls(tool: &str, config: &ExpectedCallConfig<'_>) -> Vec { let mut calls = vec![binstall_version_check(config.is_binstall_available)]; - if config.should_verify_repository_install { - calls.extend(repository_verification_call( - tool, - config.is_repository_verification_failing, - )); - if !config.is_repository_verification_failing { + match config.repository_verification { + RepositoryVerification::Skip => {} + RepositoryVerification::Succeeds => { + calls.extend(repository_verification_call(tool, false)); return calls; } + RepositoryVerification::Fails => { + calls.extend(repository_verification_call(tool, true)); + } } - calls.extend(cargo_fallback_calls(tool, &config)); + calls.extend(cargo_fallback_calls(tool, config)); calls } /// Creates an expected call for verifying cargo-dylint installation. +#[must_use] pub fn cargo_dylint_check() -> ExpectedCall { ExpectedCall { cmd: "cargo", @@ -430,6 +351,7 @@ pub fn cargo_dylint_check() -> ExpectedCall { } /// Creates an expected call for verifying cargo-dylint with a fixed result. +#[must_use] pub fn cargo_dylint_check_with_result(result: Result) -> ExpectedCall { ExpectedCall { cmd: "cargo", diff --git a/installer/src/test_utils/dependency_binary_helpers_fakes.rs b/installer/src/test_utils/dependency_binary_helpers_fakes.rs new file mode 100644 index 00000000..cda8f7c8 --- /dev/null +++ b/installer/src/test_utils/dependency_binary_helpers_fakes.rs @@ -0,0 +1,146 @@ +//! Fake binaries, PATH staging, and repository-installer doubles. +//! +//! These helpers stage executables in temporary directories and point `PATH` +//! at them so dependency-install probes can be exercised without touching a +//! real toolchain. + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{ + dependency_binaries::{ + DependencyBinary, + DependencyBinaryInstallError, + DependencyBinaryInstaller, + }, + dirs::BaseDirs, + installer_packaging::TargetTriple, + test_support::env_test_guard, +}; + +/// Repository installer test double that always reports a missing archive. +pub struct AlwaysNotFoundRepositoryInstaller; + +impl DependencyBinaryInstaller for AlwaysNotFoundRepositoryInstaller { + fn install( + &self, + dependency: &DependencyBinary, + target: &TargetTriple, + _dirs: &dyn BaseDirs, + ) -> std::result::Result { + Err(DependencyBinaryInstallError::NotFound { + url: format!( + "https://example.test/{}-{}-v{}.tgz", + dependency.package(), + target, + dependency.version() + ), + }) + } +} + +/// Writes a fake binary at `path` that exits successfully. +/// +/// # Errors +/// +/// Returns any I/O error raised while writing the binary or setting its +/// permissions. +pub fn write_fake_binary(path: &Path, is_executable: bool) -> std::io::Result<()> { + write_fake_binary_with_status(path, is_executable, 0) +} + +/// Writes a fake binary at `path` that exits with the supplied status code. +/// +/// # Errors +/// +/// Returns any I/O error raised while writing the binary or setting its +/// permissions. +pub fn write_fake_binary_with_status( + path: &Path, + is_executable: bool, + exit_code: i32, +) -> std::io::Result<()> { + fs::write(path, fake_binary_contents(exit_code))?; + #[cfg(unix)] + { + let mode = if is_executable { 0o755 } else { 0o644 }; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions)?; + } + #[cfg(not(unix))] + let _ = is_executable; + Ok(()) +} + +fn fake_binary_contents(exit_code: i32) -> Vec { + #[cfg(windows)] + { + format!("@echo off\r\nexit /b {exit_code}\r\n").into_bytes() + } + #[cfg(not(windows))] + { + format!("#!/bin/sh\nexit {exit_code}\n").into_bytes() + } +} + +/// Runs a closure with `PATH` pointing at one or more fake directories. +/// +/// # Errors +/// +/// Returns an I/O error when the fake directories cannot be created, the +/// `setup` closure fails, or the fake `PATH` cannot be joined. +pub fn with_fake_path( + setup: impl FnOnce(&[PathBuf]) -> std::io::Result<()>, + run: impl FnOnce() -> T, +) -> std::io::Result { + let _guard = env_test_guard(); + let temp_dirs = [tempfile::tempdir()?, tempfile::tempdir()?]; + let path_dirs = temp_dirs + .iter() + .map(|dir| dir.path().to_path_buf()) + .collect::>(); + setup(&path_dirs)?; + let path = std::env::join_paths(path_dirs.iter().map(PathBuf::as_path)) + .map_err(std::io::Error::other)?; + Ok(temp_env::with_var("PATH", Some(path), run)) +} + +/// Runs a closure with `PATH` containing a fake executable in the first entry. +/// +/// # Errors +/// +/// Returns an I/O error when the fake `PATH` cannot be prepared or the fake +/// binary cannot be written. +pub fn with_fake_binary_on_path( + binary_name: &str, + run: impl FnOnce() -> T, +) -> std::io::Result { + with_fake_path( + |directories| { + let first_dir = directories.first().ok_or_else(|| { + std::io::Error::other("fake PATH should contain at least one directory") + })?; + write_fake_binary(&path_binary_location(first_dir, binary_name), true) + }, + run, + ) +} + +/// Joins `binary_name` onto `directory` with the platform executable suffix, +/// so directly probed fakes are runnable on Windows as well as Unix. +#[must_use] +pub fn path_binary_location(directory: &Path, binary_name: &str) -> PathBuf { + #[cfg(windows)] + { + directory.join(format!("{binary_name}.cmd")) + } + #[cfg(not(windows))] + { + directory.join(binary_name) + } +} diff --git a/installer/src/test_utils/dependency_binary_helpers_tests.rs b/installer/src/test_utils/dependency_binary_helpers_tests.rs index ddf41f68..10d8aa27 100644 --- a/installer/src/test_utils/dependency_binary_helpers_tests.rs +++ b/installer/src/test_utils/dependency_binary_helpers_tests.rs @@ -1,16 +1,17 @@ //! Tests for dependency binary helper fixtures and expected call builders. use crate::test_utils::dependency_binary_helpers::{ - ExpectedCallConfig, dependency_version, expected_calls, repository_verification_call, + ExpectedCallConfig, + RepositoryVerification, + dependency_version, + expected_calls, + repository_verification_call, }; #[test] fn repository_verification_call_returns_probe_for_cargo_dylint() { - let call = repository_verification_call("cargo-dylint", false); - let call = match call { - Some(call) => call, - None => panic!("cargo-dylint should use a verification probe"), - }; + let call = repository_verification_call("cargo-dylint", false) + .expect("cargo-dylint should use a verification probe"); assert_eq!(call.cmd, "cargo"); assert_eq!(call.args, vec!["dylint", "--version"]); @@ -35,38 +36,38 @@ fn repository_verification_call_skips_executor_for_dylint_link(#[case] verificat fn expected_calls_include_repository_probe_for_cargo_dylint() { let calls = expected_calls( "cargo-dylint", - ExpectedCallConfig { + &ExpectedCallConfig { is_binstall_available: false, has_repository_context: true, is_repository_asset_missing: false, - should_verify_repository_install: true, - is_repository_verification_failing: false, + repository_verification: RepositoryVerification::Succeeds, cargo_binstall_failure: None, cargo_install_failure: None, }, ); assert_eq!(calls.len(), 2); - assert_eq!(calls[1].cmd, "cargo"); - assert_eq!(calls[1].args, vec!["dylint", "--version"]); + let verification = calls.get(1).expect("verification probe should be recorded"); + assert_eq!(verification.cmd, "cargo"); + assert_eq!(verification.args, vec!["dylint", "--version"]); } #[test] fn expected_calls_omit_executor_verification_for_dylint_link() { let calls = expected_calls( "dylint-link", - ExpectedCallConfig { + &ExpectedCallConfig { is_binstall_available: false, has_repository_context: true, is_repository_asset_missing: false, - should_verify_repository_install: true, - is_repository_verification_failing: false, + repository_verification: RepositoryVerification::Succeeds, cargo_binstall_failure: None, cargo_install_failure: None, }, ); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].cmd, "cargo"); - assert_eq!(calls[0].args, vec!["binstall", "--version"]); + let probe = calls.first().expect("binstall probe should be recorded"); + assert_eq!(probe.cmd, "cargo"); + assert_eq!(probe.args, vec!["binstall", "--version"]); } diff --git a/installer/src/test_utils.rs b/installer/src/test_utils/mod.rs similarity index 82% rename from installer/src/test_utils.rs rename to installer/src/test_utils/mod.rs index 24f5745a..38ebc93e 100644 --- a/installer/src/test_utils.rs +++ b/installer/src/test_utils/mod.rs @@ -1,15 +1,17 @@ //! Shared test utilities for the installer crate. +use std::{ + cell::RefCell, + collections::VecDeque, + path::PathBuf, + process::{ExitStatus, Output}, +}; + #[cfg(any(test, feature = "test-support"))] use crate::deps::CommandExecutor; -use crate::dirs::BaseDirs; #[cfg(any(test, feature = "test-support"))] use crate::error::InstallerError; -use crate::error::Result; -use std::cell::RefCell; -use std::collections::VecDeque; -use std::path::PathBuf; -use std::process::{ExitStatus, Output}; +use crate::{dirs::BaseDirs, error::Result}; /// Creates an `ExitStatus` from an exit code (Unix implementation). /// @@ -25,6 +27,7 @@ use std::process::{ExitStatus, Output}; /// assert!(!failure.success()); /// ``` #[cfg(unix)] +#[must_use] pub fn exit_status(code: i32) -> ExitStatus { use std::os::unix::process::ExitStatusExt; @@ -45,6 +48,7 @@ pub fn exit_status(code: i32) -> ExitStatus { /// assert!(!failure.success()); /// ``` #[cfg(windows)] +#[must_use] pub fn exit_status(code: i32) -> ExitStatus { use std::os::windows::process::ExitStatusExt; @@ -63,6 +67,7 @@ pub fn exit_status(code: i32) -> ExitStatus { /// assert!(output.stdout.is_empty()); /// assert!(output.stderr.is_empty()); /// ``` +#[must_use] pub fn success_output() -> Output { Output { status: exit_status(0), @@ -104,37 +109,32 @@ pub fn stdout_output(stdout: impl AsRef) -> Output { /// assert_eq!(output.stderr, b"command failed"); /// ``` pub fn failure_output(stderr: impl AsRef) -> Output { - let stderr = stderr.as_ref(); + let stderr_text = stderr.as_ref(); Output { status: exit_status(1), stdout: Vec::new(), - stderr: stderr.as_bytes().to_vec(), + stderr: stderr_text.as_bytes().to_vec(), } } /// Minimal directory stub for tests that only care about the binary path. #[derive(Debug, Clone, Default)] pub struct StubDirs { - /// Directory returned by [`BaseDirs::bin_dir`]. + /// Directory returned by [`BaseDirs::executables`]. pub bin_dir: Option, } impl BaseDirs for StubDirs { - fn home_dir(&self) -> Option { - None - } + fn home(&self) -> Option { None } - fn bin_dir(&self) -> Option { - self.bin_dir.clone() - } + fn executables(&self) -> Option { self.bin_dir.clone() } - fn whitaker_data_dir(&self) -> Option { - None - } + fn whitaker_data(&self) -> Option { None } } /// Compute the SHA-256 hex digest of a byte slice for test fixtures. #[cfg(any(test, feature = "test-support"))] +#[must_use] pub fn sha256_hex(data: &[u8]) -> String { use sha2::{Digest, Sha256}; crate::hex::to_lower_hex(&Sha256::digest(data)) @@ -147,9 +147,9 @@ pub fn prebuilt_manifest_json( target: impl AsRef, sha256: impl AsRef, ) -> String { - let toolchain = toolchain.as_ref(); - let target = target.as_ref(); - let sha256 = sha256.as_ref(); + let toolchain_value = toolchain.as_ref(); + let target_value = target.as_ref(); + let sha256_value = sha256.as_ref(); format!( concat!( r#"{{"git_sha":"abc1234","schema_version":1,"#, @@ -159,9 +159,9 @@ pub fn prebuilt_manifest_json( r#""files":["libwhitaker_suite.so"],"#, r#""sha256":"{sha256}"}}"#, ), - toolchain = toolchain, - target = target, - sha256 = sha256, + toolchain = toolchain_value, + target = target_value, + sha256 = sha256_value, ) } @@ -200,18 +200,20 @@ pub struct ExpectedCall { /// # Examples /// /// ``` -/// use whitaker_installer::deps::CommandExecutor; -/// use whitaker_installer::test_utils::{ExpectedCall, StubExecutor, success_output}; +/// use whitaker_installer::{ +/// deps::CommandExecutor, +/// test_utils::{ExpectedCall, StubExecutor, success_output}, +/// }; /// -/// let executor = StubExecutor::new(vec![ -/// ExpectedCall { -/// cmd: "cargo", -/// args: vec!["--version"], -/// result: Ok(success_output()), -/// }, -/// ]); +/// let executor = StubExecutor::new(vec![ExpectedCall { +/// cmd: "cargo", +/// args: vec!["--version"], +/// result: Ok(success_output()), +/// }]); /// -/// let output = executor.run("cargo", &["--version"]).expect("command failed"); +/// let output = executor +/// .run("cargo", &["--version"]) +/// .expect("command failed"); /// assert!(output.status.success()); /// /// executor.assert_finished(); @@ -229,14 +231,13 @@ impl StubExecutor { /// ``` /// use whitaker_installer::test_utils::{ExpectedCall, StubExecutor, success_output}; /// - /// let executor = StubExecutor::new(vec![ - /// ExpectedCall { - /// cmd: "cargo", - /// args: vec!["build"], - /// result: Ok(success_output()), - /// }, - /// ]); + /// let executor = StubExecutor::new(vec![ExpectedCall { + /// cmd: "cargo", + /// args: vec!["build"], + /// result: Ok(success_output()), + /// }]); /// ``` + #[must_use] pub fn new(expected: Vec) -> Self { Self { expected: RefCell::new(expected.into()), @@ -252,16 +253,16 @@ impl StubExecutor { /// # Examples /// /// ``` - /// use whitaker_installer::deps::CommandExecutor; - /// use whitaker_installer::test_utils::{ExpectedCall, StubExecutor, success_output}; + /// use whitaker_installer::{ + /// deps::CommandExecutor, + /// test_utils::{ExpectedCall, StubExecutor, success_output}, + /// }; /// - /// let executor = StubExecutor::new(vec![ - /// ExpectedCall { - /// cmd: "cargo", - /// args: vec!["test"], - /// result: Ok(success_output()), - /// }, - /// ]); + /// let executor = StubExecutor::new(vec![ExpectedCall { + /// cmd: "cargo", + /// args: vec!["test"], + /// result: Ok(success_output()), + /// }]); /// /// // Execute the expected command /// let _ = executor.run("cargo", &["test"]); diff --git a/installer/src/tests/fast_path.rs b/installer/src/tests/fast_path.rs index 4dd9aa08..685a5c7e 100644 --- a/installer/src/tests/fast_path.rs +++ b/installer/src/tests/fast_path.rs @@ -1,12 +1,16 @@ //! Tests for fast-path installer helper behaviour. -use super::*; use camino::{Utf8Path, Utf8PathBuf}; use rstest::{fixture, rstest}; use temp_env::with_var_unset; -use whitaker_installer::crate_name::CrateName; -use whitaker_installer::test_support::{TEST_STAGE_SUITE_ENV, env_test_guard}; -use whitaker_installer::toolchain::Toolchain; +use whitaker_installer::{ + cli::ExecutionFlags, + crate_name::CrateName, + test_support::{TEST_STAGE_SUITE_ENV, env_test_guard}, + toolchain::Toolchain, +}; + +use super::*; struct FastPathFixture { args: InstallArgs, @@ -33,9 +37,9 @@ fn fast_path_fixture() -> FastPathFixture { FastPathFixture { args: InstallArgs::default(), dirs: TestBaseDirs { - home_dir: Some("/tmp".into()), - bin_dir: Some("/tmp/bin".into()), - data_dir: Some("/tmp".into()), + home: Some("/tmp".into()), + bin: Some("/tmp/bin".into()), + data: Some("/tmp".into()), }, toolchain: Toolchain::with_override(Utf8Path::new("."), "nightly-2026-05-28"), target_dir: Utf8PathBuf::from("/tmp/target"), @@ -46,9 +50,12 @@ fn fast_path_fixture() -> FastPathFixture { #[rstest] #[case::without_cranelift(false, &[])] #[case::with_cranelift(true, &["rustc-codegen-cranelift"])] -fn resolve_additional_components_parametrised(#[case] cranelift: bool, #[case] expected: &[&str]) { +fn resolve_additional_components_parametrized(#[case] cranelift: bool, #[case] expected: &[&str]) { let args = InstallArgs { - cranelift, + execution: ExecutionFlags { + cranelift, + ..ExecutionFlags::default() + }, ..InstallArgs::default() }; @@ -59,8 +66,8 @@ fn resolve_additional_components_parametrised(#[case] cranelift: bool, #[case] e fn fast_path_context_holds_supplied_values(fast_path_fixture: FastPathFixture) { let ctx = fast_path_fixture.context(); - assert!(std::ptr::eq(ctx.args, &fast_path_fixture.args)); - assert_eq!(ctx.dirs.home_dir(), Some(PathBuf::from("/tmp"))); + assert!(std::ptr::eq(ctx.args, &raw const fast_path_fixture.args)); + assert_eq!(ctx.dirs.home(), Some(PathBuf::from("/tmp"))); assert_eq!(ctx.toolchain.channel(), "nightly-2026-05-28"); assert_eq!(ctx.target_dir, &Utf8PathBuf::from("/tmp/target")); assert!(ctx.requested_crates.is_empty()); diff --git a/installer/src/tests.rs b/installer/src/tests/mod.rs similarity index 79% rename from installer/src/tests.rs rename to installer/src/tests/mod.rs index 524be606..21776648 100644 --- a/installer/src/tests.rs +++ b/installer/src/tests/mod.rs @@ -2,40 +2,50 @@ mod fast_path; -use super::*; +use std::{path::PathBuf, time::Duration}; + use rstest::{fixture, rstest}; -use std::path::PathBuf; -use std::time::Duration; -use whitaker_installer::cli::InstallArgs; -use whitaker_installer::dependency_binaries::DependencyBinaryInstaller; -use whitaker_installer::deps::DependencyInstallOptions; -use whitaker_installer::dirs::BaseDirs; -use whitaker_installer::installer_packaging::TargetTriple; -use whitaker_installer::test_utils::dependency_binary_helpers::{ - AlwaysNotFoundRepositoryInstaller, cargo_dylint_check, dylint_link_install_list_check, - with_fake_binary_on_path, +use whitaker_installer::{ + cli::{InstallArgs, LintSelectionFlags}, + dependency_binaries::DependencyBinaryInstaller, + deps::DependencyInstallOptions, + dirs::BaseDirs, + installer_packaging::TargetTriple, + test_utils::{ + dependency_binary_helpers::{ + AlwaysNotFoundRepositoryInstaller, + cargo_dylint_check, + dylint_link_install_list_check, + with_fake_binary_on_path, + }, + *, + }, }; -use whitaker_installer::test_utils::*; + +use super::*; fn dependency_install_options<'a>( dirs: &'a TestBaseDirs, repository_installer: &'a dyn DependencyBinaryInstaller, quiet: bool, -) -> DependencyInstallOptions<'a> { - DependencyInstallOptions { +) -> std::result::Result< + DependencyInstallOptions<'a>, + whitaker_installer::artefact::error::ArtefactError, +> { + Ok(DependencyInstallOptions { dirs, repository_installer, - target: Some(TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target")), + target: Some(TargetTriple::try_from("x86_64-unknown-linux-gnu")?), quiet, - } + }) } #[fixture] fn test_base_dirs() -> TestBaseDirs { TestBaseDirs { - home_dir: Some(PathBuf::from("/tmp")), - bin_dir: Some(PathBuf::from("/tmp/bin")), - data_dir: Some(PathBuf::from("/tmp")), + home: Some(PathBuf::from("/tmp")), + bin: Some(PathBuf::from("/tmp/bin")), + data: Some(PathBuf::from("/tmp")), } } @@ -64,7 +74,13 @@ fn exit_code_for_run_result_prints_error_and_returns_one() { #[rstest] #[case::default_suite_only(InstallArgs::default(), false, true)] #[case::individual_lints( - InstallArgs { individual_lints: true, ..InstallArgs::default() }, + InstallArgs { + lint_selection: LintSelectionFlags { + individual_lints: true, + ..LintSelectionFlags::default() + }, + ..InstallArgs::default() + }, true, false )] @@ -117,13 +133,15 @@ fn ensure_dylint_tools_skips_install_when_installed(test_base_dirs: TestBaseDirs let repository_installer = AlwaysNotFoundRepositoryInstaller; let mut stderr = Vec::new(); - let options = dependency_install_options(&test_base_dirs, &repository_installer, false); - let result = ensure_dylint_tools_with_options(&executor, &mut stderr, options); + let options = dependency_install_options(&test_base_dirs, &repository_installer, false) + .expect("dependency install options should build"); + let result = ensure_dylint_tools_with_options(&executor, &mut stderr, &options); assert!(result.is_ok()); assert!(stderr.is_empty()); executor.assert_finished(); - }); + }) + .expect("prepare fake PATH"); } #[rstest] @@ -162,8 +180,9 @@ fn ensure_dylint_tools_installs_missing_tools( let repository_installer = AlwaysNotFoundRepositoryInstaller; let mut stderr = Vec::new(); - let options = dependency_install_options(&test_base_dirs, &repository_installer, quiet); - let result = ensure_dylint_tools_with_options(&executor, &mut stderr, options); + let options = dependency_install_options(&test_base_dirs, &repository_installer, quiet) + .expect("dependency install options should build"); + let result = ensure_dylint_tools_with_options(&executor, &mut stderr, &options); assert!(result.is_ok()); let stderr_text = String::from_utf8(stderr).expect("stderr was not UTF-8"); @@ -183,7 +202,8 @@ fn ensure_dylint_tools_installs_missing_tools( ); } executor.assert_finished(); - }); + }) + .expect("prepare fake PATH"); } #[rstest] @@ -210,8 +230,9 @@ fn ensure_dylint_tools_propagates_install_failures(test_base_dirs: TestBaseDirs) let repository_installer = AlwaysNotFoundRepositoryInstaller; let mut stderr = Vec::new(); - let options = dependency_install_options(&test_base_dirs, &repository_installer, false); - let err = ensure_dylint_tools_with_options(&executor, &mut stderr, options) + let options = dependency_install_options(&test_base_dirs, &repository_installer, false) + .expect("dependency install options should build"); + let err = ensure_dylint_tools_with_options(&executor, &mut stderr, &options) .expect_err("expected install failure"); assert!(matches!( @@ -221,37 +242,32 @@ fn ensure_dylint_tools_propagates_install_failures(test_base_dirs: TestBaseDirs) && message == "cargo install failed" )); executor.assert_finished(); - }); + }) + .expect("prepare fake PATH"); } #[derive(Debug, Clone)] struct TestBaseDirs { - home_dir: Option, - bin_dir: Option, - data_dir: Option, + home: Option, + bin: Option, + data: Option, } impl BaseDirs for TestBaseDirs { - fn home_dir(&self) -> Option { - self.home_dir.clone() - } + fn home(&self) -> Option { self.home.clone() } - fn bin_dir(&self) -> Option { - self.bin_dir.clone() - } + fn executables(&self) -> Option { self.bin.clone() } - fn whitaker_data_dir(&self) -> Option { - self.data_dir.clone() - } + fn whitaker_data(&self) -> Option { self.data.clone() } } #[test] fn write_install_metrics_prints_summary_and_persists_metrics() { let temp_dir = tempfile::tempdir().expect("create temp dir"); let dirs = TestBaseDirs { - home_dir: Some(temp_dir.path().to_path_buf()), - bin_dir: Some(temp_dir.path().join("bin")), - data_dir: Some(temp_dir.path().to_path_buf()), + home: Some(temp_dir.path().to_path_buf()), + bin: Some(temp_dir.path().join("bin")), + data: Some(temp_dir.path().to_path_buf()), }; let mut stderr = Vec::new(); @@ -272,17 +288,16 @@ fn write_install_metrics_prints_summary_and_persists_metrics() { let metrics_path = temp_dir.path().join("metrics").join("install_metrics.json"); assert!( metrics_path.exists(), - "expected metrics file at {:?}", - metrics_path + "expected metrics file at {metrics_path:?}" ); } #[test] fn write_install_metrics_warns_when_recording_fails() { let dirs = TestBaseDirs { - home_dir: None, - bin_dir: None, - data_dir: None, + home: None, + bin: None, + data: None, }; let mut stderr = Vec::new(); @@ -302,9 +317,9 @@ fn write_install_metrics_warns_when_recording_fails() { fn write_install_metrics_suppresses_output_in_quiet_mode() { let temp_dir = tempfile::tempdir().expect("create temp dir"); let dirs = TestBaseDirs { - home_dir: Some(temp_dir.path().to_path_buf()), - bin_dir: Some(temp_dir.path().join("bin")), - data_dir: Some(temp_dir.path().to_path_buf()), + home: Some(temp_dir.path().to_path_buf()), + bin: Some(temp_dir.path().join("bin")), + data: Some(temp_dir.path().to_path_buf()), }; let mut stderr = Vec::new(); diff --git a/installer/src/toolchain.rs b/installer/src/toolchain/mod.rs similarity index 97% rename from installer/src/toolchain.rs rename to installer/src/toolchain/mod.rs index 9615cd5b..4ed64922 100644 --- a/installer/src/toolchain.rs +++ b/installer/src/toolchain/mod.rs @@ -3,10 +3,12 @@ //! This module provides utilities to detect the pinned Rust toolchain from //! `rust-toolchain.toml` and verify that it is installed via rustup. -use crate::error::{InstallerError, Result}; -use camino::{Utf8Path, Utf8PathBuf}; use std::process::{Command, Output}; +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::error::{InstallerError, Result}; + /// Components required for building dylint lints. /// /// Only these components are installed by the installer, regardless of what @@ -30,9 +32,7 @@ pub struct ToolchainInstallStatus { impl ToolchainInstallStatus { /// Returns true if the toolchain was installed during this run. #[must_use] - pub fn installed_toolchain(&self) -> bool { - self.installed_toolchain - } + pub const fn installed_toolchain(&self) -> bool { self.installed_toolchain } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -161,15 +161,11 @@ impl Toolchain { /// Return the channel string for `cargo +` invocations. #[must_use] - pub fn channel(&self) -> &str { - &self.channel - } + pub fn channel(&self) -> &str { &self.channel } /// Return the workspace root path. #[must_use] - pub fn workspace_root(&self) -> &Utf8Path { - &self.workspace_root - } + pub fn workspace_root(&self) -> &Utf8Path { &self.workspace_root } fn is_installed_with(&self, runner: &dyn CommandRunner) -> Result { let output = run_rustup(runner, &["run", &self.channel, "rustc", "--version"])?; diff --git a/installer/src/toolchain/tests/failure_mocks.rs b/installer/src/toolchain/tests/failure_mocks/mod.rs similarity index 88% rename from installer/src/toolchain/tests/failure_mocks.rs rename to installer/src/toolchain/tests/failure_mocks/mod.rs index f1871f05..db6b56f6 100644 --- a/installer/src/toolchain/tests/failure_mocks.rs +++ b/installer/src/toolchain/tests/failure_mocks/mod.rs @@ -2,8 +2,12 @@ use super::*; use crate::toolchain::tests::test_helpers::{ - ToolchainInstallExpectation, expect_rustc_version, expect_toolchain_install, - matches_multi_component_add, output_with_status, output_with_stderr, + ToolchainInstallExpectation, + expect_rustc_version, + expect_toolchain_install, + matches_multi_component_add, + output_with_status, + output_with_stderr, }; /// Describes the type of installation failure being tested. @@ -35,9 +39,7 @@ pub(super) struct ToolchainChannel<'a>(pub(super) &'a str); impl<'a> ToolchainChannel<'a> { /// Returns the inner channel string slice (e.g. `"nightly-2026-05-28"`). - pub(super) fn as_str(self) -> &'a str { - self.0 - } + pub(super) fn as_str(self) -> &'a str { self.0 } } /// The exact stderr string emitted by the mock when a toolchain installation @@ -62,7 +64,7 @@ fn setup_toolchain_install_failure_mocks( expect_toolchain_install( runner, seq, - ToolchainInstallExpectation { + &ToolchainInstallExpectation { channel, exit_code: 1, stderr: Some(TOOLCHAIN_INSTALL_FAILURE_MESSAGE), @@ -97,7 +99,7 @@ fn setup_toolchain_unusable_failure_mocks( expect_toolchain_install( runner, seq, - ToolchainInstallExpectation { + &ToolchainInstallExpectation { channel, exit_code: 0, stderr: None, @@ -122,8 +124,7 @@ fn setup_toolchain_unusable_failure_mocks( /// /// # Arguments /// -/// - `runner` - Mock command runner that receives the expected `rustup` and -/// `rustc` calls. +/// - `runner` - Mock command runner that receives the expected `rustup` and `rustc` calls. /// - `seq` - Mockall sequence enforcing the order of expected commands. /// - `channel` - Toolchain channel being installed or checked. /// - `setup` - Failure mode and additional components to model. @@ -133,16 +134,16 @@ pub(super) fn setup_failure_mocks( channel: ToolchainChannel<'_>, setup: FailureSetup<'_>, ) { - let channel = channel.as_str(); + let channel_name = channel.as_str(); match setup.failure { InstallFailure::ToolchainInstall => { - setup_toolchain_install_failure_mocks(runner, seq, channel); + setup_toolchain_install_failure_mocks(runner, seq, channel_name); } InstallFailure::ComponentAdd => { setup_component_add_failure_mocks_inner( runner, seq, - channel, + channel_name, setup.additional_components, ); } @@ -150,7 +151,7 @@ pub(super) fn setup_failure_mocks( setup_toolchain_unusable_failure_mocks( runner, seq, - channel, + channel_name, setup.additional_components, ); } @@ -215,47 +216,50 @@ fn is_component_install_failed( /// /// - `err` - Installer error returned by the code under test. /// - `channel` - Toolchain channel expected in the error payload. -/// - `setup` - Failure mode and additional components that define the expected -/// error shape. +/// - `setup` - Failure mode and additional components that define the expected error shape. /// /// # Panics /// /// Panics with a descriptive message if the error variant or its fields do not /// match expectations. pub(super) fn assert_failure_error( - err: InstallerError, + err: &InstallerError, channel: ToolchainChannel<'_>, setup: FailureSetup<'_>, ) { - let channel = channel.as_str(); + let channel_name = channel.as_str(); let failure = failure_summary(setup); match setup.failure { InstallFailure::ToolchainInstall => assert_error_matches( - &err, - &format!("ToolchainInstallFailed for channel {channel} while exercising {failure}"), + err, + &format!( + "ToolchainInstallFailed for channel {channel_name} while exercising {failure}" + ), |e| { matches!( e, InstallerError::ToolchainInstallFailed { toolchain, message } - if toolchain == channel && message == TOOLCHAIN_INSTALL_FAILURE_MESSAGE + if toolchain == channel_name + && message == TOOLCHAIN_INSTALL_FAILURE_MESSAGE ) }, ), InstallFailure::ComponentAdd => assert_error_matches( - &err, + err, &format!( - "ToolchainComponentInstallFailed for channel {channel} while exercising {failure}" + "ToolchainComponentInstallFailed for channel {channel_name} while exercising \ + {failure}" ), - |e| is_component_install_failed(e, channel, setup.additional_components), + |e| is_component_install_failed(e, channel_name, setup.additional_components), ), InstallFailure::ToolchainUnusableAfterInstall => assert_error_matches( - &err, - &format!("ToolchainNotInstalled for channel {channel} while exercising {failure}"), + err, + &format!("ToolchainNotInstalled for channel {channel_name} while exercising {failure}"), |e| { matches!( e, InstallerError::ToolchainNotInstalled { toolchain } - if toolchain == channel + if toolchain == channel_name ) }, ), diff --git a/installer/src/toolchain/tests/mod.rs b/installer/src/toolchain/tests/mod.rs index 5d4b7a8d..322e8ada 100644 --- a/installer/src/toolchain/tests/mod.rs +++ b/installer/src/toolchain/tests/mod.rs @@ -3,18 +3,28 @@ mod failure_mocks; mod test_helpers; -use super::*; use failure_mocks::{ - COMPONENT_INSTALL_FAILURE_MESSAGE, FailureSetup, InstallFailure, ToolchainChannel, - assert_failure_error, setup_failure_mocks, + COMPONENT_INSTALL_FAILURE_MESSAGE, + FailureSetup, + InstallFailure, + ToolchainChannel, + assert_failure_error, + setup_failure_mocks, }; use rstest::rstest; use test_helpers::{ - CapturingCommandRunner, ToolchainInstallExpectation, expect_rustc_version, - expect_toolchain_install, matches_multi_component_add, output_with_status, output_with_stderr, + CapturingCommandRunner, + ToolchainInstallExpectation, + expect_rustc_version, + expect_toolchain_install, + matches_multi_component_add, + output_with_status, + output_with_stderr, test_toolchain, }; +use super::*; + const CRANELIFT_COMPONENT: &str = "rustc-codegen-cranelift"; // Asserts that a parsing function rejects invalid contents with an @@ -61,7 +71,10 @@ fn rejects_invalid_toolchain_file(#[case] contents: &str, #[case] expected_reaso assert_parse_fails_with_reason(contents, expected_reason, parse_toolchain_channel); } -fn run_missing_toolchain_install_test(extra: &[&str], expected_components: &[&str]) { +fn run_missing_toolchain_install_test( + extra: &[&str], + expected_components: &[&str], +) -> Result { let channel = "nightly-2026-05-28"; let toolchain = test_toolchain(channel); let mut runner = MockCommandRunner::new(); @@ -71,7 +84,7 @@ fn run_missing_toolchain_install_test(extra: &[&str], expected_components: &[&st expect_toolchain_install( &mut runner, &mut seq, - ToolchainInstallExpectation { + &ToolchainInstallExpectation { channel, exit_code: 0, stderr: None, @@ -87,11 +100,7 @@ fn run_missing_toolchain_install_test(extra: &[&str], expected_components: &[&st expect_rustc_version(&mut runner, &mut seq, channel, 0); - let status = toolchain - .ensure_installed_with(&runner, extra) - .expect("toolchain should install"); - - assert!(status.installed_toolchain()); + toolchain.ensure_installed_with(&runner, extra) } #[rstest] @@ -107,10 +116,16 @@ fn ensure_installed_installs_missing_toolchain( #[case] extra: Vec<&'static str>, #[case] expected_components: Vec<&'static str>, ) { - run_missing_toolchain_install_test(&extra, &expected_components); + let status = run_missing_toolchain_install_test(&extra, &expected_components) + .expect("toolchain should install"); + + assert!(status.installed_toolchain()); } -fn run_component_installation_test(extra: &[&str], expected: &[&str]) { +fn run_component_installation_test( + extra: &[&str], + expected: &[&str], +) -> Result { let channel = "nightly-2026-05-28"; let toolchain = test_toolchain(channel); let mut runner = MockCommandRunner::new(); @@ -124,11 +139,7 @@ fn run_component_installation_test(extra: &[&str], expected: &[&str]) { .in_sequence(&mut seq) .returning(|_, _| Ok(output_with_status(0))); - let status = toolchain - .ensure_installed_with(&runner, extra) - .expect("toolchain should be ready"); - - assert!(!status.installed_toolchain()); + toolchain.ensure_installed_with(&runner, extra) } #[rstest] @@ -141,7 +152,10 @@ fn ensure_installed_adds_correct_components( #[case] extra: Vec<&'static str>, #[case] expected: Vec<&'static str>, ) { - run_component_installation_test(&extra, &expected); + let status = + run_component_installation_test(&extra, &expected).expect("toolchain should be ready"); + + assert!(!status.installed_toolchain()); } #[test] @@ -177,19 +191,18 @@ fn install_components_with_failure_reports_all_components() { .install_components_with(&runner, &[CRANELIFT_COMPONENT]) .expect_err("component installation should fail"); - assert!( - matches!( - err, - InstallerError::ToolchainComponentInstallFailed { - ref toolchain, - ref components, - ref message, - } if toolchain == "nightly-2026-05-28" - && components == &expected_component_list - && message == COMPONENT_INSTALL_FAILURE_MESSAGE - ), - "expected ToolchainComponentInstallFailed with all components, got {err:?}" - ); + let InstallerError::ToolchainComponentInstallFailed { + toolchain: ref failed_toolchain, + components: ref failed_components, + ref message, + } = err + else { + panic!("expected ToolchainComponentInstallFailed, got {err:?}"); + }; + + assert_eq!(failed_toolchain, "nightly-2026-05-28"); + assert_eq!(failed_components, &expected_component_list); + assert_eq!(message, COMPONENT_INSTALL_FAILURE_MESSAGE); } #[rstest] @@ -216,10 +229,10 @@ fn ensure_installed_reports_failure( }; test_helpers::assert_install_fails_with( - toolchain, + &toolchain, |runner, seq| setup_failure_mocks(runner, seq, channel, setup), - |toolchain, runner| toolchain.ensure_installed_with(runner, additional_components), - |err| assert_failure_error(err, channel, setup), + |candidate, runner| candidate.ensure_installed_with(runner, additional_components), + |err| assert_failure_error(&err, channel, setup), ); } @@ -229,10 +242,7 @@ fn ensure_installed_reports_failure( #[case::trailing_whitespace(Some("some error message \n\n"), "some error message")] #[case::multiline_utf8(Some("line one\nline two\n"), "line one\nline two")] fn stderr_message_extracts_error(#[case] stderr: Option<&str>, #[case] expected: &str) { - let output = match stderr { - Some(s) => output_with_stderr(1, s), - None => output_with_status(1), - }; + let output = stderr.map_or_else(|| output_with_status(1), |s| output_with_stderr(1, s)); assert_eq!(stderr_message(&output), expected); } diff --git a/installer/src/toolchain/tests/test_helpers.rs b/installer/src/toolchain/tests/test_helpers.rs index 2426a3ed..d0f88d5a 100644 --- a/installer/src/toolchain/tests/test_helpers.rs +++ b/installer/src/toolchain/tests/test_helpers.rs @@ -1,8 +1,8 @@ //! Test helpers for toolchain tests. +use std::{cell::RefCell, process::ExitStatus}; + use super::*; -use std::cell::RefCell; -use std::process::ExitStatus; #[cfg(unix)] pub fn exit_status(code: i32) -> ExitStatus { @@ -82,7 +82,7 @@ pub struct RustupExpectation<'a> { fn expect_rustup_command( runner: &mut MockCommandRunner, seq: &mut mockall::Sequence, - expectation: RustupExpectation<'_>, + expectation: &RustupExpectation<'_>, matcher: F, ) where F: Fn(&str, &[&str]) -> bool + Send + 'static, @@ -95,10 +95,10 @@ fn expect_rustup_command( .times(1) .in_sequence(seq) .returning(move |_, _| { - let output = match stderr.as_deref() { - Some(message) => output_with_stderr(exit_code, message), - None => output_with_status(exit_code), - }; + let output = stderr.as_deref().map_or_else( + || output_with_status(exit_code), + |message| output_with_stderr(exit_code, message), + ); Ok(output) }); } @@ -115,16 +115,18 @@ pub fn expect_rustc_version( channel: &str, exit_code: i32, ) { - let channel = channel.to_owned(); + let expected_channel = channel.to_owned(); runner .expect_run() .withf(move |program, args| { + let [run, actual_channel, rustc, version] = args else { + return false; + }; program == "rustup" - && args.len() == 4 - && args[0] == "run" - && args[1] == channel - && args[2] == "rustc" - && args[3] == "--version" + && *run == "run" + && *actual_channel == expected_channel + && *rustc == "rustc" + && *version == "--version" }) .times(1) .in_sequence(seq) @@ -134,29 +136,31 @@ pub fn expect_rustc_version( pub fn expect_toolchain_install( runner: &mut MockCommandRunner, seq: &mut mockall::Sequence, - expectation: ToolchainInstallExpectation<'_>, + expectation: &ToolchainInstallExpectation<'_>, ) { - let channel = expectation.channel.to_owned(); + let expected_channel = expectation.channel.to_owned(); expect_rustup_command( runner, seq, - RustupExpectation { + &RustupExpectation { exit_code: expectation.exit_code, stderr: expectation.stderr, }, move |program, args| { + let [toolchain, install, actual_channel] = args else { + return false; + }; program == "rustup" - && args.len() == 3 - && args[0] == "toolchain" - && args[1] == "install" - && args[2] == channel + && *toolchain == "toolchain" + && *install == "install" + && *actual_channel == expected_channel }, ); } -/// Helper to test that ensure_installed fails with the expected error. +/// Helper to test that `ensure_installed` fails with the expected error. pub fn assert_install_fails_with( - toolchain: Toolchain, + toolchain: &Toolchain, setup_mocks: F, install: I, error_matcher: E, @@ -170,7 +174,7 @@ pub fn assert_install_fails_with( setup_mocks(&mut runner, &mut seq); - let err = install(&toolchain, &runner).expect_err("expected installation failure"); + let err = install(toolchain, &runner).expect_err("expected installation failure"); error_matcher(err); } @@ -180,21 +184,23 @@ pub fn matches_multi_component_add( channel: &str, components: &[&str], ) -> impl Fn(&str, &[&str]) -> bool + use<> { - let channel = channel.to_owned(); - let components: Vec = components.iter().map(|s| (*s).to_owned()).collect(); + let expected_channel = channel.to_owned(); + let expected_components: Vec = components.iter().map(|s| (*s).to_owned()).collect(); move |program, args| { - let Some(actual_components) = args.get(4..) else { + let Some((&[component, add, toolchain_flag, actual_channel], actual_components)) = + args.split_at_checked(4) + else { return false; }; program == "rustup" - && args[0] == "component" - && args[1] == "add" - && args[2] == "--toolchain" - && args[3] == channel - && actual_components.len() == components.len() + && component == "component" + && add == "add" + && toolchain_flag == "--toolchain" + && actual_channel == expected_channel + && actual_components.len() == expected_components.len() && actual_components .iter() - .zip(&components) + .zip(&expected_components) .all(|(a, b)| *a == b) } } diff --git a/installer/src/version.rs b/installer/src/version.rs index 8208b998..787ac7e3 100644 --- a/installer/src/version.rs +++ b/installer/src/version.rs @@ -15,49 +15,37 @@ pub struct Version(String); impl Version { /// Create a new [`Version`] from any string-like value. #[must_use] - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } + pub fn new(value: impl Into) -> Self { Self(value.into()) } /// Borrow the underlying version string. #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper and return the inner string. #[must_use] - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl AsRef for Version { - fn as_ref(&self) -> &str { - &self.0 - } + fn as_ref(&self) -> &str { &self.0 } } impl From<&str> for Version { - fn from(value: &str) -> Self { - Self(value.to_owned()) - } + fn from(value: &str) -> Self { Self(value.to_owned()) } } impl From for Version { - fn from(value: String) -> Self { - Self(value) - } + fn from(value: String) -> Self { Self(value) } } impl fmt::Display for Version { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } #[cfg(test)] mod tests { + //! Tests for installer version reporting. + use super::*; #[test] diff --git a/installer/src/workspace.rs b/installer/src/workspace.rs index 3d06bbf5..1b16bb7a 100644 --- a/installer/src/workspace.rs +++ b/installer/src/workspace.rs @@ -3,10 +3,13 @@ //! This module provides utilities for detecting whether the current directory //! is a Whitaker workspace and for resolving platform-specific clone locations. -use crate::dirs::BaseDirs; -use crate::error::{InstallerError, Result}; use camino::{Utf8Path, Utf8PathBuf}; +use crate::{ + dirs::BaseDirs, + error::{InstallerError, Result}, +}; + /// Repository URL for cloning Whitaker. pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker"; @@ -29,6 +32,7 @@ const WHITAKER_PACKAGE_NAME: &str = "whitaker"; /// println!("This is a Whitaker workspace"); /// } /// ``` +#[must_use] pub fn is_whitaker_workspace(dir: &Utf8Path) -> bool { let cargo_toml = dir.join("Cargo.toml"); if !cargo_toml.exists() { @@ -57,7 +61,7 @@ pub fn is_whitaker_workspace(dir: &Utf8Path) -> bool { /// /// Returns `None` if the platform's data directory cannot be determined. pub fn clone_directory(dirs: &dyn BaseDirs) -> Option { - dirs.whitaker_data_dir() + dirs.whitaker_data() .and_then(|p| Utf8PathBuf::try_from(p).ok()) } @@ -80,6 +84,7 @@ pub enum WorkspaceAction { /// operation (if any) is needed. Returns `UseCurrentDir` if `cwd` is a /// Whitaker workspace, `CloneTo` if `clone_dir` doesn't exist, `UpdateAt` /// if `update` is true and the clone exists, or `UseExisting` otherwise. +#[must_use] pub fn decide_workspace_action( cwd: &Utf8Path, clone_dir: &Utf8Path, @@ -132,6 +137,11 @@ pub fn ensure_workspace(dirs: &dyn BaseDirs, update: bool) -> Result Result { let cwd = current_dir_utf8()?; @@ -195,192 +205,5 @@ fn is_cargo_workspace_root(cargo_toml: &Utf8Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::dirs::{MockBaseDirs, SystemBaseDirs}; - use rstest::{fixture, rstest}; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - /// A temporary directory converted to a UTF-8 path for workspace tests. - struct TempWorkspace { - _temp: TempDir, - path: Utf8PathBuf, - } - - #[fixture] - fn temp_workspace() -> TempWorkspace { - let temp = TempDir::new().expect("failed to create temp dir"); - let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path"); - TempWorkspace { _temp: temp, path } - } - - fn write_cargo_toml(dir: &Utf8Path, package_name: &str) { - let cargo_toml = dir.join("Cargo.toml"); - fs::write( - cargo_toml, - format!("[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\n"), - ) - .expect("failed to write Cargo.toml"); - } - - #[rstest] - #[case::whitaker_project(Some("whitaker"), true)] - #[case::other_project(Some("other-project"), false)] - #[case::empty_dir(None, false)] - fn is_whitaker_workspace_detection( - temp_workspace: TempWorkspace, - #[case] package_name: Option<&str>, - #[case] expected: bool, - ) { - if let Some(name) = package_name { - write_cargo_toml(&temp_workspace.path, name); - } - assert_eq!(is_whitaker_workspace(&temp_workspace.path), expected); - } - - #[test] - fn clone_directory_returns_some_on_supported_platforms() { - // This test may fail on unsupported platforms, but should pass on - // Linux, macOS, and Windows. - let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); - let dir = clone_directory(&dirs); - assert!(dir.is_some(), "expected clone_directory to return Some"); - assert!( - dir.as_ref() - .is_some_and(|p| p.as_str().contains("whitaker")), - "expected path to contain 'whitaker'" - ); - } - - #[rstest] - fn decide_workspace_action_uses_cwd_when_whitaker(temp_workspace: TempWorkspace) { - write_cargo_toml(&temp_workspace.path, "whitaker"); - let clone_dir = Utf8PathBuf::from("/nonexistent/clone/dir"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); - - assert_eq!(action, WorkspaceAction::UseCurrentDir(temp_workspace.path)); - } - - #[rstest] - fn decide_workspace_action_clones_when_empty(temp_workspace: TempWorkspace) { - // temp_workspace.path is empty (no Cargo.toml), clone_dir doesn't exist - let clone_dir = temp_workspace.path.join("clone_target"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); - - assert_eq!(action, WorkspaceAction::CloneTo(clone_dir)); - } - - #[rstest] - fn decide_workspace_action_updates_when_clone_exists(temp_workspace: TempWorkspace) { - // Create a clone directory (not a whitaker workspace, just exists) - let clone_dir = temp_workspace.path.join("clone_target"); - fs::create_dir(&clone_dir).expect("failed to create clone dir"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); - - assert_eq!(action, WorkspaceAction::UpdateAt(clone_dir)); - } - - #[rstest] - fn decide_workspace_action_uses_existing_when_no_update(temp_workspace: TempWorkspace) { - let clone_dir = temp_workspace.path.join("clone_target"); - fs::create_dir(&clone_dir).expect("failed to create clone dir"); - - let action = decide_workspace_action(&temp_workspace.path, &clone_dir, false); - - assert_eq!(action, WorkspaceAction::UseExisting(clone_dir)); - } - - // ------------------------------------------------------------------------- - // Behavioural tests for workspace orchestration with mocked dependencies - // ------------------------------------------------------------------------- - - fn mock_dirs_returning(data_dir: Option) -> MockBaseDirs { - let mut mock = MockBaseDirs::new(); - mock.expect_whitaker_data_dir().return_const(data_dir); - mock - } - - #[rstest] - fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace( - temp_workspace: TempWorkspace, - ) { - // Mock returns a data directory inside temp workspace - let expected_dir = temp_workspace.path.join("data").join("whitaker"); - let mock = mock_dirs_returning(Some(expected_dir.clone().into_std_path_buf())); - - let result = resolve_workspace_path(&mock); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), expected_dir); - } - - #[rstest] - fn resolve_workspace_path_errors_when_data_dir_unavailable(temp_workspace: TempWorkspace) { - let _ = temp_workspace; // Ensure fixture is used - let mock = mock_dirs_returning(None); - - let result = resolve_workspace_path(&mock); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - matches!(err, InstallerError::WorkspaceNotFound { .. }), - "expected WorkspaceNotFound error, got: {err:?}" - ); - } - - #[test] - fn clone_directory_returns_none_when_data_dir_unavailable() { - let mock = mock_dirs_returning(None); - assert!(clone_directory(&mock).is_none()); - } - - #[rstest] - fn clone_directory_returns_path_from_mock(temp_workspace: TempWorkspace) { - let expected = temp_workspace.path.join("data").join("whitaker"); - let mock = mock_dirs_returning(Some(expected.clone().into_std_path_buf())); - assert_eq!(clone_directory(&mock), Some(expected)); - } - - // Tests for find_workspace_root - - fn write_workspace_cargo_toml(dir: &Utf8Path) { - fs::write( - dir.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]\n", - ) - .expect("failed to write workspace Cargo.toml"); - } - - #[rstest] - fn find_workspace_root_finds_workspace_in_current_dir(temp_workspace: TempWorkspace) { - write_workspace_cargo_toml(&temp_workspace.path); - assert_eq!( - find_workspace_root(&temp_workspace.path).unwrap(), - temp_workspace.path - ); - } - - #[rstest] - fn find_workspace_root_finds_workspace_in_parent_dir(temp_workspace: TempWorkspace) { - write_workspace_cargo_toml(&temp_workspace.path); - let subdir = temp_workspace.path.join("crates").join("my_crate"); - fs::create_dir_all(&subdir).expect("failed to create subdirs"); - assert_eq!(find_workspace_root(&subdir).unwrap(), temp_workspace.path); - } - - #[rstest] - fn find_workspace_root_errors_when_no_workspace_found(temp_workspace: TempWorkspace) { - write_cargo_toml(&temp_workspace.path, "not_a_workspace"); - let result = find_workspace_root(&temp_workspace.path); - assert!(matches!( - result.unwrap_err(), - InstallerError::WorkspaceNotFound { .. } - )); - } -} +#[path = "workspace_tests.rs"] +mod tests; diff --git a/installer/src/workspace_tests.rs b/installer/src/workspace_tests.rs new file mode 100644 index 00000000..33cdafc0 --- /dev/null +++ b/installer/src/workspace_tests.rs @@ -0,0 +1,231 @@ +//! Tests for workspace discovery and layout. + +use std::{fs, path::PathBuf}; + +use rstest::{fixture, rstest}; +use tempfile::TempDir; + +use super::*; +use crate::dirs::{MockBaseDirs, SystemBaseDirs}; + +/// A temporary directory converted to a UTF-8 path for workspace tests. +struct TempWorkspace { + _temp: TempDir, + path: Utf8PathBuf, +} + +#[fixture] +fn temp_workspace() -> std::io::Result { + let temp = TempDir::new()?; + let path = Utf8PathBuf::try_from(temp.path().to_owned()) + .map_err(|_| std::io::Error::other("temporary directory path must be UTF-8"))?; + Ok(TempWorkspace { _temp: temp, path }) +} + +fn write_cargo_toml(dir: &Utf8Path, package_name: &str) -> std::io::Result<()> { + let cargo_toml = dir.join("Cargo.toml"); + fs::write( + cargo_toml, + format!("[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\n"), + ) +} + +#[rstest] +#[case::whitaker_project(Some("whitaker"), true)] +#[case::other_project(Some("other-project"), false)] +#[case::empty_dir(None, false)] +fn is_whitaker_workspace_detection( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, + #[case] package_name: Option<&str>, + #[case] expected: bool, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + if let Some(name) = package_name { + write_cargo_toml(&temp_workspace.path, name).expect("Cargo.toml should be written"); + } + assert_eq!(is_whitaker_workspace(&temp_workspace.path), expected); +} + +#[test] +fn clone_directory_returns_some_on_supported_platforms() { + // This test may fail on unsupported platforms, but should pass on + // Linux, macOS, and Windows. + let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs"); + let dir = clone_directory(&dirs); + assert!(dir.is_some(), "expected clone_directory to return Some"); + assert!( + dir.as_ref() + .is_some_and(|p| p.as_str().contains("whitaker")), + "expected path to contain 'whitaker'" + ); +} + +#[rstest] +fn decide_workspace_action_uses_cwd_when_whitaker( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + write_cargo_toml(&temp_workspace.path, "whitaker").expect("Cargo.toml should be written"); + let clone_dir = Utf8PathBuf::from("/nonexistent/clone/dir"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); + + assert_eq!(action, WorkspaceAction::UseCurrentDir(temp_workspace.path)); +} + +#[rstest] +fn decide_workspace_action_clones_when_empty( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + // temp_workspace.path is empty (no Cargo.toml), clone_dir doesn't exist + let clone_dir = temp_workspace.path.join("clone_target"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); + + assert_eq!(action, WorkspaceAction::CloneTo(clone_dir)); +} + +#[rstest] +fn decide_workspace_action_updates_when_clone_exists( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + // Create a clone directory (not a whitaker workspace, just exists) + let clone_dir = temp_workspace.path.join("clone_target"); + fs::create_dir(&clone_dir).expect("failed to create clone dir"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true); + + assert_eq!(action, WorkspaceAction::UpdateAt(clone_dir)); +} + +#[rstest] +fn decide_workspace_action_uses_existing_when_no_update( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + let clone_dir = temp_workspace.path.join("clone_target"); + fs::create_dir(&clone_dir).expect("failed to create clone dir"); + + let action = decide_workspace_action(&temp_workspace.path, &clone_dir, false); + + assert_eq!(action, WorkspaceAction::UseExisting(clone_dir)); +} + +// ------------------------------------------------------------------------- +// Behavioural tests for workspace orchestration with mocked dependencies +// ------------------------------------------------------------------------- + +fn mock_dirs_returning(data_dir: Option) -> MockBaseDirs { + let mut mock = MockBaseDirs::new(); + mock.expect_whitaker_data().return_const(data_dir); + mock +} + +#[rstest] +fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + // Mock returns a data directory inside temp workspace + let expected_dir = temp_workspace.path.join("data").join("whitaker"); + let mock = mock_dirs_returning(Some(expected_dir.clone().into_std_path_buf())); + + let result = resolve_workspace_path(&mock); + + assert_eq!(result.expect("workspace path should resolve"), expected_dir); +} + +#[rstest] +fn resolve_workspace_path_errors_when_data_dir_unavailable( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + let _ = temp_workspace; // Ensure fixture is used + let mock = mock_dirs_returning(None); + + let result = resolve_workspace_path(&mock); + + let err = result.expect_err("resolution should fail without a data directory"); + assert!( + matches!(err, InstallerError::WorkspaceNotFound { .. }), + "expected WorkspaceNotFound error, got: {err:?}" + ); +} + +#[test] +fn clone_directory_returns_none_when_data_dir_unavailable() { + let mock = mock_dirs_returning(None); + assert!(clone_directory(&mock).is_none()); +} + +#[rstest] +fn clone_directory_returns_path_from_mock( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + let expected = temp_workspace.path.join("data").join("whitaker"); + let mock = mock_dirs_returning(Some(expected.clone().into_std_path_buf())); + assert_eq!(clone_directory(&mock), Some(expected)); +} + +// Tests for find_workspace_root + +fn write_workspace_cargo_toml(dir: &Utf8Path) -> std::io::Result<()> { + fs::write( + dir.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\n", + ) +} + +#[rstest] +fn find_workspace_root_finds_workspace_in_current_dir( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + write_workspace_cargo_toml(&temp_workspace.path).expect("Cargo.toml should be written"); + assert_eq!( + find_workspace_root(&temp_workspace.path).expect("workspace root should be found"), + temp_workspace.path + ); +} + +#[rstest] +fn find_workspace_root_finds_workspace_in_parent_dir( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + write_workspace_cargo_toml(&temp_workspace.path).expect("Cargo.toml should be written"); + let subdir = temp_workspace.path.join("crates").join("my_crate"); + fs::create_dir_all(&subdir).expect("failed to create subdirs"); + assert_eq!( + find_workspace_root(&subdir).expect("workspace root should be found"), + temp_workspace.path + ); +} + +#[rstest] +fn find_workspace_root_errors_when_no_workspace_found( + #[from(temp_workspace)] temp_workspace_res: std::io::Result, +) { + let temp_workspace = temp_workspace_res.expect("temporary workspace should be created"); + + write_cargo_toml(&temp_workspace.path, "not_a_workspace") + .expect("Cargo.toml should be written"); + let result = find_workspace_root(&temp_workspace.path); + assert!(matches!( + result.expect_err("workspace lookup should fail"), + InstallerError::WorkspaceNotFound { .. } + )); +} diff --git a/installer/src/wrapper.rs b/installer/src/wrapper.rs index 8e9a190a..cc074a11 100644 --- a/installer/src/wrapper.rs +++ b/installer/src/wrapper.rs @@ -4,12 +4,16 @@ //! that set the `DYLINT_LIBRARY_PATH` environment variable and invoke //! `cargo dylint`. -use crate::dirs::BaseDirs; -use crate::error::{InstallerError, Result}; -use crate::resolution::SUITE_CRATE; -use camino::Utf8Path; use std::path::Path; +use camino::Utf8Path; + +use crate::{ + dirs::BaseDirs, + error::{InstallerError, Result}, + resolution::SUITE_CRATE, +}; + /// Result of wrapper script generation. #[derive(Debug)] pub struct WrapperResult { @@ -44,8 +48,10 @@ pub struct WrapperResult { /// /// ```no_run /// use camino::Utf8Path; -/// use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; -/// use whitaker_installer::wrapper::generate_wrapper_scripts; +/// use whitaker_installer::{ +/// dirs::{BaseDirs, SystemBaseDirs}, +/// wrapper::generate_wrapper_scripts, +/// }; /// /// let dirs = SystemBaseDirs::new().expect("failed to initialize directories"); /// let library_path = Utf8Path::new("/home/user/.local/share/dylint/lib"); @@ -63,7 +69,7 @@ pub fn generate_wrapper_scripts( dirs: &dyn BaseDirs, library_path: &Utf8Path, ) -> Result { - let bin_dir = dirs.bin_dir().ok_or_else(|| { + let bin_dir = dirs.executables().ok_or_else(|| { InstallerError::WrapperGeneration("could not determine bin directory".to_owned()) })?; @@ -111,9 +117,8 @@ exec cargo dylint "$@" r#"#!/usr/bin/env bash set -euo pipefail export DYLINT_LIBRARY_PATH="{library_path}" -cargo dylint list --color never | awk -v suite="{suite_crate}" '$0 ~ "^" suite "([[:space:]]|$)" {{ print }}' -"#, - suite_crate = SUITE_CRATE, +cargo dylint list --color never | awk -v suite="{SUITE_CRATE}" '$0 ~ "^" suite "([[:space:]]|$)" {{ print }}' +"# ); write_unix_script(&whitaker_ls_path, &whitaker_ls_content)?; @@ -158,12 +163,11 @@ cargo dylint @args let whitaker_ls_path = bin_dir.join("whitaker-ls.ps1"); let whitaker_ls_content = format!( r#"$env:DYLINT_LIBRARY_PATH = "{library_path}" -$suite = "{suite_crate}" +$suite = "{SUITE_CRATE}" cargo dylint list --color never | Where-Object {{ $_ -match ("^\\s*" + [regex]::Escape($suite) + "(\\s|$)") }} -"#, - suite_crate = SUITE_CRATE, +"# ); std::fs::write(&whitaker_ls_path, whitaker_ls_content) @@ -174,12 +178,11 @@ cargo dylint list --color never | Where-Object {{ /// Checks if a directory is in the PATH environment variable. fn is_directory_in_path(dir: &Path) -> bool { - std::env::var_os("PATH") - .map(|path| std::env::split_paths(&path).any(|p| p == dir)) - .unwrap_or(false) + std::env::var_os("PATH").is_some_and(|path| std::env::split_paths(&path).any(|p| p == dir)) } /// Returns instructions for adding a directory to PATH. +#[must_use] pub fn path_instructions(bin_dir: &Path) -> String { #[cfg(unix)] { @@ -213,20 +216,56 @@ pub fn path_instructions(bin_dir: &Path) -> String { #[cfg(test)] mod tests { - use super::*; + //! Tests for the generated cargo-whitaker wrapper script. + use tempfile::TempDir; + use super::*; + #[test] fn is_directory_in_path_returns_false_for_random_dir() { let temp = TempDir::new().expect("failed to create temp dir"); assert!(!is_directory_in_path(temp.path())); } + /// Asserts that every user, group, and other execute bit is set on `$path`. + /// + /// Expressed as a macro so failures point at the calling test, and so the + /// fallible metadata read stays inside the test body. + #[cfg(unix)] + macro_rules! assert_script_is_executable { + ($path:expr) => {{ + use std::os::unix::fs::PermissionsExt as _; + + let metadata = std::fs::metadata($path).expect("script metadata should be readable"); + assert_eq!( + metadata.permissions().mode() & 0o111, + 0o111, + "script should be executable" + ); + }}; + } + + /// Asserts that the script at `$path` contains every fragment in `$fragments`. + #[cfg(unix)] + macro_rules! assert_script_contains { + ($path:expr, $fragments:expr) => {{ + let path: &Path = $path; + let content = std::fs::read_to_string(path).expect("script should be readable"); + for fragment in $fragments { + assert!( + content.contains(fragment), + "script {} should contain {fragment}", + path.display() + ); + } + }}; + } + #[cfg(unix)] #[test] fn generate_unix_scripts_create_executables() { use camino::Utf8PathBuf; - use std::os::unix::fs::PermissionsExt; let temp = TempDir::new().expect("failed to create temp dir"); let library_path = Utf8PathBuf::from("/tmp/dylint/lib"); @@ -236,23 +275,15 @@ mod tests { assert!(whitaker_path.exists()); assert!(whitaker_ls_path.exists()); - - let perms = std::fs::metadata(&whitaker_path) - .expect("failed to read metadata") - .permissions(); - assert_eq!(perms.mode() & 0o111, 0o111, "script should be executable"); - - let whitaker_content = - std::fs::read_to_string(&whitaker_path).expect("failed to read script"); - assert!(whitaker_content.contains("DYLINT_LIBRARY_PATH")); - assert!(whitaker_content.contains("cargo dylint")); - assert!(whitaker_content.contains("$@")); - - let whitaker_ls_content = - std::fs::read_to_string(&whitaker_ls_path).expect("failed to read script"); - assert!(whitaker_ls_content.contains("DYLINT_LIBRARY_PATH")); - assert!(whitaker_ls_content.contains("cargo dylint list")); - assert!(whitaker_ls_content.contains("whitaker_suite")); + assert_script_is_executable!(&whitaker_path); + assert_script_contains!( + &whitaker_path, + &["DYLINT_LIBRARY_PATH", "cargo dylint", "$@"] + ); + assert_script_contains!( + &whitaker_ls_path, + &["DYLINT_LIBRARY_PATH", "cargo dylint list", "whitaker_suite"] + ); } #[test] diff --git a/installer/tests/behaviour_artefact.rs b/installer/tests/behaviour_artefact.rs index 49266449..885acf51 100644 --- a/installer/tests/behaviour_artefact.rs +++ b/installer/tests/behaviour_artefact.rs @@ -3,21 +3,21 @@ //! //! These scenarios validate the domain types defined in the `artefact` module //! against the rules specified in ADR-001. Tests use the rstest-bdd v0.5.0 -//! mutable world pattern. +//! mutable world pattern with fallible steps. use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use whitaker_installer::artefact::error::ArtefactError; -use whitaker_installer::artefact::git_sha::GitSha; -use whitaker_installer::artefact::manifest::{ - GeneratedAt, Manifest, ManifestContent, ManifestProvenance, +use whitaker_installer::artefact::{ + error::ArtefactError, + git_sha::GitSha, + manifest::{GeneratedAt, Manifest, ManifestContent, ManifestProvenance}, + naming::ArtefactName, + schema_version::SchemaVersion, + sha256_digest::Sha256Digest, + target::TargetTriple, + toolchain_channel::ToolchainChannel, + verification::{VerificationFailureAction, VerificationPolicy}, }; -use whitaker_installer::artefact::naming::ArtefactName; -use whitaker_installer::artefact::schema_version::SchemaVersion; -use whitaker_installer::artefact::sha256_digest::Sha256Digest; -use whitaker_installer::artefact::target::TargetTriple; -use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; -use whitaker_installer::artefact::verification::{VerificationFailureAction, VerificationPolicy}; // --------------------------------------------------------------------------- // World types @@ -38,26 +38,38 @@ struct ArtefactWorld { all_triples_ok: Option, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> ArtefactWorld { - ArtefactWorld::default() +fn world() -> ArtefactWorld { ArtefactWorld::default() } + +/// Check that an error option contains a specific `ArtefactError` variant. +fn ensure_error_matches( + error: Option<&ArtefactError>, + field_name: &str, + predicate: F, +) -> Result<(), String> +where + F: FnOnce(&ArtefactError) -> bool, +{ + let observed = error.ok_or_else(|| format!("expected {field_name} validation to fail"))?; + if predicate(observed) { + Ok(()) + } else { + Err(format!("error variant mismatch for {field_name}")) + } } -/// Helper to assert that an error option contains a specific ArtefactError variant. -fn assert_error_matches(error: &Option, field_name: &str, predicate: F) +/// Compare two values for equality, reporting a mismatch as an error. +fn ensure_eq(actual: &T, expected: &U, context: &str) -> Result<(), String> where - F: FnOnce(&ArtefactError) -> bool, + T: PartialEq + std::fmt::Debug + ?Sized, + U: std::fmt::Debug + ?Sized, { - assert!( - error.is_some(), - "expected {} validation to fail", - field_name - ); - assert!( - predicate(error.as_ref().expect("checked above")), - "error variant mismatch for {}", - field_name - ); + if actual == expected { + Ok(()) + } else { + Err(format!("{context}: expected {expected:?}, got {actual:?}")) + } } // --------------------------------------------------------------------------- @@ -65,32 +77,49 @@ where // --------------------------------------------------------------------------- #[given("a git SHA \"{sha}\"")] -fn given_git_sha(world: &mut ArtefactWorld, sha: String) { - world.git_sha = Some(GitSha::try_from(sha).expect("test SHA")); +fn given_git_sha(world: &mut ArtefactWorld, sha: String) -> Result<(), String> { + world.git_sha = Some(GitSha::try_from(sha).map_err(|e| format!("test SHA: {e}"))?); + Ok(()) } #[given("a toolchain channel \"{channel}\"")] -fn given_toolchain_channel(world: &mut ArtefactWorld, channel: String) { - world.toolchain = Some(ToolchainChannel::try_from(channel).expect("test channel")); +fn given_toolchain_channel(world: &mut ArtefactWorld, channel: String) -> Result<(), String> { + world.toolchain = + Some(ToolchainChannel::try_from(channel).map_err(|e| format!("test channel: {e}"))?); + Ok(()) } #[given("a target triple \"{triple}\"")] -fn given_target_triple(world: &mut ArtefactWorld, triple: String) { - world.target = Some(TargetTriple::try_from(triple).expect("test triple")); +fn given_target_triple(world: &mut ArtefactWorld, triple: String) -> Result<(), String> { + world.target = Some(TargetTriple::try_from(triple).map_err(|e| format!("test triple: {e}"))?); + Ok(()) } #[when("an artefact name is constructed")] -fn when_artefact_name_constructed(world: &mut ArtefactWorld) { - let sha = world.git_sha.clone().expect("git_sha set"); - let ch = world.toolchain.clone().expect("toolchain set"); - let tgt = world.target.clone().expect("target set"); +fn when_artefact_name_constructed(world: &mut ArtefactWorld) -> Result<(), String> { + let sha = world + .git_sha + .clone() + .ok_or_else(|| String::from("git_sha set"))?; + let ch = world + .toolchain + .clone() + .ok_or_else(|| String::from("toolchain set"))?; + let tgt = world + .target + .clone() + .ok_or_else(|| String::from("target set"))?; world.artefact_name = Some(ArtefactName::new(sha, ch, tgt)); + Ok(()) } #[then("the filename is \"{expected}\"")] -fn then_filename_matches(world: &mut ArtefactWorld, expected: String) { - let name = world.artefact_name.as_ref().expect("artefact_name set"); - assert_eq!(name.filename(), expected); +fn then_filename_matches(world: &mut ArtefactWorld, expected: String) -> Result<(), String> { + let name = world + .artefact_name + .as_ref() + .ok_or_else(|| String::from("artefact_name set"))?; + ensure_eq(&name.filename(), &expected, "artefact filename") } #[given("an invalid target triple \"{triple}\"")] @@ -99,10 +128,10 @@ fn given_invalid_target(world: &mut ArtefactWorld, triple: String) { } #[then("the target triple is rejected")] -fn then_target_rejected(world: &mut ArtefactWorld) { - assert_error_matches(&world.target_error, "target", |e| { +fn then_target_rejected(world: &mut ArtefactWorld) -> Result<(), String> { + ensure_error_matches(world.target_error.as_ref(), "target", |e| { matches!(e, ArtefactError::UnsupportedTarget { .. }) - }); + }) } #[given("all supported target triples")] @@ -114,8 +143,8 @@ fn given_all_supported(world: &mut ArtefactWorld) { } #[then("every triple is accepted")] -fn then_all_accepted(world: &mut ArtefactWorld) { - assert_eq!(world.all_triples_ok, Some(true)); +fn then_all_accepted(world: &mut ArtefactWorld) -> Result<(), String> { + ensure_eq(&world.all_triples_ok, &Some(true), "all triples accepted") } #[given("an invalid git SHA \"{sha}\"")] @@ -124,10 +153,10 @@ fn given_invalid_sha(world: &mut ArtefactWorld, sha: String) { } #[then("the git SHA is rejected")] -fn then_sha_rejected(world: &mut ArtefactWorld) { - assert_error_matches(&world.sha_error, "SHA", |e| { +fn then_sha_rejected(world: &mut ArtefactWorld) -> Result<(), String> { + ensure_error_matches(world.sha_error.as_ref(), "SHA", |e| { matches!(e, ArtefactError::InvalidGitSha { .. }) - }); + }) } #[given("an empty toolchain channel")] @@ -136,47 +165,71 @@ fn given_empty_channel(world: &mut ArtefactWorld) { } #[then("the toolchain channel is rejected")] -fn then_channel_rejected(world: &mut ArtefactWorld) { - assert_error_matches(&world.channel_error, "channel", |e| { +fn then_channel_rejected(world: &mut ArtefactWorld) -> Result<(), String> { + ensure_error_matches(world.channel_error.as_ref(), "channel", |e| { matches!(e, ArtefactError::InvalidToolchainChannel { .. }) - }); + }) } #[given("a complete set of manifest fields")] -fn given_manifest_fields(world: &mut ArtefactWorld) { - world.git_sha = Some(GitSha::try_from("abc1234").expect("valid sha")); - world.toolchain = - Some(ToolchainChannel::try_from("nightly-2026-05-28").expect("valid channel")); - world.target = Some(TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target")); +fn given_manifest_fields(world: &mut ArtefactWorld) -> Result<(), String> { + world.git_sha = Some(GitSha::try_from("abc1234").map_err(|e| format!("valid sha: {e}"))?); + world.toolchain = Some( + ToolchainChannel::try_from("nightly-2026-05-28") + .map_err(|e| format!("valid channel: {e}"))?, + ); + world.target = Some( + TargetTriple::try_from("x86_64-unknown-linux-gnu") + .map_err(|e| format!("valid target: {e}"))?, + ); + Ok(()) } #[when("a manifest is constructed")] -fn when_manifest_constructed(world: &mut ArtefactWorld) { +fn when_manifest_constructed(world: &mut ArtefactWorld) -> Result<(), String> { let provenance = ManifestProvenance { - git_sha: world.git_sha.clone().expect("git_sha set"), + git_sha: world + .git_sha + .clone() + .ok_or_else(|| String::from("git_sha set"))?, schema_version: SchemaVersion::current(), - toolchain: world.toolchain.clone().expect("toolchain set"), - target: world.target.clone().expect("target set"), + toolchain: world + .toolchain + .clone() + .ok_or_else(|| String::from("toolchain set"))?, + target: world + .target + .clone() + .ok_or_else(|| String::from("target set"))?, }; let digest_hex = "a".repeat(64); let content = ManifestContent { generated_at: GeneratedAt::new("2026-05-28T00:00:00Z"), files: vec!["libwhitaker_lints.so".to_owned()], - sha256: Sha256Digest::try_from(digest_hex.as_str()).expect("valid digest"), + sha256: Sha256Digest::try_from(digest_hex.as_str()) + .map_err(|e| format!("valid digest: {e}"))?, }; world.manifest = Some(Manifest::new(provenance, content)); + Ok(()) } #[then("all manifest fields are accessible")] -fn then_manifest_accessible(world: &mut ArtefactWorld) { - let m = world.manifest.as_ref().expect("manifest set"); - assert_eq!(m.git_sha().as_str(), "abc1234"); - assert_eq!(m.schema_version().as_u32(), 1); - assert_eq!(m.toolchain().as_str(), "nightly-2026-05-28"); - assert_eq!(m.target().as_str(), "x86_64-unknown-linux-gnu"); - assert_eq!(m.generated_at().as_str(), "2026-05-28T00:00:00Z"); - assert_eq!(m.files().len(), 1); - assert_eq!(m.sha256().as_str().len(), 64); +fn then_manifest_accessible(world: &mut ArtefactWorld) -> Result<(), String> { + let m = world + .manifest + .as_ref() + .ok_or_else(|| String::from("manifest set"))?; + ensure_eq(m.git_sha().as_str(), "abc1234", "git sha")?; + ensure_eq(&m.schema_version().as_u32(), &1, "schema version")?; + ensure_eq(m.toolchain().as_str(), "nightly-2026-05-28", "toolchain")?; + ensure_eq(m.target().as_str(), "x86_64-unknown-linux-gnu", "target")?; + ensure_eq( + m.generated_at().as_str(), + "2026-05-28T00:00:00Z", + "generated at", + )?; + ensure_eq(&m.files().len(), &1, "file count")?; + ensure_eq(&m.sha256().as_str().len(), &64, "sha256 length") } #[given("the default verification policy")] @@ -185,9 +238,16 @@ fn given_default_policy(world: &mut ArtefactWorld) { } #[then("checksum verification is required")] -fn then_checksum_required(world: &mut ArtefactWorld) { - let policy = world.policy.as_ref().expect("policy set"); - assert!(policy.require_checksum()); +fn then_checksum_required(world: &mut ArtefactWorld) -> Result<(), String> { + let policy = world + .policy + .as_ref() + .ok_or_else(|| String::from("policy set"))?; + if policy.require_checksum() { + Ok(()) + } else { + Err(String::from("checksum verification must be required")) + } } #[given("the default failure action")] @@ -196,11 +256,12 @@ fn given_default_failure_action(world: &mut ArtefactWorld) { } #[then("the action is fallback with warning")] -fn then_action_is_fallback(world: &mut ArtefactWorld) { - assert_eq!( - world.failure_action, - Some(VerificationFailureAction::FallbackWithWarning) - ); +fn then_action_is_fallback(world: &mut ArtefactWorld) -> Result<(), String> { + ensure_eq( + &world.failure_action, + &Some(VerificationFailureAction::FallbackWithWarning), + "failure action", + ) } // --------------------------------------------------------------------------- @@ -211,62 +272,46 @@ fn then_action_is_fallback(world: &mut ArtefactWorld) { path = "tests/features/artefact_policy.feature", name = "Construct artefact name from valid components" )] -fn scenario_construct_artefact_name(world: ArtefactWorld) { - let _ = world; -} +fn scenario_construct_artefact_name(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Reject unsupported target triple" )] -fn scenario_reject_unsupported_target(world: ArtefactWorld) { - let _ = world; -} +fn scenario_reject_unsupported_target(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Accept all five supported target triples" )] -fn scenario_accept_all_supported_targets(world: ArtefactWorld) { - let _ = world; -} +fn scenario_accept_all_supported_targets(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Reject invalid git SHA" )] -fn scenario_reject_invalid_git_sha(world: ArtefactWorld) { - let _ = world; -} +fn scenario_reject_invalid_git_sha(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Reject empty toolchain channel" )] -fn scenario_reject_empty_channel(world: ArtefactWorld) { - let _ = world; -} +fn scenario_reject_empty_channel(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Construct manifest with all fields" )] -fn scenario_construct_manifest(world: ArtefactWorld) { - let _ = world; -} +fn scenario_construct_manifest(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Default verification policy requires checksum" )] -fn scenario_default_verification_policy(world: ArtefactWorld) { - let _ = world; -} +fn scenario_default_verification_policy(world: ArtefactWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_policy.feature", name = "Verification failure triggers fallback" )] -fn scenario_verification_failure_fallback(world: ArtefactWorld) { - let _ = world; -} +fn scenario_verification_failure_fallback(world: ArtefactWorld) { let _ = world; } diff --git a/installer/tests/behaviour_artefact_packaging.rs b/installer/tests/behaviour_artefact_packaging.rs index 861a7c64..1b3e30e0 100644 --- a/installer/tests/behaviour_artefact_packaging.rs +++ b/installer/tests/behaviour_artefact_packaging.rs @@ -2,22 +2,28 @@ //! //! These scenarios validate the packaging pipeline defined in the //! `artefact::packaging` module against ADR-001 rules. Tests use the -//! rstest-bdd v0.5.0 mutable world pattern. +//! rstest-bdd v0.5.0 mutable world pattern with fallible steps. + +use std::{fs, path::PathBuf}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::fs; -use std::path::PathBuf; use tempfile::TempDir; -use whitaker_installer::artefact::git_sha::GitSha; -use whitaker_installer::artefact::manifest::GeneratedAt; -use whitaker_installer::artefact::naming::ArtefactName; -use whitaker_installer::artefact::packaging::{ - PackageOutput, PackageParams, compute_sha256, generate_manifest_json, package_artefact, +use whitaker_installer::artefact::{ + git_sha::GitSha, + manifest::GeneratedAt, + naming::ArtefactName, + packaging::{ + PackageOutput, + PackageParams, + compute_sha256, + generate_manifest_json, + package_artefact, + }, + packaging_error::PackagingError, + target::TargetTriple, + toolchain_channel::ToolchainChannel, }; -use whitaker_installer::artefact::packaging_error::PackagingError; -use whitaker_installer::artefact::target::TargetTriple; -use whitaker_installer::artefact::toolchain_channel::ToolchainChannel; // --------------------------------------------------------------------------- // World types @@ -36,42 +42,81 @@ struct PackagingWorld { archive_sha256: Option, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> PackagingWorld { - PackagingWorld { - temp_dir: Some(TempDir::new().expect("temp dir")), - ..PackagingWorld::default() +fn world() -> PackagingWorld { PackagingWorld::default() } + +/// Return the temp directory path, creating the directory if needed. +fn temp_path(world: &mut PackagingWorld) -> Result { + if world.temp_dir.is_none() { + let dir = TempDir::new().map_err(|e| format!("create temp dir: {e}"))?; + world.temp_dir = Some(dir); } + world + .temp_dir + .as_ref() + .map(|dir| dir.path().to_path_buf()) + .ok_or_else(|| String::from("temp_dir set")) } -/// Return the temp directory path, creating one if needed. -fn temp_path(world: &PackagingWorld) -> PathBuf { +/// Fetch the packaging output, failing if packaging has not run successfully. +fn output_ref(world: &PackagingWorld) -> Result<&PackageOutput, String> { world - .temp_dir + .output .as_ref() - .expect("temp_dir set") - .path() - .to_path_buf() + .ok_or_else(|| String::from("packaging output must be set")) } /// Run the packaging pipeline and store the result in the world. -fn run_packaging(world: &mut PackagingWorld) { - let output_dir = temp_path(world).join("dist"); - fs::create_dir_all(&output_dir).expect("mkdir dist"); +fn run_packaging(world: &mut PackagingWorld) -> Result<(), String> { + let output_dir = temp_path(world)?.join("dist"); + fs::create_dir_all(&output_dir).map_err(|e| format!("mkdir dist: {e}"))?; let params = PackageParams { - git_sha: world.git_sha.clone().expect("git_sha set"), - toolchain: world.toolchain.clone().expect("toolchain set"), - target: world.target.clone().expect("target set"), + git_sha: world + .git_sha + .clone() + .ok_or_else(|| String::from("git_sha set"))?, + toolchain: world + .toolchain + .clone() + .ok_or_else(|| String::from("toolchain set"))?, + target: world + .target + .clone() + .ok_or_else(|| String::from("target set"))?, library_files: world.library_files.clone(), output_dir, generated_at: GeneratedAt::new("2026-02-11T00:00:00Z"), }; - match package_artefact(params) { + match package_artefact(¶ms) { Ok(output) => world.output = Some(output), Err(e) => world.packaging_error = Some(e), } + Ok(()) +} + +/// Write a fixture library file into the temp directory and register it. +fn add_library_file(world: &mut PackagingWorld, name: &str, content: &[u8]) -> Result<(), String> { + let path = temp_path(world)?.join(name); + fs::write(&path, content).map_err(|e| format!("write {name}: {e}"))?; + world.library_files.push(path); + Ok(()) +} + +/// Populate the world with valid known packaging components. +fn set_known_components(world: &mut PackagingWorld) -> Result<(), String> { + world.git_sha = Some(GitSha::try_from("abc1234").map_err(|e| format!("valid sha: {e}"))?); + world.toolchain = Some( + ToolchainChannel::try_from("nightly-2026-05-28") + .map_err(|e| format!("valid channel: {e}"))?, + ); + world.target = Some( + TargetTriple::try_from("x86_64-unknown-linux-gnu") + .map_err(|e| format!("valid target: {e}"))?, + ); + Ok(()) } // --------------------------------------------------------------------------- @@ -79,54 +124,55 @@ fn run_packaging(world: &mut PackagingWorld) { // --------------------------------------------------------------------------- #[given("a library file \"{name}\"")] -fn given_library_file(world: &mut PackagingWorld, name: String) { - let path = temp_path(world).join(&name); - fs::write(&path, b"fake library content").expect("write lib"); - world.library_files.push(path); +fn given_library_file(world: &mut PackagingWorld, name: String) -> Result<(), String> { + add_library_file(world, &name, b"fake library content") } #[given("a git SHA \"{sha}\"")] -fn given_git_sha(world: &mut PackagingWorld, sha: String) { - world.git_sha = Some(GitSha::try_from(sha).expect("valid SHA")); +fn given_git_sha(world: &mut PackagingWorld, sha: String) -> Result<(), String> { + world.git_sha = Some(GitSha::try_from(sha).map_err(|e| format!("valid SHA: {e}"))?); + Ok(()) } #[given("a toolchain channel \"{channel}\"")] -fn given_toolchain(world: &mut PackagingWorld, channel: String) { - world.toolchain = Some(ToolchainChannel::try_from(channel).expect("valid channel")); +fn given_toolchain(world: &mut PackagingWorld, channel: String) -> Result<(), String> { + world.toolchain = + Some(ToolchainChannel::try_from(channel).map_err(|e| format!("valid channel: {e}"))?); + Ok(()) } #[given("a target triple \"{triple}\"")] -fn given_target(world: &mut PackagingWorld, triple: String) { - world.target = Some(TargetTriple::try_from(triple).expect("valid target")); +fn given_target(world: &mut PackagingWorld, triple: String) -> Result<(), String> { + world.target = Some(TargetTriple::try_from(triple).map_err(|e| format!("valid target: {e}"))?); + Ok(()) } #[when("the artefact is packaged")] -fn when_packaged(world: &mut PackagingWorld) { - run_packaging(world); -} +fn when_packaged(world: &mut PackagingWorld) -> Result<(), String> { run_packaging(world) } #[then("the archive exists with the expected ADR-001 filename")] -fn then_archive_exists(world: &mut PackagingWorld) { - let output = world.output.as_ref().expect("output set"); - assert!(output.archive_path.exists(), "archive file must exist"); +fn then_archive_exists(world: &mut PackagingWorld) -> Result<(), String> { + let output = output_ref(world)?; + if !output.archive_path.exists() { + return Err(String::from("archive file must exist")); + } let filename = output .archive_path .file_name() - .expect("filename") + .ok_or_else(|| String::from("archive path must have a filename"))? .to_string_lossy(); - assert!( - filename.starts_with("whitaker-lints-"), - "filename must start with 'whitaker-lints-'" - ); - assert!( - filename.ends_with(".tar.zst"), - "filename must end with '.tar.zst'" - ); + if !filename.starts_with("whitaker-lints-") { + return Err(String::from("filename must start with 'whitaker-lints-'")); + } + if !filename.ends_with(".tar.zst") { + return Err(String::from("filename must end with '.tar.zst'")); + } + Ok(()) } #[then("the archive contains the library file")] -fn then_archive_has_library(world: &mut PackagingWorld) { - let entries = list_archive_entries(world); +fn then_archive_has_library(world: &mut PackagingWorld) -> Result<(), String> { + let entries = list_archive_entries(world)?; let expected: Vec = world .library_files .iter() @@ -134,184 +180,220 @@ fn then_archive_has_library(world: &mut PackagingWorld) { .map(|n| n.to_string_lossy().into_owned()) .collect(); for name in &expected { - assert!( - entries.contains(name), - "archive must contain {name}, got {entries:?}" - ); + if !entries.contains(name) { + return Err(format!("archive must contain {name}, got {entries:?}")); + } } + Ok(()) } #[then("the archive does not contain a manifest")] -fn then_archive_has_no_manifest(world: &mut PackagingWorld) { - let entries = list_archive_entries(world); - assert!( - !entries.contains(&"manifest.json".to_owned()), - "archive must not contain manifest.json" - ); +fn then_archive_has_no_manifest(world: &mut PackagingWorld) -> Result<(), String> { + let entries = list_archive_entries(world)?; + if entries.contains(&"manifest.json".to_owned()) { + return Err(String::from("archive must not contain manifest.json")); + } + Ok(()) } #[given("a packaged artefact")] -fn given_packaged_artefact(world: &mut PackagingWorld) { - let path = temp_path(world).join("libwhitaker_suite.so"); - fs::write(&path, b"fake library").expect("write"); - world.library_files.push(path); - world.git_sha = Some(GitSha::try_from("abc1234").expect("valid")); - world.toolchain = Some(ToolchainChannel::try_from("nightly-2026-05-28").expect("valid")); - world.target = Some(TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid")); - run_packaging(world); +fn given_packaged_artefact(world: &mut PackagingWorld) -> Result<(), String> { + add_library_file(world, "libwhitaker_suite.so", b"fake library")?; + set_known_components(world)?; + run_packaging(world) } #[when("the manifest JSON is generated")] -fn when_manifest_json_generated(world: &mut PackagingWorld) { - let output = world.output.as_ref().expect("output set"); - let json = generate_manifest_json(&output.manifest).expect("serialization"); - world.manifest_json = Some(serde_json::from_str(&json).expect("parse JSON")); +fn when_manifest_json_generated(world: &mut PackagingWorld) -> Result<(), String> { + let output = output_ref(world)?; + let json = + generate_manifest_json(&output.manifest).map_err(|e| format!("serialization: {e}"))?; + world.manifest_json = + Some(serde_json::from_str(&json).map_err(|e| format!("parse JSON: {e}"))?); + Ok(()) } #[then("the manifest contains field \"{field}\"")] -fn then_manifest_has_field(world: &mut PackagingWorld, field: String) { - let json = world.manifest_json.as_ref().expect("manifest_json set"); - let obj = json.as_object().expect("top-level object"); - assert!(obj.contains_key(&field), "missing field: {field}"); +fn then_manifest_has_field(world: &mut PackagingWorld, field: String) -> Result<(), String> { + let json = world + .manifest_json + .as_ref() + .ok_or_else(|| String::from("manifest_json set"))?; + let obj = json + .as_object() + .ok_or_else(|| String::from("manifest JSON must be a top-level object"))?; + if !obj.contains_key(&field) { + return Err(format!("missing field: {field}")); + } + Ok(()) } #[when("the archive SHA-256 is computed")] -fn when_sha256_computed(world: &mut PackagingWorld) { - let output = world.output.as_ref().expect("output set"); - let digest = compute_sha256(&output.archive_path).expect("sha256"); +fn when_sha256_computed(world: &mut PackagingWorld) -> Result<(), String> { + let output = output_ref(world)?; + let digest = compute_sha256(&output.archive_path).map_err(|e| format!("sha256: {e}"))?; world.archive_sha256 = Some(digest.as_str().to_owned()); + Ok(()) } #[then("it matches the manifest sha256")] -fn then_digest_matches_manifest(world: &mut PackagingWorld) { - let archive_hex = world.archive_sha256.as_ref().expect("sha256 set"); - let manifest_hex = world - .output +fn then_digest_matches_manifest(world: &mut PackagingWorld) -> Result<(), String> { + let archive_hex = world + .archive_sha256 .as_ref() - .expect("output set") - .manifest - .sha256() - .as_str(); - assert_eq!( - archive_hex, manifest_hex, - "archive digest must match manifest sha256" - ); + .ok_or_else(|| String::from("sha256 set"))?; + let manifest_hex = output_ref(world)?.manifest.sha256().as_str(); + if archive_hex != manifest_hex { + return Err(format!( + "archive digest {archive_hex} must match manifest sha256 {manifest_hex}" + )); + } + Ok(()) } #[then("it is a valid 64-character hex string")] -fn then_valid_hex(world: &mut PackagingWorld) { - let hex = world.archive_sha256.as_ref().expect("sha256 set"); - assert_eq!(hex.len(), 64, "digest must be 64 characters"); - assert!( - hex.chars().all(|c| c.is_ascii_hexdigit()), - "digest must be hex" - ); +fn then_valid_hex(world: &mut PackagingWorld) -> Result<(), String> { + let hex = world + .archive_sha256 + .as_ref() + .ok_or_else(|| String::from("sha256 set"))?; + if hex.len() != 64 { + return Err(format!("digest must be 64 characters, got {}", hex.len())); + } + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("digest must be hex: {hex}")); + } + Ok(()) } #[given("no library files")] -fn given_no_files(world: &mut PackagingWorld) { +fn given_no_files(world: &mut PackagingWorld) -> Result<(), String> { world.library_files.clear(); - world.git_sha = Some(GitSha::try_from("abc1234").expect("valid")); - world.toolchain = Some(ToolchainChannel::try_from("nightly-2026-05-28").expect("valid")); - world.target = Some(TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid")); + set_known_components(world) } #[when("packaging is attempted")] -fn when_packaging_attempted(world: &mut PackagingWorld) { - run_packaging(world); +fn when_packaging_attempted(world: &mut PackagingWorld) -> Result<(), String> { + run_packaging(world) } #[then("a packaging error is returned")] -fn then_packaging_error(world: &mut PackagingWorld) { - assert!( - world.packaging_error.is_some(), - "expected a packaging error" - ); - assert!( - matches!( - world.packaging_error.as_ref().expect("checked above"), - PackagingError::EmptyFileList - ), - "expected EmptyFileList error" - ); +fn then_packaging_error(world: &mut PackagingWorld) -> Result<(), String> { + let error = world + .packaging_error + .as_ref() + .ok_or_else(|| String::from("expected a packaging error"))?; + if matches!(error, PackagingError::EmptyFileList) { + Ok(()) + } else { + Err(format!("expected EmptyFileList error, got {error:?}")) + } } #[given("library files \"{a}\" and \"{b}\" and \"{c}\"")] -fn given_three_library_files(world: &mut PackagingWorld, a: String, b: String, c: String) { +fn given_three_library_files( + world: &mut PackagingWorld, + a: String, + b: String, + c: String, +) -> Result<(), String> { for name in [a, b, c] { - let path = temp_path(world).join(&name); - fs::write(&path, format!("content of {name}")).expect("write"); - world.library_files.push(path); + add_library_file(world, &name, format!("content of {name}").as_bytes())?; } + Ok(()) } #[given("library files \"{a}\" and \"{b}\"")] -fn given_two_library_files(world: &mut PackagingWorld, a: String, b: String) { +fn given_two_library_files(world: &mut PackagingWorld, a: String, b: String) -> Result<(), String> { for name in [a, b] { - let path = temp_path(world).join(&name); - fs::write(&path, format!("content of {name}")).expect("write"); - world.library_files.push(path); + add_library_file(world, &name, format!("content of {name}").as_bytes())?; } + Ok(()) } #[then("the archive contains {count} library files")] -fn then_archive_has_n_libraries(world: &mut PackagingWorld, count: usize) { - let entries = list_archive_entries(world); +fn then_archive_has_n_libraries(world: &mut PackagingWorld, count: usize) -> Result<(), String> { + let entries = list_archive_entries(world)?; let lib_count = entries.iter().filter(|e| *e != "manifest.json").count(); - assert_eq!( - lib_count, count, - "expected {count} library files, got {lib_count}" - ); + if lib_count != count { + return Err(format!("expected {count} library files, got {lib_count}")); + } + Ok(()) } #[then("the manifest files field contains \"{name}\"")] -fn then_manifest_files_contains(world: &mut PackagingWorld, name: String) { - let json = world.manifest_json.as_ref().expect("manifest_json set"); - let files = json["files"].as_array().expect("files is an array"); +fn then_manifest_files_contains(world: &mut PackagingWorld, name: String) -> Result<(), String> { + let json = world + .manifest_json + .as_ref() + .ok_or_else(|| String::from("manifest_json set"))?; + let files = json + .get("files") + .and_then(|v| v.as_array()) + .ok_or_else(|| String::from("files must be an array"))?; let names: Vec<&str> = files.iter().filter_map(|v| v.as_str()).collect(); - assert!( - names.contains(&name.as_str()), - "files field missing {name}: {names:?}" - ); + if !names.contains(&name.as_str()) { + return Err(format!("files field missing {name}: {names:?}")); + } + Ok(()) } #[given("a packaged artefact with known components")] -fn given_packaged_with_known(world: &mut PackagingWorld) { - given_packaged_artefact(world); +fn given_packaged_with_known(world: &mut PackagingWorld) -> Result<(), String> { + given_packaged_artefact(world) } #[then("it matches the ArtefactName string representation")] -fn then_filename_matches_artefact_name(world: &mut PackagingWorld) { - let output = world.output.as_ref().expect("output set"); +fn then_filename_matches_artefact_name(world: &mut PackagingWorld) -> Result<(), String> { let expected = ArtefactName::new( - world.git_sha.clone().expect("sha"), - world.toolchain.clone().expect("toolchain"), - world.target.clone().expect("target"), - ); - assert_eq!( - output - .archive_path - .file_name() - .expect("filename") - .to_string_lossy(), - expected.filename() + world + .git_sha + .clone() + .ok_or_else(|| String::from("sha set"))?, + world + .toolchain + .clone() + .ok_or_else(|| String::from("toolchain set"))?, + world + .target + .clone() + .ok_or_else(|| String::from("target set"))?, ); + let output = output_ref(world)?; + let filename = output + .archive_path + .file_name() + .ok_or_else(|| String::from("archive path must have a filename"))? + .to_string_lossy(); + if filename != expected.filename() { + return Err(format!( + "filename {filename} must match ArtefactName {}", + expected.filename() + )); + } + Ok(()) } /// Extract entry names from a `.tar.zst` archive. -fn list_archive_entries(world: &PackagingWorld) -> Vec { - let output = world.output.as_ref().expect("output set"); - let file = fs::File::open(&output.archive_path).expect("open"); - let decoder = zstd::Decoder::new(file).expect("decode"); +fn list_archive_entries(world: &PackagingWorld) -> Result, String> { + let output = output_ref(world)?; + let file = fs::File::open(&output.archive_path).map_err(|e| format!("open archive: {e}"))?; + let decoder = zstd::Decoder::new(file).map_err(|e| format!("decode archive: {e}"))?; let mut archive = tar::Archive::new(decoder); - archive + let mut names = Vec::new(); + for entry in archive .entries() - .expect("entries") - .map(|e| { - let entry = e.expect("entry"); - entry.path().expect("path").to_string_lossy().into_owned() - }) - .collect() + .map_err(|e| format!("list archive entries: {e}"))? + { + let archive_entry = entry.map_err(|e| format!("read archive entry: {e}"))?; + let path = archive_entry + .path() + .map_err(|e| format!("read entry path: {e}"))? + .to_string_lossy() + .into_owned(); + names.push(path); + } + Ok(names) } // --------------------------------------------------------------------------- @@ -322,62 +404,46 @@ fn list_archive_entries(world: &PackagingWorld) -> Vec { path = "tests/features/artefact_packaging.feature", name = "Package a single library file into a tar.zst archive" )] -fn scenario_package_single_library(world: PackagingWorld) { - let _ = world; -} +fn scenario_package_single_library(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Manifest JSON contains all required fields" )] -fn scenario_manifest_fields(world: PackagingWorld) { - let _ = world; -} +fn scenario_manifest_fields(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Manifest sha256 matches the archive digest" )] -fn scenario_manifest_digest_self_consistency(world: PackagingWorld) { - let _ = world; -} +fn scenario_manifest_digest_self_consistency(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Archive SHA-256 is a valid digest" )] -fn scenario_archive_sha256(world: PackagingWorld) { - let _ = world; -} +fn scenario_archive_sha256(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Packaging rejects an empty file list" )] -fn scenario_reject_empty_files(world: PackagingWorld) { - let _ = world; -} +fn scenario_reject_empty_files(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Archive filename matches ArtefactName convention" )] -fn scenario_filename_matches(world: PackagingWorld) { - let _ = world; -} +fn scenario_filename_matches(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Archive contains multiple library files" )] -fn scenario_multi_library(world: PackagingWorld) { - let _ = world; -} +fn scenario_multi_library(world: PackagingWorld) { let _ = world; } #[scenario( path = "tests/features/artefact_packaging.feature", name = "Manifest files field lists all library basenames" )] -fn scenario_manifest_files_field(world: PackagingWorld) { - let _ = world; -} +fn scenario_manifest_files_field(world: PackagingWorld) { let _ = world; } diff --git a/installer/tests/behaviour_binstall.rs b/installer/tests/behaviour_binstall.rs index f58c0dab..a1ed58c3 100644 --- a/installer/tests/behaviour_binstall.rs +++ b/installer/tests/behaviour_binstall.rs @@ -8,8 +8,13 @@ use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use toml::Table; use whitaker_installer::binstall_metadata::{ - BIN_DIR_TEMPLATE, PKG_URL_TEMPLATE, WINDOWS_OVERRIDE_TARGET, expand_bin_dir, expand_pkg_url, - extract_binstall_table, load_cargo_toml, + BIN_DIR_TEMPLATE, + PKG_URL_TEMPLATE, + WINDOWS_OVERRIDE_TARGET, + expand_bin_dir, + expand_pkg_url, + extract_binstall_table, + load_cargo_toml, }; // --------------------------------------------------------------------------- @@ -30,9 +35,47 @@ struct BinstallWorld { expanded_bin_dir: String, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> BinstallWorld { - BinstallWorld::default() +fn world() -> BinstallWorld { BinstallWorld::default() } + +/// Fetch the binstall table, failing if it has not been loaded. +fn binstall_table(world: &BinstallWorld) -> Result<&Table, String> { + world + .binstall_table + .as_ref() + .ok_or_else(|| String::from("binstall table must be loaded")) +} + +/// Read a string-valued key from a TOML table, failing if absent. +fn table_str<'a>(table: &'a Table, key: &str) -> Result<&'a str, String> { + table + .get(key) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("{key} not found")) +} + +/// Assert that a string-valued binstall key equals `expected`. +/// +/// The mismatch is returned as an `Err` rather than asserted: these steps +/// return `Result`, so a panic here would trip `clippy::panic_in_result_fn`, +/// and returning the message keeps the failure reporting identical to the +/// other steps in this file. +fn assert_binstall_value_equals( + world: &BinstallWorld, + key: &str, + expected: &str, + value_name: &str, +) -> Result<(), String> { + let binstall = binstall_table(world)?; + let actual = table_str(binstall, key)?; + if actual == expected { + Ok(()) + } else { + Err(format!( + "{value_name} mismatch: expected {expected}, got {actual}" + )) + } } // --------------------------------------------------------------------------- @@ -40,9 +83,11 @@ fn world() -> BinstallWorld { // --------------------------------------------------------------------------- #[given("the installer Cargo.toml is loaded")] -fn given_cargo_toml_loaded(world: &mut BinstallWorld) { - let table = load_cargo_toml(); - world.binstall_table = Some(extract_binstall_table(&table)); +fn given_cargo_toml_loaded(world: &mut BinstallWorld) -> Result<(), String> { + let table = load_cargo_toml().map_err(|e| format!("load installer Cargo.toml: {e}"))?; + world.binstall_table = + Some(extract_binstall_table(&table).map_err(|e| format!("extract binstall table: {e}"))?); + Ok(()) } #[given("target \"{target}\" and version \"{version}\"")] @@ -62,13 +107,13 @@ fn when_binstall_inspected(world: &mut BinstallWorld) { } #[when("the binstall overrides are inspected")] -fn when_overrides_inspected(world: &mut BinstallWorld) { +fn when_overrides_inspected(world: &mut BinstallWorld) -> Result<(), String> { // Verify overrides are accessible; the Then step extracts them directly. - let binstall = world.binstall_table.as_ref().expect("binstall table set"); - assert!( - binstall.get("overrides").is_some(), - "overrides table not found" - ); + let binstall = binstall_table(world)?; + if binstall.get("overrides").is_none() { + return Err(String::from("overrides table not found")); + } + Ok(()) } #[when("the pkg-url template is expanded")] @@ -82,48 +127,41 @@ fn when_bin_dir_expanded(world: &mut BinstallWorld) { } #[then("the pkg-url template is present")] -fn then_pkg_url_present(world: &mut BinstallWorld) { - let binstall = world.binstall_table.as_ref().expect("binstall table set"); - let pkg_url = binstall - .get("pkg-url") - .and_then(|v| v.as_str()) - .expect("pkg-url not found"); - assert_eq!(pkg_url, PKG_URL_TEMPLATE); +fn then_pkg_url_present(world: &mut BinstallWorld) -> Result<(), String> { + assert_binstall_value_equals(world, "pkg-url", PKG_URL_TEMPLATE, "pkg-url") } #[then("the bin-dir template is present")] -fn then_bin_dir_present(world: &mut BinstallWorld) { - let binstall = world.binstall_table.as_ref().expect("binstall table set"); - let bin_dir = binstall - .get("bin-dir") - .and_then(|v| v.as_str()) - .expect("bin-dir not found"); - assert_eq!(bin_dir, BIN_DIR_TEMPLATE); +fn then_bin_dir_present(world: &mut BinstallWorld) -> Result<(), String> { + assert_binstall_value_equals(world, "bin-dir", BIN_DIR_TEMPLATE, "bin-dir") } #[then("the default pkg-fmt is \"{expected}\"")] -fn then_default_pkg_fmt(world: &mut BinstallWorld, expected: String) { - let binstall = world.binstall_table.as_ref().expect("binstall table set"); - let pkg_fmt = binstall - .get("pkg-fmt") - .and_then(|v| v.as_str()) - .expect("pkg-fmt not found"); - assert_eq!(pkg_fmt, expected); +fn then_default_pkg_fmt(world: &mut BinstallWorld, expected: String) -> Result<(), String> { + assert_binstall_value_equals(world, "pkg-fmt", &expected, "pkg-fmt") } #[then("the x86_64-pc-windows-msvc override has pkg-fmt \"{expected}\"")] -fn then_windows_override_pkg_fmt(world: &mut BinstallWorld, expected: String) { - let binstall = world.binstall_table.as_ref().expect("binstall table set"); +fn then_windows_override_pkg_fmt( + world: &mut BinstallWorld, + expected: String, +) -> Result<(), String> { + let binstall = binstall_table(world)?; let windows = binstall .get("overrides") .and_then(|o| o.get(WINDOWS_OVERRIDE_TARGET)) .and_then(|w| w.as_table()) - .expect("Windows override not found"); + .ok_or_else(|| String::from("Windows override not found"))?; let pkg_fmt = windows .get("pkg-fmt") .and_then(|v| v.as_str()) - .expect("pkg-fmt not found in Windows override"); - assert_eq!(pkg_fmt, expected); + .ok_or_else(|| String::from("pkg-fmt not found in Windows override"))?; + if pkg_fmt != expected { + return Err(format!( + "Windows override pkg-fmt mismatch: expected {expected}, got {pkg_fmt}" + )); + } + Ok(()) } #[then("the URL ends with \"{suffix}\"")] @@ -155,24 +193,24 @@ fn then_path_ends_with(world: &mut BinstallWorld, suffix: String) { } #[then("no templates contain the placeholder \"{placeholder}\"")] -fn then_no_invalid_placeholder(world: &mut BinstallWorld, placeholder: String) { - let binstall = world.binstall_table.as_ref().expect("binstall table set"); - let pkg_url = binstall - .get("pkg-url") - .and_then(|v| v.as_str()) - .expect("pkg-url not found"); - let bin_dir = binstall - .get("bin-dir") - .and_then(|v| v.as_str()) - .expect("bin-dir not found"); - assert!( - !pkg_url.contains(&placeholder), - "pkg-url contains invalid placeholder '{placeholder}'" - ); - assert!( - !bin_dir.contains(&placeholder), - "bin-dir contains invalid placeholder '{placeholder}'" - ); +fn then_no_invalid_placeholder( + world: &mut BinstallWorld, + placeholder: String, +) -> Result<(), String> { + let binstall = binstall_table(world)?; + let pkg_url = table_str(binstall, "pkg-url")?; + let bin_dir = table_str(binstall, "bin-dir")?; + if pkg_url.contains(&placeholder) { + return Err(format!( + "pkg-url contains invalid placeholder '{placeholder}'" + )); + } + if bin_dir.contains(&placeholder) { + return Err(format!( + "bin-dir contains invalid placeholder '{placeholder}'" + )); + } + Ok(()) } // --------------------------------------------------------------------------- @@ -183,54 +221,40 @@ fn then_no_invalid_placeholder(world: &mut BinstallWorld, placeholder: String) { path = "tests/features/binstall_metadata.feature", name = "Binstall metadata section exists in Cargo.toml" )] -fn scenario_binstall_metadata_exists(world: BinstallWorld) { - let _ = world; -} +fn scenario_binstall_metadata_exists(world: BinstallWorld) { let _ = world; } #[scenario( path = "tests/features/binstall_metadata.feature", name = "Windows override uses zip format" )] -fn scenario_windows_override(world: BinstallWorld) { - let _ = world; -} +fn scenario_windows_override(world: BinstallWorld) { let _ = world; } #[scenario( path = "tests/features/binstall_metadata.feature", name = "URL template expands correctly for Linux" )] -fn scenario_url_linux(world: BinstallWorld) { - let _ = world; -} +fn scenario_url_linux(world: BinstallWorld) { let _ = world; } #[scenario( path = "tests/features/binstall_metadata.feature", name = "URL template expands correctly for Windows" )] -fn scenario_url_windows(world: BinstallWorld) { - let _ = world; -} +fn scenario_url_windows(world: BinstallWorld) { let _ = world; } #[scenario( path = "tests/features/binstall_metadata.feature", name = "Binary directory expands correctly for Unix" )] -fn scenario_bin_dir_unix(world: BinstallWorld) { - let _ = world; -} +fn scenario_bin_dir_unix(world: BinstallWorld) { let _ = world; } #[scenario( path = "tests/features/binstall_metadata.feature", name = "Binary directory expands correctly for Windows" )] -fn scenario_bin_dir_windows(world: BinstallWorld) { - let _ = world; -} +fn scenario_bin_dir_windows(world: BinstallWorld) { let _ = world; } #[scenario( path = "tests/features/binstall_metadata.feature", name = "No invalid placeholders in templates" )] -fn scenario_no_invalid_placeholders(world: BinstallWorld) { - let _ = world; -} +fn scenario_no_invalid_placeholders(world: BinstallWorld) { let _ = world; } diff --git a/installer/tests/behaviour_cli.rs b/installer/tests/behaviour_cli.rs index 0bd1f57c..a15475e3 100644 --- a/installer/tests/behaviour_cli.rs +++ b/installer/tests/behaviour_cli.rs @@ -9,17 +9,28 @@ mod scenarios; #[path = "behaviour_cli/support.rs"] mod support; -use rstest_bdd_macros::{given, then, when}; use std::process::Command; + +use rstest_bdd_macros::{given, then, when}; pub(crate) use support::{CliWorld, cli_world}; use support::{ - assert_cli_exits_successfully, assert_cli_exits_with_error, assert_dry_run_output_is_shown, + assert_cli_exits_successfully, + assert_cli_exits_with_error, + assert_dry_run_output_is_shown, assert_experimental_lint_dry_run_output_is_shown, - assert_experimental_lint_opt_in_message_is_shown, assert_installation_succeeds_or_is_skipped, - assert_suite_library_is_staged, assert_unknown_lint_message_is_shown, - configure_dry_run_experimental_lint, configure_dry_run_experimental_lint_with_opt_in, - configure_dry_run_unknown_lint, configure_dry_run_with_target_dir, configure_suite_install, - is_toolchain_installed, pinned_toolchain_channel, run_installer_cli, workspace_root, + assert_experimental_lint_opt_in_message_is_shown, + assert_installation_succeeds_or_is_skipped, + assert_suite_library_is_staged, + assert_unknown_lint_message_is_shown, + configure_dry_run_experimental_lint, + configure_dry_run_experimental_lint_with_opt_in, + configure_dry_run_unknown_lint, + configure_dry_run_with_target_dir, + configure_suite_install, + is_toolchain_installed, + pinned_toolchain_channel, + run_installer_cli, + workspace_root, }; #[given("the installer is invoked with dry-run and a target directory")] @@ -28,9 +39,7 @@ fn given_dry_run_with_target_dir(cli_world: &CliWorld) { } #[given("the installer is invoked with dry-run and an unknown lint")] -fn given_dry_run_unknown_lint(cli_world: &CliWorld) { - configure_dry_run_unknown_lint(cli_world); -} +fn given_dry_run_unknown_lint(cli_world: &CliWorld) { configure_dry_run_unknown_lint(cli_world); } #[given("the installer is invoked with dry-run and an experimental lint")] fn given_dry_run_experimental_lint(cli_world: &CliWorld) { @@ -45,29 +54,19 @@ fn given_dry_run_experimental_lint_with_opt_in(cli_world: &CliWorld) { } #[given("the installer is invoked to a temporary directory")] -fn given_suite_install(cli_world: &CliWorld) { - configure_suite_install(cli_world); -} +fn given_suite_install(cli_world: &CliWorld) { configure_suite_install(cli_world); } #[when("the installer CLI is run")] -fn when_installer_cli_run(cli_world: &CliWorld) { - run_installer_cli(cli_world); -} +fn when_installer_cli_run(cli_world: &CliWorld) { run_installer_cli(cli_world); } #[then("the CLI exits successfully")] -fn then_cli_exits_successfully(cli_world: &CliWorld) { - assert_cli_exits_successfully(cli_world); -} +fn then_cli_exits_successfully(cli_world: &CliWorld) { assert_cli_exits_successfully(cli_world); } #[then("dry-run output is shown")] -fn then_dry_run_output_is_shown(cli_world: &CliWorld) { - assert_dry_run_output_is_shown(cli_world); -} +fn then_dry_run_output_is_shown(cli_world: &CliWorld) { assert_dry_run_output_is_shown(cli_world); } #[then("the CLI exits with an error")] -fn then_cli_exits_with_error(cli_world: &CliWorld) { - assert_cli_exits_with_error(cli_world); -} +fn then_cli_exits_with_error(cli_world: &CliWorld) { assert_cli_exits_with_error(cli_world); } #[then("an unknown lint message is shown")] fn then_unknown_lint_message_is_shown(cli_world: &CliWorld) { @@ -90,9 +89,7 @@ fn then_installation_succeeds_or_is_skipped(cli_world: &CliWorld) { } #[then("the suite library is staged")] -fn then_suite_library_is_staged(cli_world: &CliWorld) { - assert_suite_library_is_staged(cli_world); -} +fn then_suite_library_is_staged(cli_world: &CliWorld) { assert_suite_library_is_staged(cli_world); } #[test] fn dry_run_reports_verbosity_levels() { diff --git a/installer/tests/behaviour_cli/scenarios.rs b/installer/tests/behaviour_cli/scenarios.rs index c95291d8..15d371ed 100644 --- a/installer/tests/behaviour_cli/scenarios.rs +++ b/installer/tests/behaviour_cli/scenarios.rs @@ -1,24 +1,19 @@ //! Scenario bindings for CLI behaviour tests. -use super::{CliWorld, cli_world}; use rstest_bdd_macros::scenario; +use super::{CliWorld, cli_world}; + // Do not reorder scenarios in tests/features/installer.feature — bindings are // index-based. #[scenario(path = "tests/features/installer.feature", index = 12)] -fn scenario_dry_run_outputs_configuration(cli_world: CliWorld) { - let _ = cli_world; -} +fn scenario_dry_run_outputs_configuration(cli_world: CliWorld) { let _ = cli_world; } #[scenario(path = "tests/features/installer.feature", index = 13)] -fn scenario_dry_run_rejects_unknown_lint(cli_world: CliWorld) { - let _ = cli_world; -} +fn scenario_dry_run_rejects_unknown_lint(cli_world: CliWorld) { let _ = cli_world; } #[scenario(path = "tests/features/installer.feature", index = 14)] -fn scenario_install_suite_to_temp_dir(cli_world: CliWorld) { - let _ = cli_world; -} +fn scenario_install_suite_to_temp_dir(cli_world: CliWorld) { let _ = cli_world; } #[scenario(path = "tests/features/installer.feature", index = 21)] fn scenario_dry_run_rejects_experimental_lint_without_opt_in(cli_world: CliWorld) { diff --git a/installer/tests/behaviour_cli/support.rs b/installer/tests/behaviour_cli/support.rs index 1bae4eac..1bed8cb3 100644 --- a/installer/tests/behaviour_cli/support.rs +++ b/installer/tests/behaviour_cli/support.rs @@ -1,15 +1,19 @@ -//! Shared fixtures, command helpers, and assertions for CLI behaviour tests. +//! Shared fixtures and command helpers for CLI behaviour tests. + +use std::{ + cell::{Cell, Ref, RefCell}, + path::{Path, PathBuf}, + process::{Command, Output}, +}; -use super::prebuilt_markers::PREBUILT_INSTALL_MARKER; use rstest::fixture; -use std::cell::{Cell, Ref, RefCell}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; use tempfile::TempDir; -use whitaker_installer::dirs::SystemBaseDirs; -use whitaker_installer::prebuilt_path::prebuilt_library_dir; -use whitaker_installer::test_support::TEST_STAGE_SUITE_ENV; -use whitaker_installer::toolchain::parse_toolchain_channel; +use whitaker_installer::{ + dirs::SystemBaseDirs, + prebuilt_path::prebuilt_library_dir, + test_support::TEST_STAGE_SUITE_ENV, + toolchain::parse_toolchain_channel, +}; #[derive(Default)] pub(super) struct CliWorld { @@ -23,50 +27,48 @@ pub(super) struct CliWorld { temp_dir: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -pub(super) fn cli_world() -> CliWorld { - CliWorld::default() -} +pub(super) fn cli_world() -> CliWorld { CliWorld::default() } pub(super) fn workspace_root() -> PathBuf { - PathBuf::from(std::env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("manifest dir should have parent") - .to_owned() + let manifest_dir = PathBuf::from(std::env!("CARGO_MANIFEST_DIR")); + let Some(parent) = manifest_dir.parent() else { + panic!("manifest dir should have parent"); + }; + parent.to_owned() } pub(super) fn pinned_toolchain_channel() -> String { let toolchain_path = workspace_root().join("rust-toolchain.toml"); - let contents = std::fs::read_to_string(&toolchain_path).unwrap_or_else(|err| { + let Ok(contents) = std::fs::read_to_string(&toolchain_path) else { panic!( - "failed to read rust-toolchain.toml at {}: {err}", + "rust-toolchain.toml at {} should be readable", toolchain_path.display() - ) - }); - parse_toolchain_channel(&contents).unwrap_or_else(|err| { + ); + }; + let Ok(channel) = parse_toolchain_channel(&contents) else { panic!( - "failed to parse rust-toolchain.toml at {}: {err}", + "rust-toolchain.toml at {} should declare a channel", toolchain_path.display() - ) - }) + ); + }; + channel } pub(super) fn is_toolchain_installed(channel: &str) -> bool { Command::new("rustup") .args(["run", channel, "rustc", "--version"]) .output() - .map(|output| output.status.success()) - .unwrap_or(false) + .is_ok_and(|output| output.status.success()) } fn skip_scenario_when_toolchain_missing(cli_world: &CliWorld, channel: &str) { if !is_toolchain_installed(channel) { - eprintln!( - "Skipping scenario because rustup toolchain '{channel}' is not installed. Install this toolchain to run these tests." - ); cli_world.skip_assertions.set(true); rstest_bdd::skip!( - "rustup toolchain '{channel}' is not installed. Install this toolchain to run these tests.", + "rustup toolchain '{channel}' is not installed. Install this toolchain to run these \ + tests.", channel = channel ); } @@ -87,7 +89,9 @@ pub(super) fn ensure_required_toolchain_available(cli_world: &CliWorld) -> Optio } pub(super) fn setup_temp_dir(cli_world: &CliWorld) -> String { - let temp_dir = TempDir::new().expect("failed to create temp dir"); + let Ok(temp_dir) = TempDir::new() else { + panic!("temporary directory should be created"); + }; let target_dir = temp_dir.path().to_string_lossy().to_string(); cli_world.temp_dir.replace(Some(temp_dir)); target_dir @@ -111,7 +115,7 @@ fn expected_prebuilt_target_dir(toolchain: &str) -> Option { let host_target = detect_host_target()?; prebuilt_library_dir(&dirs, toolchain, &host_target) .ok() - .map(|path| path.into_string()) + .map(camino::Utf8PathBuf::into_string) } fn matching_files(dir: &Path, substring: &str) -> Vec { @@ -122,7 +126,7 @@ fn matching_files(dir: &Path, substring: &str) -> Vec { }; entries .map(|entry| match entry { - Ok(entry) => entry.file_name().to_string_lossy().to_string(), + Ok(dir_entry) => dir_entry.file_name().to_string_lossy().to_string(), Err(error) => panic!("failed to read entry in {}: {error}", dir.display()), }) .filter(|name| name.contains(substring)) @@ -206,180 +210,32 @@ pub(super) fn run_installer_cli(cli_world: &CliWorld) { command.env(TEST_STAGE_SUITE_ENV, "1"); } - let output = command.output().expect("failed to run whitaker-installer"); + let Ok(output) = command.output() else { + panic!("whitaker-installer should run"); + }; cli_world.output.replace(Some(output)); } pub(super) fn get_output(cli_world: &CliWorld) -> Ref<'_, Output> { - let output = cli_world.output.borrow(); - Ref::map(output, |opt| opt.as_ref().expect("output not set")) -} - -fn assert_exit_status(cli_world: &CliWorld, expected_success: bool) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - assert_eq!( - output.status.success(), - expected_success, - "expected success={expected_success}, stdout={}, stderr={stderr}", - String::from_utf8_lossy(&output.stdout), - ); -} - -pub(super) fn assert_cli_exits_successfully(cli_world: &CliWorld) { - assert_exit_status(cli_world, true); -} - -pub(super) fn assert_dry_run_output_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let toolchain = cli_world.toolchain.borrow(); - let toolchain = toolchain.as_ref().expect("toolchain not set"); - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!(stderr.contains("Dry run - no files will be modified")); - assert!(stderr.contains(&format!("Toolchain: {toolchain}"))); - assert!(stderr.contains("Crates to build:")); - assert!(stderr.contains("whitaker_suite")); - assert!( - !stderr.contains("module_max_lines"), - "individual lint crate should not appear in suite-only mode, stderr: {stderr}" - ); - - let temp_dir = cli_world.temp_dir.borrow(); - let temp_dir = temp_dir.as_ref().expect("temp dir not set"); - let target_dir = temp_dir.path().to_string_lossy(); - let expected_target_dir = - expected_prebuilt_target_dir(toolchain).unwrap_or_else(|| target_dir.into_owned()); - assert!(stderr.contains(&format!("Target directory: {expected_target_dir}"))); -} - -pub(super) fn assert_cli_exits_with_error(cli_world: &CliWorld) { - assert_exit_status(cli_world, false); -} - -pub(super) fn assert_unknown_lint_message_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stderr.contains("Dry run - no files will be modified"), - "dry-run configuration output should not be printed on unknown-lint error, stderr: {stderr}" - ); - assert!( - !stderr.contains("Crates to build:"), - "dry-run configuration output should not be printed on unknown-lint error, stderr: {stderr}" - ); - assert!( - stderr.contains("lint crate nonexistent_lint not found"), - "unexpected stderr: {stderr}" - ); -} - -pub(super) fn assert_experimental_lint_opt_in_message_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stderr.contains("Dry run - no files will be modified"), - "dry-run configuration output should not be printed on experimental-lint error, stderr: {stderr}" - ); - assert!( - !stderr.contains("Crates to build:"), - "dry-run configuration output should not be printed on experimental-lint error, stderr: {stderr}" - ); - assert!( - stderr.contains( - "experimental lint crate rstest_helper_should_be_fixture requires --experimental" - ), - "unexpected stderr: {stderr}" - ); -} - -pub(super) fn assert_experimental_lint_dry_run_output_is_shown(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!(stderr.contains("Dry run - no files will be modified")); - assert!(stderr.contains("Crates to build:")); - assert!(stderr.contains("rstest_helper_should_be_fixture")); - assert!( - !stderr.contains( - "experimental lint crate rstest_helper_should_be_fixture requires --experimental" - ), - "experimental opt-in error should not be printed when --experimental is set, stderr: {stderr}" - ); + let output_slot = cli_world.output.borrow(); + Ref::map(output_slot, |opt| { + let Some(output) = opt.as_ref() else { + panic!("output not set"); + }; + output + }) } -pub(super) fn assert_installation_succeeds_or_is_skipped(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } +#[path = "support_assertions.rs"] +mod assertions; - let output = get_output(cli_world); - assert!( - output.status.success(), - "installation failed: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -pub(super) fn assert_suite_library_is_staged(cli_world: &CliWorld) { - if cli_world.skip_assertions.get() { - return; - } - - let output = get_output(cli_world); - let stderr = String::from_utf8_lossy(&output.stderr); - let channel = cli_world.toolchain.borrow(); - let channel = channel.as_ref().expect("toolchain not set"); - let needle = format!("whitaker_suite@{channel}"); - - if stderr.contains(PREBUILT_INSTALL_MARKER) - && let Some(dir) = expected_prebuilt_target_dir(channel) - { - let prebuilt_path = PathBuf::from(&dir); - let matches = matching_files(&prebuilt_path, &needle); - assert!( - !matches.is_empty(), - "prebuilt marker found in stderr but no library matching \ - '{needle}' in {prebuilt_path:?}, entries={:?}", - matching_files(&prebuilt_path, ""), - ); - return; - } - - let temp_dir = cli_world.temp_dir.borrow(); - let temp_dir = temp_dir.as_ref().expect("temp dir not set"); - let staging_dir = temp_dir.path().join(channel).join("release"); - let matches = matching_files(&staging_dir, &needle); - - assert!( - matches.len() == 1, - "expected exactly one suite library matching '{needle}' in \ - {staging_dir:?}, matches={matches:?}, entries={:?}, \ - stdout={}, stderr={stderr}", - matching_files(&staging_dir, ""), - String::from_utf8_lossy(&output.stdout), - ); -} +pub(super) use assertions::{ + assert_cli_exits_successfully, + assert_cli_exits_with_error, + assert_dry_run_output_is_shown, + assert_experimental_lint_dry_run_output_is_shown, + assert_experimental_lint_opt_in_message_is_shown, + assert_installation_succeeds_or_is_skipped, + assert_suite_library_is_staged, + assert_unknown_lint_message_is_shown, +}; diff --git a/installer/tests/behaviour_cli/support_assertions.rs b/installer/tests/behaviour_cli/support_assertions.rs new file mode 100644 index 00000000..e5eb895d --- /dev/null +++ b/installer/tests/behaviour_cli/support_assertions.rs @@ -0,0 +1,203 @@ +//! Assertions over the installer CLI's observed output for behaviour tests. + +use std::path::PathBuf; + +use super::{CliWorld, expected_prebuilt_target_dir, get_output, matching_files}; +use crate::prebuilt_markers::PREBUILT_INSTALL_MARKER; + +fn assert_exit_status(cli_world: &CliWorld, expected_success: bool) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.success(), + expected_success, + "expected success={expected_success}, stdout={}, stderr={stderr}", + String::from_utf8_lossy(&output.stdout), + ); +} + +fn assert_error_output_is_shown(cli_world: &CliWorld, error_kind: &str, expected_error: &str) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !stderr.contains("Dry run - no files will be modified"), + "dry-run configuration output should not be printed on {error_kind} error, stderr: \ + {stderr}" + ); + assert!( + !stderr.contains("Crates to build:"), + "dry-run configuration output should not be printed on {error_kind} error, stderr: \ + {stderr}" + ); + assert!( + stderr.contains(expected_error), + "unexpected stderr: {stderr}" + ); +} + +pub(crate) fn assert_cli_exits_successfully(cli_world: &CliWorld) { + assert_exit_status(cli_world, true); +} + +pub(crate) fn assert_dry_run_output_is_shown(cli_world: &CliWorld) { + if cli_world.skip_assertions.get() { + return; + } + + let toolchain_slot = cli_world.toolchain.borrow(); + let Some(toolchain) = toolchain_slot.as_ref() else { + panic!("toolchain not set"); + }; + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + stderr.contains("Dry run - no files will be modified"), + "expected dry-run banner in stderr: {stderr}" + ); + assert!( + stderr.contains(&format!("Toolchain: {toolchain}")), + "expected toolchain line in stderr: {stderr}" + ); + assert!( + stderr.contains("Crates to build:"), + "expected crate list in stderr: {stderr}" + ); + assert!( + stderr.contains("whitaker_suite"), + "expected whitaker_suite in stderr: {stderr}" + ); + assert!( + !stderr.contains("module_max_lines"), + "individual lint crate should not appear in suite-only mode, stderr: {stderr}" + ); + + let temp_dir_slot = cli_world.temp_dir.borrow(); + let Some(temp_dir) = temp_dir_slot.as_ref() else { + panic!("temp dir not set"); + }; + let target_dir = temp_dir.path().to_string_lossy(); + let expected_target_dir = + expected_prebuilt_target_dir(toolchain).unwrap_or_else(|| target_dir.into_owned()); + assert!( + stderr.contains(&format!("Target directory: {expected_target_dir}")), + "expected target directory line in stderr: {stderr}" + ); +} + +pub(crate) fn assert_cli_exits_with_error(cli_world: &CliWorld) { + assert_exit_status(cli_world, false); +} + +pub(crate) fn assert_unknown_lint_message_is_shown(cli_world: &CliWorld) { + assert_error_output_is_shown( + cli_world, + "unknown-lint", + "lint crate nonexistent_lint not found", + ); +} + +pub(crate) fn assert_experimental_lint_opt_in_message_is_shown(cli_world: &CliWorld) { + assert_error_output_is_shown( + cli_world, + "experimental-lint", + "experimental lint crate rstest_helper_should_be_fixture requires --experimental", + ); +} + +pub(crate) fn assert_experimental_lint_dry_run_output_is_shown(cli_world: &CliWorld) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + stderr.contains("Dry run - no files will be modified"), + "expected dry-run banner in stderr: {stderr}" + ); + assert!( + stderr.contains("Crates to build:"), + "expected crate list in stderr: {stderr}" + ); + assert!( + stderr.contains("rstest_helper_should_be_fixture"), + "expected experimental lint crate in stderr: {stderr}" + ); + assert!( + !stderr.contains( + "experimental lint crate rstest_helper_should_be_fixture requires --experimental" + ), + "experimental opt-in error should not be printed when --experimental is set, stderr: \ + {stderr}" + ); +} + +pub(crate) fn assert_installation_succeeds_or_is_skipped(cli_world: &CliWorld) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + assert!( + output.status.success(), + "installation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +pub(crate) fn assert_suite_library_is_staged(cli_world: &CliWorld) { + if cli_world.skip_assertions.get() { + return; + } + + let output = get_output(cli_world); + let stderr = String::from_utf8_lossy(&output.stderr); + let channel_slot = cli_world.toolchain.borrow(); + let Some(channel) = channel_slot.as_ref() else { + panic!("toolchain not set"); + }; + let needle = format!("whitaker_suite@{channel}"); + + if stderr.contains(PREBUILT_INSTALL_MARKER) + && let Some(dir) = expected_prebuilt_target_dir(channel) + { + let prebuilt_path = PathBuf::from(&dir); + let matches = matching_files(&prebuilt_path, &needle); + assert!( + !matches.is_empty(), + "prebuilt marker found in stderr but no library matching '{needle}' in {}, \ + entries={:?}", + prebuilt_path.display(), + matching_files(&prebuilt_path, ""), + ); + return; + } + + let temp_dir_slot = cli_world.temp_dir.borrow(); + let Some(temp_dir) = temp_dir_slot.as_ref() else { + panic!("temp dir not set"); + }; + let staging_dir = temp_dir.path().join(channel).join("release"); + let matches = matching_files(&staging_dir, &needle); + + assert!( + matches.len() == 1, + "expected exactly one suite library matching '{needle}' in {}, matches={matches:?}, \ + entries={:?}, stdout={}, stderr={stderr}", + staging_dir.display(), + matching_files(&staging_dir, ""), + String::from_utf8_lossy(&output.stdout), + ); +} diff --git a/installer/tests/behaviour_core.rs b/installer/tests/behaviour_core.rs index 40f24f40..61e6be47 100644 --- a/installer/tests/behaviour_core.rs +++ b/installer/tests/behaviour_core.rs @@ -3,17 +3,24 @@ //! These scenarios validate crate resolution, crate name validation, toolchain //! parsing, and shell snippet generation using rstest-bdd. +use std::cell::{Cell, RefCell}; + use camino::Utf8PathBuf; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; -use whitaker_installer::crate_name::CrateName; -use whitaker_installer::output::ShellSnippet; -use whitaker_installer::resolution::{ - CrateResolutionOptions, EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE, resolve_crates, - validate_crate_names, +use whitaker_installer::{ + crate_name::CrateName, + output::ShellSnippet, + resolution::{ + CrateResolutionOptions, + EXPERIMENTAL_LINT_CRATES, + LINT_CRATES, + SUITE_CRATE, + resolve_crates, + validate_crate_names, + }, + toolchain::parse_toolchain_channel, }; -use whitaker_installer::toolchain::parse_toolchain_channel; // --------------------------------------------------------------------------- // Crate resolution world @@ -27,10 +34,9 @@ struct CrateResolutionWorld { resolved: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn crate_world() -> CrateResolutionWorld { - CrateResolutionWorld::default() -} +fn crate_world() -> CrateResolutionWorld { CrateResolutionWorld::default() } #[given("no specific lints are requested")] fn given_no_specific_lints(crate_world: &CrateResolutionWorld) { @@ -134,10 +140,9 @@ struct ValidationWorld { error_message: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn validation_world() -> ValidationWorld { - ValidationWorld::default() -} +fn validation_world() -> ValidationWorld { ValidationWorld::default() } #[given("a list of valid crate names")] fn given_valid_names(validation_world: &ValidationWorld) { @@ -197,8 +202,10 @@ fn then_validation_fails(validation_world: &ValidationWorld) { #[then("validation fails with an experimental opt-in error")] fn then_validation_fails_experimental_opt_in(validation_world: &ValidationWorld) { assert_eq!(validation_world.result.get(), Some(false)); - let error_message = validation_world.error_message.borrow(); - let error_message = error_message.as_ref().expect("error message should be set"); + let error_slot = validation_world.error_message.borrow(); + let Some(error_message) = error_slot.as_ref() else { + panic!("error message should be set"); + }; assert!( error_message.contains( "experimental lint crate rstest_helper_should_be_fixture requires --experimental" @@ -218,10 +225,9 @@ struct ToolchainWorld { error: Cell, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn toolchain_world() -> ToolchainWorld { - ToolchainWorld::default() -} +fn toolchain_world() -> ToolchainWorld { ToolchainWorld::default() } #[given("a rust-toolchain.toml with standard format")] fn given_standard_toolchain(toolchain_world: &ToolchainWorld) { @@ -286,10 +292,9 @@ struct SnippetWorld { snippet: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn snippet_world() -> SnippetWorld { - SnippetWorld::default() -} +fn snippet_world() -> SnippetWorld { SnippetWorld::default() } #[given("a target library path")] fn given_library_path(snippet_world: &SnippetWorld) { @@ -306,25 +311,39 @@ fn when_snippets_generated(snippet_world: &SnippetWorld) { snippet_world.snippet.replace(Some(snippet)); } -#[then("bash snippet uses export syntax")] -fn then_bash_export(snippet_world: &SnippetWorld) { +/// Check a shell snippet field against an expected prefix. +fn ensure_snippet_prefix( + snippet_world: &SnippetWorld, + field: impl FnOnce(&ShellSnippet) -> &str, + prefix: &str, +) -> Result<(), String> { let snippet = snippet_world.snippet.borrow(); - let s = snippet.as_ref().expect("snippet should exist"); - assert!(s.bash.starts_with("export ")); + let s = snippet + .as_ref() + .ok_or_else(|| String::from("snippet should exist"))?; + let value = field(s); + if value.starts_with(prefix) { + Ok(()) + } else { + Err(format!( + "expected snippet to start with '{prefix}': {value}" + )) + } +} + +#[then("bash snippet uses export syntax")] +fn then_bash_export(snippet_world: &SnippetWorld) -> Result<(), String> { + ensure_snippet_prefix(snippet_world, |s| &s.bash, "export ") } #[then("fish snippet uses set -gx syntax")] -fn then_fish_set(snippet_world: &SnippetWorld) { - let snippet = snippet_world.snippet.borrow(); - let s = snippet.as_ref().expect("snippet should exist"); - assert!(s.fish.starts_with("set -gx ")); +fn then_fish_set(snippet_world: &SnippetWorld) -> Result<(), String> { + ensure_snippet_prefix(snippet_world, |s| &s.fish, "set -gx ") } #[then("PowerShell snippet uses $env syntax")] -fn then_powershell_env(snippet_world: &SnippetWorld) { - let snippet = snippet_world.snippet.borrow(); - let s = snippet.as_ref().expect("snippet should exist"); - assert!(s.powershell.starts_with("$env:")); +fn then_powershell_env(snippet_world: &SnippetWorld) -> Result<(), String> { + ensure_snippet_prefix(snippet_world, |s| &s.powershell, "$env:") } // --------------------------------------------------------------------------- @@ -337,9 +356,7 @@ fn scenario_resolve_suite_only_by_default(crate_world: CrateResolutionWorld) { } #[scenario(path = "tests/features/installer.feature", index = 1)] -fn scenario_resolve_individual_lints(crate_world: CrateResolutionWorld) { - let _ = crate_world; -} +fn scenario_resolve_individual_lints(crate_world: CrateResolutionWorld) { let _ = crate_world; } #[scenario(path = "tests/features/installer.feature", index = 2)] fn scenario_resolve_individual_lints_with_experimental(crate_world: CrateResolutionWorld) { @@ -347,39 +364,25 @@ fn scenario_resolve_individual_lints_with_experimental(crate_world: CrateResolut } #[scenario(path = "tests/features/installer.feature", index = 3)] -fn scenario_resolve_specific_lints(crate_world: CrateResolutionWorld) { - let _ = crate_world; -} +fn scenario_resolve_specific_lints(crate_world: CrateResolutionWorld) { let _ = crate_world; } #[scenario(path = "tests/features/installer.feature", index = 4)] -fn scenario_validate_known_names(validation_world: ValidationWorld) { - let _ = validation_world; -} +fn scenario_validate_known_names(validation_world: ValidationWorld) { let _ = validation_world; } #[scenario(path = "tests/features/installer.feature", index = 5)] -fn scenario_reject_unknown_names(validation_world: ValidationWorld) { - let _ = validation_world; -} +fn scenario_reject_unknown_names(validation_world: ValidationWorld) { let _ = validation_world; } #[scenario(path = "tests/features/installer.feature", index = 6)] -fn scenario_parse_standard_toolchain(toolchain_world: ToolchainWorld) { - let _ = toolchain_world; -} +fn scenario_parse_standard_toolchain(toolchain_world: ToolchainWorld) { let _ = toolchain_world; } #[scenario(path = "tests/features/installer.feature", index = 7)] -fn scenario_parse_top_level_channel(toolchain_world: ToolchainWorld) { - let _ = toolchain_world; -} +fn scenario_parse_top_level_channel(toolchain_world: ToolchainWorld) { let _ = toolchain_world; } #[scenario(path = "tests/features/installer.feature", index = 8)] -fn scenario_reject_missing_channel(toolchain_world: ToolchainWorld) { - let _ = toolchain_world; -} +fn scenario_reject_missing_channel(toolchain_world: ToolchainWorld) { let _ = toolchain_world; } #[scenario(path = "tests/features/installer.feature", index = 9)] -fn scenario_generate_shell_snippets(snippet_world: SnippetWorld) { - let _ = snippet_world; -} +fn scenario_generate_shell_snippets(snippet_world: SnippetWorld) { let _ = snippet_world; } #[scenario(path = "tests/features/installer.feature", index = 19)] fn scenario_validate_experimental_names_with_opt_in(validation_world: ValidationWorld) { diff --git a/installer/tests/behaviour_dependency_binaries.rs b/installer/tests/behaviour_dependency_binaries.rs index e9281be5..2a93ceeb 100644 --- a/installer/tests/behaviour_dependency_binaries.rs +++ b/installer/tests/behaviour_dependency_binaries.rs @@ -1,34 +1,54 @@ //! Behaviour tests for dependency-binary installation and provenance output. +use std::path::{Path, PathBuf}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::path::Path; -use std::path::PathBuf; use temp_env::with_var; -use whitaker_installer::dependency_binaries::{ - DependencyBinary, DependencyBinaryInstallError, DependencyBinaryInstaller, - required_dependency_binaries, -}; -use whitaker_installer::dependency_packaging::render_provenance_markdown; -use whitaker_installer::deps::{ - DependencyInstallOptions, DylintToolStatus, install_dylint_tools_with_options, -}; -use whitaker_installer::dirs::BaseDirs; -use whitaker_installer::installer_packaging::TargetTriple; -use whitaker_installer::test_support::env_test_guard; -use whitaker_installer::test_utils::{ - StubDirs, StubExecutor, - dependency_binary_helpers::{ - ExpectedCallConfig, expected_calls, path_binary_location, write_fake_binary, +use whitaker_installer::{ + dependency_binaries::{ + DependencyBinary, + DependencyBinaryInstallError, + DependencyBinaryInstaller, + required_dependency_binaries, + }, + dependency_packaging::render_provenance_markdown, + deps::{DependencyInstallOptions, DylintToolStatus, install_dylint_tools_with_options}, + dirs::BaseDirs, + installer_packaging::TargetTriple, + test_support::env_test_guard, + test_utils::{ + StubDirs, + StubExecutor, + dependency_binary_helpers::{ + ExpectedCallConfig, + RepositoryVerification, + expected_calls, + path_binary_location, + write_fake_binary, + }, }, }; +/// Outcome the stubbed repository installer should simulate. enum RepositoryInstallerBehaviour { + /// Installation succeeds and the staged binary verifies. Success, + /// Installation succeeds but verification of the staged binary fails. + SuccessWithFailedVerification, + /// The release asset is absent from the repository. NotFound, + /// Installation fails with the given message. Failure(String), } +impl RepositoryInstallerBehaviour { + /// Whether the stub stages a runnable binary for this behaviour. + const fn installs_binary(&self) -> bool { + matches!(*self, Self::Success | Self::SuccessWithFailedVerification) + } +} + struct StubRepositoryInstaller { behaviour: RepositoryInstallerBehaviour, } @@ -41,20 +61,23 @@ impl DependencyBinaryInstaller for StubRepositoryInstaller { dirs: &dyn BaseDirs, ) -> std::result::Result { match &self.behaviour { - RepositoryInstallerBehaviour::Success => dirs.bin_dir().map_or_else( - || Err(DependencyBinaryInstallError::MissingBinDir), - |bin_dir| { - // Stage a runnable fake at the returned path: dylint-link - // verification probes the extracted binary directly. The - // platform suffix keeps the fake executable on Windows. - let installed_path = path_binary_location( - &bin_dir, - &format!("{}-{}", dependency.package(), target), - ); - write_fake_binary(&installed_path, true); - Ok(installed_path) - }, - ), + RepositoryInstallerBehaviour::Success + | RepositoryInstallerBehaviour::SuccessWithFailedVerification => { + dirs.executables().map_or_else( + || Err(DependencyBinaryInstallError::MissingBinDir), + |bin_dir| { + // Stage a runnable fake at the returned path: dylint-link + // verification probes the extracted binary directly. The + // platform suffix keeps the fake executable on Windows. + let installed_path = path_binary_location( + &bin_dir, + &format!("{}-{}", dependency.package(), target), + ); + write_fake_binary(&installed_path, true)?; + Ok(installed_path) + }, + ) + } RepositoryInstallerBehaviour::NotFound => Err(DependencyBinaryInstallError::NotFound { url: format!( "{}/releases/download/v{}/{}", @@ -77,7 +100,6 @@ impl DependencyBinaryInstaller for StubRepositoryInstaller { struct DependencyBinaryWorld { missing_tool: Option, repository_behaviour: Option, - should_repository_verification_fail: bool, expect_missing_dylint_link: bool, is_binstall_available: bool, cargo_binstall_failure: Option, @@ -89,10 +111,9 @@ struct DependencyBinaryWorld { dependencies: Vec, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> DependencyBinaryWorld { - DependencyBinaryWorld::default() -} +fn world() -> DependencyBinaryWorld { DependencyBinaryWorld::default() } #[given("the missing tool is \"{tool}\"")] fn given_missing_tool(world: &mut DependencyBinaryWorld, tool: String) { @@ -115,8 +136,7 @@ fn given_repository_failure(world: &mut DependencyBinaryWorld, message: String) #[given("the repository installer succeeds but verification fails")] fn given_repository_verification_failure(world: &mut DependencyBinaryWorld) { - world.repository_behaviour = Some(RepositoryInstallerBehaviour::Success); - world.should_repository_verification_fail = true; + world.repository_behaviour = Some(RepositoryInstallerBehaviour::SuccessWithFailedVerification); } #[given("dylint-link is missing from PATH after installation")] @@ -150,10 +170,11 @@ fn given_unsupported_target(world: &mut DependencyBinaryWorld) { } #[given("the dependency manifest is loaded")] -fn given_manifest_loaded(world: &mut DependencyBinaryWorld) { +fn given_manifest_loaded(world: &mut DependencyBinaryWorld) -> Result<(), String> { world.dependencies = required_dependency_binaries() - .expect("dependency manifest should load") + .map_err(|error| format!("dependency manifest should load: {error}"))? .to_vec(); + Ok(()) } fn build_stub_executor(world: &DependencyBinaryWorld, tool: &str) -> StubExecutor { @@ -161,51 +182,63 @@ fn build_stub_executor(world: &DependencyBinaryWorld, tool: &str) -> StubExecuto world.repository_behaviour, Some(RepositoryInstallerBehaviour::NotFound) ); - let expect_repository_verification = matches!( + let expect_repository_verification = world + .repository_behaviour + .as_ref() + .is_some_and(RepositoryInstallerBehaviour::installs_binary) + && !world.is_unsupported_target; + let should_verification_fail = matches!( world.repository_behaviour, - Some(RepositoryInstallerBehaviour::Success) - ) && !world.is_unsupported_target; + Some(RepositoryInstallerBehaviour::SuccessWithFailedVerification) + ); + let repository_verification = match (expect_repository_verification, should_verification_fail) { + (false, _) => RepositoryVerification::Skip, + (true, true) => RepositoryVerification::Fails, + (true, false) => RepositoryVerification::Succeeds, + }; StubExecutor::new(expected_calls( tool, - ExpectedCallConfig { + &ExpectedCallConfig { is_binstall_available: world.is_binstall_available, has_repository_context: !world.is_unsupported_target, is_repository_asset_missing, - should_verify_repository_install: expect_repository_verification, - is_repository_verification_failing: world.should_repository_verification_fail, + repository_verification, cargo_binstall_failure: world.cargo_binstall_failure.as_deref(), cargo_install_failure: world.cargo_install_failure.as_deref(), }, )) } +/// Stages a runnable `dylint-link` fake in the executables directory so PATH +/// lookups succeed. +fn stage_dylint_link_on_path(bin_dir: &Path) -> Result<(), String> { + #[cfg(windows)] + let dylint_link_path = bin_dir.join("dylint-link.cmd"); + #[cfg(not(windows))] + let dylint_link_path = bin_dir.join("dylint-link"); + write_fake_binary(&dylint_link_path, true) + .map_err(|error| format!("write fake dylint-link: {error}")) +} + fn run_install_with_dylint_link_on_path( - expect_missing_dylint_link: bool, bin_dir: &Path, run_install: impl FnOnce() -> std::result::Result<(), whitaker_installer::error::InstallerError>, ) -> std::result::Result<(), whitaker_installer::error::InstallerError> { let _guard = env_test_guard(); - if !expect_missing_dylint_link { - #[cfg(windows)] - let dylint_link_path = bin_dir.join("dylint-link.cmd"); - #[cfg(not(windows))] - let dylint_link_path = bin_dir.join("dylint-link"); - write_fake_binary(&dylint_link_path, true); - } with_var("PATH", Some(bin_dir), run_install) } #[when("dependency installation runs")] -fn when_dependency_installation_runs(world: &mut DependencyBinaryWorld) { +fn when_dependency_installation_runs(world: &mut DependencyBinaryWorld) -> Result<(), String> { let tool = world .missing_tool .clone() - .expect("missing tool should be configured"); + .ok_or_else(|| String::from("missing tool should be configured"))?; let executor = build_stub_executor(world, &tool); let repository_installer = StubRepositoryInstaller { - behaviour: world.repository_behaviour.take().unwrap_or( - RepositoryInstallerBehaviour::Failure("missing repository".to_owned()), - ), + behaviour: world.repository_behaviour.take().unwrap_or_else(|| { + RepositoryInstallerBehaviour::Failure("missing repository".to_owned()) + }), }; let status = DylintToolStatus { cargo_dylint: tool != "cargo-dylint", @@ -215,19 +248,27 @@ fn when_dependency_installation_runs(world: &mut DependencyBinaryWorld) { let target = if world.is_unsupported_target { None } else { - Some(TargetTriple::try_from("x86_64-unknown-linux-gnu").expect("valid target")) + Some( + TargetTriple::try_from("x86_64-unknown-linux-gnu") + .map_err(|error| format!("valid target: {error}"))?, + ) }; - let bin_dir_temp = tempfile::tempdir().expect("bin dir tempdir should be created"); + let bin_dir_temp = tempfile::tempdir() + .map_err(|error| format!("bin dir tempdir should be created: {error}"))?; let bin_dir = bin_dir_temp.path().to_path_buf(); let dirs = StubDirs { bin_dir: Some(bin_dir.clone()), }; + let is_dylint_link = tool == "dylint-link"; + if is_dylint_link && !world.expect_missing_dylint_link { + stage_dylint_link_on_path(&bin_dir)?; + } let run_install = || { install_dylint_tools_with_options( &executor, &status, &mut world.stderr, - DependencyInstallOptions { + &DependencyInstallOptions { dirs: &dirs, repository_installer: &repository_installer, target, @@ -235,16 +276,13 @@ fn when_dependency_installation_runs(world: &mut DependencyBinaryWorld) { }, ) }; - world.install_result = Some(if tool == "dylint-link" { - run_install_with_dylint_link_on_path( - world.expect_missing_dylint_link, - &bin_dir, - run_install, - ) + world.install_result = Some(if is_dylint_link { + run_install_with_dylint_link_on_path(&bin_dir, run_install) } else { run_install() }); executor.assert_finished(); + Ok(()) } #[when("provenance markdown is rendered")] @@ -252,22 +290,36 @@ fn when_provenance_markdown_rendered(world: &mut DependencyBinaryWorld) { world.provenance = Some(render_provenance_markdown(&world.dependencies)); } -#[then("the install succeeds")] -fn then_install_succeeds(world: &mut DependencyBinaryWorld) { - let result = world +/// Borrows the recorded install outcome, failing when the When step has not run. +fn install_result( + world: &DependencyBinaryWorld, +) -> Result<&std::result::Result<(), whitaker_installer::error::InstallerError>, String> { + world .install_result .as_ref() - .expect("install result should exist"); - assert!(result.is_ok(), "expected success, got {result:?}"); + .ok_or_else(|| String::from("install result should exist")) +} + +#[then("the install succeeds")] +fn then_install_succeeds(world: &mut DependencyBinaryWorld) -> Result<(), String> { + let result = install_result(world)?; + match result { + Ok(()) => Ok(()), + Err(error) => Err(format!("expected success, got {error:?}")), + } } #[then("stderr contains \"{expected}\"")] -fn then_stderr_contains(world: &mut DependencyBinaryWorld, expected: String) { - let stderr = String::from_utf8(world.stderr.clone()).expect("stderr should be UTF-8"); - assert!( - stderr.contains(&expected), - "expected stderr to contain {expected:?}, got {stderr:?}" - ); +fn then_stderr_contains(world: &mut DependencyBinaryWorld, expected: String) -> Result<(), String> { + let stderr = String::from_utf8(world.stderr.clone()) + .map_err(|error| format!("stderr should be UTF-8: {error}"))?; + if stderr.contains(&expected) { + Ok(()) + } else { + Err(format!( + "expected stderr to contain {expected:?}, got {stderr:?}" + )) + } } #[then("the install fails for \"{tool}\" with message containing \"{expected}\"")] @@ -275,52 +327,55 @@ fn then_install_fails_with_message( world: &mut DependencyBinaryWorld, tool: String, expected: String, -) { - let result = world - .install_result - .as_ref() - .expect("install result should exist"); - match result { - Err(whitaker_installer::error::InstallerError::DependencyInstall { - tool: actual_tool, - message, - }) => { - assert_eq!(actual_tool, &tool); - assert!( - message.contains(&expected), - "expected error message to contain {expected:?}, got {message:?}" - ); - } - other => panic!("expected dependency install error, got {other:?}"), +) -> Result<(), String> { + let result = install_result(world)?; + let Err(whitaker_installer::error::InstallerError::DependencyInstall { + tool: actual_tool, + message, + }) = result + else { + return Err(format!("expected dependency install error, got {result:?}")); + }; + if actual_tool != &tool { + return Err(format!( + "expected failure for {tool:?}, got {actual_tool:?}" + )); + } + if message.contains(&expected) { + Ok(()) + } else { + Err(format!( + "expected error message to contain {expected:?}, got {message:?}" + )) } } #[then("the provenance contains \"{expected}\"")] -fn then_provenance_contains(world: &mut DependencyBinaryWorld, expected: String) { +fn then_provenance_contains( + world: &mut DependencyBinaryWorld, + expected: String, +) -> Result<(), String> { let provenance = world .provenance .as_ref() - .expect("provenance should have been rendered"); - assert!( - provenance.contains(&expected), - "expected provenance to contain {expected:?}, got {provenance:?}" - ); + .ok_or_else(|| String::from("provenance should have been rendered"))?; + if provenance.contains(&expected) { + Ok(()) + } else { + Err(format!( + "expected provenance to contain {expected:?}, got {provenance:?}" + )) + } } #[scenario(path = "tests/features/dependency_binaries.feature", index = 0)] -fn scenario_install_cargo_dylint_from_repository(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_install_cargo_dylint_from_repository(world: DependencyBinaryWorld) { let _ = world; } #[scenario(path = "tests/features/dependency_binaries.feature", index = 1)] -fn scenario_install_dylint_link_from_repository(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_install_dylint_link_from_repository(world: DependencyBinaryWorld) { let _ = world; } #[scenario(path = "tests/features/dependency_binaries.feature", index = 2)] -fn scenario_repository_falls_back_to_binstall(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_repository_falls_back_to_binstall(world: DependencyBinaryWorld) { let _ = world; } #[scenario(path = "tests/features/dependency_binaries.feature", index = 3)] fn scenario_repository_and_binstall_and_cargo_all_fail(world: DependencyBinaryWorld) { @@ -345,21 +400,13 @@ fn scenario_repository_verification_failure_uses_binstall(world: DependencyBinar } #[scenario(path = "tests/features/dependency_binaries.feature", index = 7)] -fn scenario_unsupported_target_uses_binstall(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_unsupported_target_uses_binstall(world: DependencyBinaryWorld) { let _ = world; } #[scenario(path = "tests/features/dependency_binaries.feature", index = 8)] -fn scenario_repository_success_without_binstall(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_repository_success_without_binstall(world: DependencyBinaryWorld) { let _ = world; } #[scenario(path = "tests/features/dependency_binaries.feature", index = 9)] -fn scenario_provenance_lists_both_dependencies(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_provenance_lists_both_dependencies(world: DependencyBinaryWorld) { let _ = world; } #[scenario(path = "tests/features/dependency_binaries.feature", index = 10)] -fn scenario_dylint_link_missing_after_install_fails(world: DependencyBinaryWorld) { - let _ = world; -} +fn scenario_dylint_link_missing_after_install_fails(world: DependencyBinaryWorld) { let _ = world; } diff --git a/installer/tests/behaviour_docs.rs b/installer/tests/behaviour_docs.rs index d5e86c31..edac722a 100644 --- a/installer/tests/behaviour_docs.rs +++ b/installer/tests/behaviour_docs.rs @@ -6,10 +6,11 @@ mod doc_extraction; +use std::cell::{Ref, RefCell}; + use doc_extraction::extraction::{DOC_TOML_BLOCKS, find_block_containing}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::RefCell; use toml::Table; // --------------------------------------------------------------------------- @@ -24,10 +25,9 @@ struct TomlWorld { error: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn toml_world() -> TomlWorld { - TomlWorld::default() -} +fn toml_world() -> TomlWorld { TomlWorld::default() } /// Helper function to set TOML content in the world fixture. fn set_toml_content(toml_world: &TomlWorld, content: &str) { @@ -46,7 +46,7 @@ fn given_suite_only_metadata(toml_world: &TomlWorld) { } #[given("a workspace metadata example for individual crates")] -fn given_individual_crates_metadata(toml_world: &TomlWorld) { +fn given_individual_crates_metadata(toml_world: &TomlWorld) -> Result<(), String> { // Matches the individual crates example showing explicit lint patterns let block = DOC_TOML_BLOCKS .iter() @@ -55,9 +55,10 @@ fn given_individual_crates_metadata(toml_world: &TomlWorld) { && !b.contains("tag =") && !b.contains("rev =") }) - .expect("no individual crates TOML block found") + .ok_or_else(|| String::from("no individual crates TOML block found"))? .clone(); set_toml_content(toml_world, &block); + Ok(()) } #[given("a workspace metadata example with tag pinning")] @@ -111,8 +112,14 @@ fn when_toml_parsed(toml_world: &TomlWorld) { // Helper functions for TOML navigation // --------------------------------------------------------------------------- -/// Get a reference to the first library entry in workspace.metadata.dylint.libraries. -fn get_first_library(table: &Table) -> &toml::Value { +/// Borrows the parsed TOML table, failing when the When step has not run. +fn parsed_table(toml_world: &TomlWorld) -> Result, String> { + Ref::filter_map(toml_world.parsed.borrow(), Option::as_ref) + .map_err(|_| String::from("expected parsed TOML")) +} + +/// Get a reference to the first library entry in `workspace.metadata.dylint.libraries`. +fn get_first_library(table: &Table) -> Result<&toml::Value, String> { table .get("workspace") .and_then(|w| w.get("metadata")) @@ -120,24 +127,37 @@ fn get_first_library(table: &Table) -> &toml::Value { .and_then(|d| d.get("libraries")) .and_then(|l| l.as_array()) .and_then(|arr| arr.first()) - .expect("expected workspace.metadata.dylint.libraries[0]") + .ok_or_else(|| String::from("expected workspace.metadata.dylint.libraries[0]")) } /// Get a string field from the first library entry. -fn get_library_string_field<'a>(table: &'a Table, field: &str) -> &'a str { - get_first_library(table) +fn get_library_string_field<'a>(table: &'a Table, field: &str) -> Result<&'a str, String> { + get_first_library(table)? .get(field) .and_then(|v| v.as_str()) - .unwrap_or_else(|| panic!("expected libraries[0].{field} to be a string")) + .ok_or_else(|| format!("expected libraries[0].{field} to be a string")) } /// Get an integer configuration value from a nested table. -fn get_config_integer(table: &Table, section: &str, key: &str) -> i64 { +fn get_config_integer(table: &Table, section: &str, key: &str) -> Result { table .get(section) .and_then(|s| s.get(key)) - .and_then(|v| v.as_integer()) - .unwrap_or_else(|| panic!("expected {section}.{key} to be an integer")) + .and_then(toml::Value::as_integer) + .ok_or_else(|| format!("expected {section}.{key} to be an integer")) +} + +/// Compare two values for equality, reporting a mismatch as an error. +fn ensure_eq(actual: &T, expected: &U, context: &str) -> Result<(), String> +where + T: PartialEq + std::fmt::Debug + ?Sized, + U: std::fmt::Debug + ?Sized, +{ + if actual == expected { + Ok(()) + } else { + Err(format!("{context}: expected {expected:?}, got {actual:?}")) + } } // --------------------------------------------------------------------------- @@ -145,147 +165,143 @@ fn get_config_integer(table: &Table, section: &str, key: &str) -> i64 { // --------------------------------------------------------------------------- #[then("parsing succeeds")] -fn then_parsing_succeeds(toml_world: &TomlWorld) { +fn then_parsing_succeeds(toml_world: &TomlWorld) -> Result<(), String> { let error = toml_world.error.borrow(); - assert!( - error.is_none(), - "expected TOML to parse successfully, but got error: {:?}", - error - ); + error.as_ref().map_or(Ok(()), |message| { + Err(format!( + "expected TOML to parse successfully, but got error: {message}" + )) + }) } #[then("the libraries pattern is {expected}")] -fn then_libraries_pattern_is(toml_world: &TomlWorld, expected: String) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let pattern = get_library_string_field(table, "pattern"); - - assert_eq!(pattern, expected); +fn then_libraries_pattern_is(toml_world: &TomlWorld, expected: String) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let pattern = get_library_string_field(&table, "pattern")?; + ensure_eq(pattern, expected.as_str(), "libraries[0].pattern") } #[then("the libraries pattern starts with {prefix}")] -fn then_libraries_pattern_starts_with(toml_world: &TomlWorld, prefix: String) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let pattern = get_library_string_field(table, "pattern"); - - assert!( - pattern.starts_with(&prefix), - "expected pattern to start with '{prefix}', got '{pattern}'" - ); +fn then_libraries_pattern_starts_with( + toml_world: &TomlWorld, + prefix: String, +) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let pattern = get_library_string_field(&table, "pattern")?; + if pattern.starts_with(&prefix) { + Ok(()) + } else { + Err(format!( + "expected pattern to start with '{prefix}', got '{pattern}'" + )) + } } #[then("the tag field is present")] -fn then_tag_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let tag = get_library_string_field(table, "tag"); - - assert_eq!(tag, "v0.1.0", "expected tag == \"v0.1.0\""); +fn then_tag_present(toml_world: &TomlWorld) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let tag = get_library_string_field(&table, "tag")?; + ensure_eq(tag, "v0.1.0", "libraries[0].tag") } #[then("the revision field is present")] -fn then_revision_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let rev = get_library_string_field(table, "rev"); - - assert_eq!(rev, "abc123def456", "expected rev == \"abc123def456\""); +fn then_revision_present(toml_world: &TomlWorld) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let rev = get_library_string_field(&table, "rev")?; + ensure_eq(rev, "abc123def456", "libraries[0].rev") } #[then("the path field is present")] -fn then_path_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let path = get_library_string_field(table, "path"); - - assert!( - path.contains("/whitaker/lints/") - && path.contains("/nightly-") - && path.contains("/x86_64-unknown-linux-gnu/lib"), - "expected path to contain prebuilt lints layout, got: {path}" - ); +fn then_path_present(toml_world: &TomlWorld) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let path = get_library_string_field(&table, "path")?; + let has_prebuilt_layout = path.contains("/whitaker/lints/") + && path.contains("/nightly-") + && path.contains("/x86_64-unknown-linux-gnu/lib"); + if has_prebuilt_layout { + Ok(()) + } else { + Err(format!( + "expected path to contain prebuilt lints layout, got: {path}" + )) + } } #[then("module_max_lines configuration is present")] -fn then_module_max_lines_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let max_lines = get_config_integer(table, "module_max_lines", "max_lines"); - - assert_eq!(max_lines, 500); +fn then_module_max_lines_present(toml_world: &TomlWorld) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let max_lines = get_config_integer(&table, "module_max_lines", "max_lines")?; + ensure_eq(&max_lines, &500, "module_max_lines.max_lines") } #[then("conditional_max_n_branches configuration is present")] -fn then_conditional_max_branches_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); - - let max_branches = get_config_integer(table, "conditional_max_n_branches", "max_branches"); - - assert_eq!(max_branches, 3); +fn then_conditional_max_branches_present(toml_world: &TomlWorld) -> Result<(), String> { + let table = parsed_table(toml_world)?; + let max_branches = get_config_integer(&table, "conditional_max_n_branches", "max_branches")?; + ensure_eq(&max_branches, &3, "conditional_max_n_branches.max_branches") } #[then("no_expect_outside_tests additional_test_attributes configuration is present")] -fn then_no_expect_outside_tests_additional_test_attributes_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); +fn then_no_expect_outside_tests_additional_test_attributes_present( + toml_world: &TomlWorld, +) -> Result<(), String> { + let table = parsed_table(toml_world)?; let attributes = table .get("no_expect_outside_tests") .and_then(|t| t.get("additional_test_attributes")) .and_then(|a| a.as_array()) - .expect("expected no_expect_outside_tests.additional_test_attributes array"); + .ok_or_else(|| { + String::from("expected no_expect_outside_tests.additional_test_attributes array") + })?; - let values: Vec<_> = attributes + let values = attributes .iter() .map(|v| { - v.as_str() - .expect("expected additional_test_attributes entries to be strings") + v.as_str().ok_or_else(|| { + String::from("expected additional_test_attributes entries to be strings") + }) }) - .collect(); + .collect::, _>>()?; - assert_eq!( - values, - vec!["my_framework::test", "wasm_bindgen_test"], - "unexpected no_expect_outside_tests.additional_test_attributes" - ); + ensure_eq( + values.as_slice(), + ["my_framework::test", "wasm_bindgen_test"].as_slice(), + "no_expect_outside_tests.additional_test_attributes", + ) } #[then("no_unwrap_or_else_panic allow_in_main configuration is present")] -fn then_no_unwrap_or_else_panic_allow_in_main_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); +fn then_no_unwrap_or_else_panic_allow_in_main_present( + toml_world: &TomlWorld, +) -> Result<(), String> { + let table = parsed_table(toml_world)?; let allow_in_main = table .get("no_unwrap_or_else_panic") .and_then(|t| t.get("allow_in_main")) - .and_then(|v| v.as_bool()) - .expect("expected no_unwrap_or_else_panic.allow_in_main boolean"); - - assert!( - allow_in_main, - "expected no_unwrap_or_else_panic.allow_in_main to be true" - ); + .and_then(toml::Value::as_bool) + .ok_or_else(|| String::from("expected no_unwrap_or_else_panic.allow_in_main boolean"))?; + + if allow_in_main { + Ok(()) + } else { + Err(String::from( + "expected no_unwrap_or_else_panic.allow_in_main to be true", + )) + } } #[then("locale configuration is present")] -fn then_locale_configuration_present(toml_world: &TomlWorld) { - let parsed = toml_world.parsed.borrow(); - let table = parsed.as_ref().expect("expected parsed TOML"); +fn then_locale_configuration_present(toml_world: &TomlWorld) -> Result<(), String> { + let table = parsed_table(toml_world)?; let locale = table .get("locale") .and_then(|v| v.as_str()) - .expect("expected locale string"); + .ok_or_else(|| String::from("expected locale string"))?; - assert_eq!(locale, "cy", "expected locale == \"cy\""); + ensure_eq(locale, "cy", "locale") } // --------------------------------------------------------------------------- @@ -296,46 +312,34 @@ fn then_locale_configuration_present(toml_world: &TomlWorld) { path = "tests/features/consumer_guidance.feature", name = "Suite-only workspace metadata is valid TOML" )] -fn scenario_suite_only_metadata(toml_world: TomlWorld) { - let _ = toml_world; -} +fn scenario_suite_only_metadata(toml_world: TomlWorld) { let _ = toml_world; } #[scenario( path = "tests/features/consumer_guidance.feature", name = "Individual crates workspace metadata is valid TOML" )] -fn scenario_individual_crates_metadata(toml_world: TomlWorld) { - let _ = toml_world; -} +fn scenario_individual_crates_metadata(toml_world: TomlWorld) { let _ = toml_world; } #[scenario( path = "tests/features/consumer_guidance.feature", name = "Version-pinned workspace metadata with tag is valid TOML" )] -fn scenario_tag_pinning_metadata(toml_world: TomlWorld) { - let _ = toml_world; -} +fn scenario_tag_pinning_metadata(toml_world: TomlWorld) { let _ = toml_world; } #[scenario( path = "tests/features/consumer_guidance.feature", name = "Version-pinned workspace metadata with revision is valid TOML" )] -fn scenario_revision_pinning_metadata(toml_world: TomlWorld) { - let _ = toml_world; -} +fn scenario_revision_pinning_metadata(toml_world: TomlWorld) { let _ = toml_world; } #[scenario( path = "tests/features/consumer_guidance.feature", name = "Pre-built library path workspace metadata is valid TOML" )] -fn scenario_prebuilt_path_metadata(toml_world: TomlWorld) { - let _ = toml_world; -} +fn scenario_prebuilt_path_metadata(toml_world: TomlWorld) { let _ = toml_world; } #[scenario( path = "tests/features/consumer_guidance.feature", name = "dylint.toml lint configuration is valid TOML" )] -fn scenario_dylint_toml_config(toml_world: TomlWorld) { - let _ = toml_world; -} +fn scenario_dylint_toml_config(toml_world: TomlWorld) { let _ = toml_world; } diff --git a/installer/tests/behaviour_install_metrics.rs b/installer/tests/behaviour_install_metrics.rs index 940dbd01..10bc84d2 100644 --- a/installer/tests/behaviour_install_metrics.rs +++ b/installer/tests/behaviour_install_metrics.rs @@ -1,19 +1,21 @@ //! Behaviour tests for installer metrics recording. +use std::{path::PathBuf, time::Duration}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::path::PathBuf; -use std::time::Duration; use tempfile::TempDir; use whitaker_installer::install_metrics::{ - InstallMetrics, InstallMode, RecordOutcome, record_install_at_path, + InstallMetrics, + InstallMode, + RecordOutcome, + record_install_at_path, }; -const FLOAT_RATE_TOLERANCE: f64 = 1e-6; - #[derive(Default)] struct InstallMetricsWorld { - _temp_dir: Option, + /// Owns the scenario's temporary metrics directory so it outlives the run. + temp_dir: Option, metrics_path: Option, outcome: Option, last_error: Option, @@ -21,13 +23,45 @@ struct InstallMetricsWorld { summary_line: Option, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> InstallMetricsWorld { - InstallMetricsWorld::default() +fn world() -> InstallMetricsWorld { InstallMetricsWorld::default() } + +/// Compare two values for equality, reporting a mismatch as an error. +fn ensure_eq(actual: &T, expected: &U, context: &str) -> Result<(), String> +where + T: PartialEq + std::fmt::Debug + ?Sized, + U: std::fmt::Debug + ?Sized, +{ + if actual == expected { + Ok(()) + } else { + Err(format!("{context}: expected {expected:?}, got {actual:?}")) + } +} + +/// Borrow the configured metrics path, failing when no Given step has run. +fn metrics_path(world: &InstallMetricsWorld) -> Result<&std::path::Path, String> { + world + .metrics_path + .as_deref() + .ok_or_else(|| String::from("metrics path set")) } -fn record_mode(world: &mut InstallMetricsWorld, mode: InstallMode, millis: u64) { - let path = world.metrics_path.as_deref().expect("metrics path set"); +/// Borrow the aggregated metrics, failing when nothing has been recorded. +fn metrics(world: &InstallMetricsWorld) -> Result<&InstallMetrics, String> { + world + .in_memory_metrics + .as_ref() + .ok_or_else(|| String::from("metrics available")) +} + +fn record_mode( + world: &mut InstallMetricsWorld, + mode: InstallMode, + millis: u64, +) -> Result<(), String> { + let path = metrics_path(world)?; let result = record_install_at_path(path, mode, Duration::from_millis(millis)); match result { Ok(outcome) => { @@ -44,37 +78,42 @@ fn record_mode(world: &mut InstallMetricsWorld, mode: InstallMode, millis: u64) world.in_memory_metrics = None; } } + Ok(()) } #[given("an empty install metrics store")] -fn given_empty_store(world: &mut InstallMetricsWorld) { - let temp_dir = tempfile::tempdir().expect("create temp dir"); +fn given_empty_store(world: &mut InstallMetricsWorld) -> Result<(), String> { + let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; world.metrics_path = Some(temp_dir.path().join("metrics").join("install_metrics.json")); - world._temp_dir = Some(temp_dir); + world.temp_dir = Some(temp_dir); world.outcome = None; world.last_error = None; world.in_memory_metrics = None; world.summary_line = None; + Ok(()) } #[given("a corrupt install metrics store")] -fn given_corrupt_store(world: &mut InstallMetricsWorld) { - given_empty_store(world); - let path = world.metrics_path.as_deref().expect("metrics path set"); - std::fs::create_dir_all(path.parent().expect("metrics parent exists")).expect("create parent"); - std::fs::write(path, "{not valid json").expect("write corrupt file"); +fn given_corrupt_store(world: &mut InstallMetricsWorld) -> Result<(), String> { + given_empty_store(world)?; + let path = metrics_path(world)?; + let parent = path + .parent() + .ok_or_else(|| String::from("metrics parent exists"))?; + std::fs::create_dir_all(parent).map_err(|error| format!("create parent: {error}"))?; + std::fs::write(path, "{not valid json").map_err(|error| format!("write corrupt file: {error}")) } #[given("a blocked install metrics path")] -fn given_blocked_path(world: &mut InstallMetricsWorld) { - given_empty_store(world); - let path = world.metrics_path.as_deref().expect("metrics path set"); - std::fs::create_dir_all(path).expect("create blocking directory"); +fn given_blocked_path(world: &mut InstallMetricsWorld) -> Result<(), String> { + given_empty_store(world)?; + let path = metrics_path(world)?; + std::fs::create_dir_all(path).map_err(|error| format!("create blocking directory: {error}")) } #[given("a download install of {millis:u64} milliseconds is recorded")] -fn given_download_recorded(world: &mut InstallMetricsWorld, millis: u64) { - record_mode(world, InstallMode::Download, millis); +fn given_download_recorded(world: &mut InstallMetricsWorld, millis: u64) -> Result<(), String> { + record_mode(world, InstallMode::Download, millis) } #[given("an in-memory zero metrics aggregate")] @@ -83,152 +122,170 @@ fn given_zero_metrics(world: &mut InstallMetricsWorld) { } #[when("a download install of {millis:u64} milliseconds is recorded")] -fn when_download_recorded(world: &mut InstallMetricsWorld, millis: u64) { - record_mode(world, InstallMode::Download, millis); +fn when_download_recorded(world: &mut InstallMetricsWorld, millis: u64) -> Result<(), String> { + record_mode(world, InstallMode::Download, millis) } #[when("a build install of {millis:u64} milliseconds is recorded")] -fn when_build_recorded(world: &mut InstallMetricsWorld, millis: u64) { - record_mode(world, InstallMode::Build, millis); +fn when_build_recorded(world: &mut InstallMetricsWorld, millis: u64) -> Result<(), String> { + record_mode(world, InstallMode::Build, millis) } #[when("download and build rates are calculated")] -fn when_rates_calculated(world: &mut InstallMetricsWorld) { - let _ = world; -} +fn when_rates_calculated(world: &mut InstallMetricsWorld) { let _ = world; } #[then("total installs is {expected:u64}")] -fn then_total_installs(world: &mut InstallMetricsWorld, expected: u64) { - let metrics = world.in_memory_metrics.as_ref().expect("metrics available"); - assert_eq!(metrics.total_installs(), expected); +fn then_total_installs(world: &mut InstallMetricsWorld, expected: u64) -> Result<(), String> { + ensure_eq( + &metrics(world)?.total_installs(), + &expected, + "total installs", + ) } #[then("download installs is {expected:u64}")] -fn then_download_installs(world: &mut InstallMetricsWorld, expected: u64) { - let metrics = world.in_memory_metrics.as_ref().expect("metrics available"); - assert_eq!(metrics.download_installs(), expected); +fn then_download_installs(world: &mut InstallMetricsWorld, expected: u64) -> Result<(), String> { + ensure_eq( + &metrics(world)?.download_installs(), + &expected, + "download installs", + ) } #[then("build installs is {expected:u64}")] -fn then_build_installs(world: &mut InstallMetricsWorld, expected: u64) { - let metrics = world.in_memory_metrics.as_ref().expect("metrics available"); - assert_eq!(metrics.build_installs(), expected); +fn then_build_installs(world: &mut InstallMetricsWorld, expected: u64) -> Result<(), String> { + ensure_eq( + &metrics(world)?.build_installs(), + &expected, + "build installs", + ) } -#[then("download rate is {expected:f64}")] -fn then_download_rate(world: &mut InstallMetricsWorld, expected: f64) { - let metrics = world.in_memory_metrics.as_ref().expect("metrics available"); - assert!( - (metrics.download_rate() - expected).abs() < FLOAT_RATE_TOLERANCE, - "expected {}, got {}", - expected, - metrics.download_rate() - ); +#[then("download rate is {expected:u64} permille")] +fn then_download_rate(world: &mut InstallMetricsWorld, expected: u64) -> Result<(), String> { + ensure_eq( + &metrics(world)?.download_rate_permille(), + &expected, + "download rate permille", + ) } -#[then("build rate is {expected:f64}")] -fn then_build_rate(world: &mut InstallMetricsWorld, expected: f64) { - let metrics = world.in_memory_metrics.as_ref().expect("metrics available"); - assert!( - (metrics.build_rate() - expected).abs() < FLOAT_RATE_TOLERANCE, - "expected {}, got {}", - expected, - metrics.build_rate() - ); +#[then("build rate is {expected:u64} permille")] +fn then_build_rate(world: &mut InstallMetricsWorld, expected: u64) -> Result<(), String> { + ensure_eq( + &metrics(world)?.build_rate_permille(), + &expected, + "build rate permille", + ) } #[then("total installation time is {expected:u64} milliseconds")] -fn then_total_installation_time(world: &mut InstallMetricsWorld, expected: u64) { - let metrics = world.in_memory_metrics.as_ref().expect("metrics available"); - assert_eq!( - metrics.total_install_duration(), - Duration::from_millis(expected) - ); +fn then_total_installation_time( + world: &mut InstallMetricsWorld, + expected: u64, +) -> Result<(), String> { + ensure_eq( + &metrics(world)?.total_install_duration(), + &Duration::from_millis(expected), + "total installation time", + ) } #[then("metrics recovery from corrupt file is true")] -fn then_recovered(world: &mut InstallMetricsWorld) { - let outcome = world.outcome.as_ref().expect("recording outcome available"); - assert!(outcome.recovered_from_corrupt_file()); +fn then_recovered(world: &mut InstallMetricsWorld) -> Result<(), String> { + let outcome = world + .outcome + .as_ref() + .ok_or_else(|| String::from("recording outcome available"))?; + if outcome.recovered_from_corrupt_file() { + Ok(()) + } else { + Err(String::from( + "expected recovery from a corrupt metrics file", + )) + } } #[then("metrics recording fails")] -fn then_recording_fails(world: &mut InstallMetricsWorld) { - assert!( - world.last_error.is_some(), - "expected recording to fail, got success outcome" - ); +fn then_recording_fails(world: &mut InstallMetricsWorld) -> Result<(), String> { + if world.last_error.is_some() { + Ok(()) + } else { + Err(String::from( + "expected recording to fail, got success outcome", + )) + } } #[then("summary line contains \"{expected}\"")] -fn then_summary_line_contains(world: &mut InstallMetricsWorld, expected: String) { +fn then_summary_line_contains( + world: &mut InstallMetricsWorld, + expected: String, +) -> Result<(), String> { let summary = world .summary_line .as_deref() - .expect("summary line is available"); - assert!( - summary.contains(&expected), - "expected summary line to contain {expected:?}, got {summary:?}" - ); + .ok_or_else(|| String::from("summary line is available"))?; + if summary.contains(&expected) { + Ok(()) + } else { + Err(format!( + "expected summary line to contain {expected:?}, got {summary:?}" + )) + } } #[then("warning text contains \"{expected}\"")] -fn then_warning_text_contains(world: &mut InstallMetricsWorld, expected: String) { +fn then_warning_text_contains( + world: &mut InstallMetricsWorld, + expected: String, +) -> Result<(), String> { let error = world .last_error .as_deref() - .expect("metrics recording error should be available"); + .ok_or_else(|| String::from("metrics recording error should be available"))?; let warning_text = format!("Warning: could not record install metrics: {error}"); - assert!( - warning_text.contains(&expected), - "expected warning text to contain {expected:?}, got {warning_text:?}" - ); + if warning_text.contains(&expected) { + Ok(()) + } else { + Err(format!( + "expected warning text to contain {expected:?}, got {warning_text:?}" + )) + } } #[scenario( path = "tests/features/install_metrics.feature", name = "Record a successful prebuilt-download install" )] -fn scenario_download_install(world: InstallMetricsWorld) { - let _ = world; -} +fn scenario_download_install(world: InstallMetricsWorld) { let _ = world; } #[scenario( path = "tests/features/install_metrics.feature", name = "Record a successful build-only install" )] -fn scenario_build_only_install(world: InstallMetricsWorld) { - let _ = world; -} +fn scenario_build_only_install(world: InstallMetricsWorld) { let _ = world; } #[scenario( path = "tests/features/install_metrics.feature", name = "Record download and build installs" )] -fn scenario_download_and_build_installs(world: InstallMetricsWorld) { - let _ = world; -} +fn scenario_download_and_build_installs(world: InstallMetricsWorld) { let _ = world; } #[scenario( path = "tests/features/install_metrics.feature", name = "Recover from a corrupt metrics file" )] -fn scenario_recover_from_corrupt_file(world: InstallMetricsWorld) { - let _ = world; -} +fn scenario_recover_from_corrupt_file(world: InstallMetricsWorld) { let _ = world; } #[scenario( path = "tests/features/install_metrics.feature", name = "Report write failures as warning text" )] -fn scenario_report_write_failures(world: InstallMetricsWorld) { - let _ = world; -} +fn scenario_report_write_failures(world: InstallMetricsWorld) { let _ = world; } #[scenario( path = "tests/features/install_metrics.feature", name = "Zero-state rates are zero" )] -fn scenario_zero_state_rates(world: InstallMetricsWorld) { - let _ = world; -} +fn scenario_zero_state_rates(world: InstallMetricsWorld) { let _ = world; } diff --git a/installer/tests/behaviour_installer_release.rs b/installer/tests/behaviour_installer_release.rs index 83af635f..7572d511 100644 --- a/installer/tests/behaviour_installer_release.rs +++ b/installer/tests/behaviour_installer_release.rs @@ -4,13 +4,21 @@ //! archives matching the binstall `pkg-url` and `bin-dir` templates for //! all supported targets, and that error paths are handled correctly. +use std::path::PathBuf; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::path::PathBuf; -use whitaker_installer::binstall_metadata; -use whitaker_installer::installer_packaging::{ - self, ArchiveFormat, InstallerPackageOutput, InstallerPackageParams, InstallerPackagingError, - TargetTriple, Version, +use whitaker_installer::{ + binstall_metadata, + installer_packaging::{ + self, + ArchiveFormat, + InstallerPackageOutput, + InstallerPackageParams, + InstallerPackagingError, + TargetTriple, + Version, + }, }; // --------------------------------------------------------------------------- @@ -29,10 +37,9 @@ struct InstallerReleaseWorld { packaging_error: Option, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> InstallerReleaseWorld { - InstallerReleaseWorld::default() -} +fn world() -> InstallerReleaseWorld { InstallerReleaseWorld::default() } // --------------------------------------------------------------------------- // Step definitions @@ -44,59 +51,73 @@ fn given_version_and_target(world: &mut InstallerReleaseWorld, version: String, world.target = target; } +/// Parse the world's target string, reporting a descriptive error on failure. +fn world_target(world: &InstallerReleaseWorld) -> Result { + TargetTriple::try_from(world.target.as_str()) + .map_err(|error| format!("invalid target '{}': {error}", world.target)) +} + #[given("a fake installer binary exists")] -fn given_fake_binary_exists(world: &mut InstallerReleaseWorld) { - let temp = tempfile::tempdir().expect("temp dir"); - let bin_name = installer_packaging::binary_filename( - &TargetTriple::try_from(world.target.as_str()).expect("valid target"), - ); +fn given_fake_binary_exists(world: &mut InstallerReleaseWorld) -> Result<(), String> { + let temp = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; + let bin_name = installer_packaging::binary_filename(&world_target(world)?); let binary_path = temp.path().join(&bin_name); - std::fs::write(&binary_path, b"fake-binary").expect("write fake binary"); + std::fs::write(&binary_path, b"fake-binary") + .map_err(|error| format!("write fake binary: {error}"))?; world.binary_path = Some(binary_path); world.temp_dir = Some(temp); + Ok(()) } #[given("the binary path does not exist")] -fn given_binary_missing(world: &mut InstallerReleaseWorld) { - let temp = tempfile::tempdir().expect("temp dir"); +fn given_binary_missing(world: &mut InstallerReleaseWorld) -> Result<(), String> { + let temp = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; world.binary_path = Some(temp.path().join("does-not-exist")); world.temp_dir = Some(temp); + Ok(()) } #[when("the archive filename is computed")] -fn when_archive_filename_computed(world: &mut InstallerReleaseWorld) { - world.computed_filename = installer_packaging::archive_filename( - &Version::new(&world.version), - &TargetTriple::try_from(world.target.as_str()).expect("valid target"), - ); +fn when_archive_filename_computed(world: &mut InstallerReleaseWorld) -> Result<(), String> { + world.computed_filename = + installer_packaging::archive_filename(&Version::new(&world.version), &world_target(world)?); + Ok(()) } /// Run the packaging pipeline and store the result in the world. -fn attempt_packaging(world: &mut InstallerReleaseWorld) { - let temp_dir = world.temp_dir.as_ref().expect("temp dir set"); - let binary_path = world.binary_path.as_ref().expect("binary path set"); +fn attempt_packaging(world: &mut InstallerReleaseWorld) -> Result<(), String> { + let target = world_target(world)?; + let temp_dir = world + .temp_dir + .as_ref() + .ok_or_else(|| String::from("temp dir set"))?; + let binary_path = world + .binary_path + .as_ref() + .ok_or_else(|| String::from("binary path set"))?; let params = InstallerPackageParams { version: Version::new(&world.version), - target: TargetTriple::try_from(world.target.as_str()).expect("valid target"), + target, binary_path: binary_path.clone(), output_dir: temp_dir.path().to_path_buf(), }; - match installer_packaging::package_installer(params) { + match installer_packaging::package_installer(¶ms) { Ok(output) => world.package_output = Some(output), Err(e) => world.packaging_error = Some(e), } + Ok(()) } #[when("the installer is packaged")] -fn when_installer_packaged(world: &mut InstallerReleaseWorld) { - attempt_packaging(world); +fn when_installer_packaged(world: &mut InstallerReleaseWorld) -> Result<(), String> { + attempt_packaging(world) } #[when("packaging is attempted")] -fn when_packaging_attempted(world: &mut InstallerReleaseWorld) { - attempt_packaging(world); +fn when_packaging_attempted(world: &mut InstallerReleaseWorld) -> Result<(), String> { + attempt_packaging(world) } #[then("the archive filename is \"{expected}\"")] @@ -108,21 +129,25 @@ fn then_archive_filename_is(world: &mut InstallerReleaseWorld, expected: String) } #[then("the archive contains \"{expected_path}\"")] -fn then_archive_contains(world: &mut InstallerReleaseWorld, expected_path: String) { +fn then_archive_contains( + world: &mut InstallerReleaseWorld, + expected_path: String, +) -> Result<(), String> { + let format = installer_packaging::archive_format(&world_target(world)?); let output = world .package_output .as_ref() - .expect("package output should be set"); + .ok_or_else(|| String::from("package output should be set"))?; - let format = installer_packaging::archive_format( - &TargetTriple::try_from(world.target.as_str()).expect("valid target"), - ); - let entries = read_archive_entries(&output.archive_path, format); + let entries = read_archive_entries(&output.archive_path, format)?; - assert!( - entries.contains(&expected_path), - "expected archive to contain '{expected_path}', found: {entries:?}" - ); + if entries.contains(&expected_path) { + Ok(()) + } else { + Err(format!( + "expected archive to contain '{expected_path}', found: {entries:?}" + )) + } } #[then("the binstall pkg-url ends with the archive filename")] @@ -136,15 +161,16 @@ fn then_binstall_url_ends_with_filename(world: &mut InstallerReleaseWorld) { } #[then("a packaging error is returned")] -fn then_packaging_error_returned(world: &mut InstallerReleaseWorld) { +fn then_packaging_error_returned(world: &mut InstallerReleaseWorld) -> Result<(), String> { let err = world .packaging_error .as_ref() - .expect("expected packaging to fail, but it succeeded"); - assert!( - matches!(err, InstallerPackagingError::BinaryNotFound(_)), - "expected BinaryNotFound, got {err:?}" - ); + .ok_or_else(|| String::from("expected packaging to fail, but it succeeded"))?; + if matches!(err, InstallerPackagingError::BinaryNotFound(_)) { + Ok(()) + } else { + Err(format!("expected BinaryNotFound, got {err:?}")) + } } // --------------------------------------------------------------------------- @@ -152,7 +178,14 @@ fn then_packaging_error_returned(world: &mut InstallerReleaseWorld) { // --------------------------------------------------------------------------- /// Read entry paths from an archive file. -fn read_archive_entries(path: &std::path::Path, format: ArchiveFormat) -> Vec { +/// +/// # Errors +/// +/// Returns an error when the archive cannot be opened or its entries read. +fn read_archive_entries( + path: &std::path::Path, + format: ArchiveFormat, +) -> Result, String> { match format { ArchiveFormat::Tgz => read_tgz_entries(path), ArchiveFormat::Zip => read_zip_entries(path), @@ -160,32 +193,34 @@ fn read_archive_entries(path: &std::path::Path, format: ArchiveFormat) -> Vec Vec { - let file = std::fs::File::open(path).expect("open tgz"); +fn read_tgz_entries(path: &std::path::Path) -> Result, String> { + let file = std::fs::File::open(path).map_err(|error| format!("open tgz: {error}"))?; let gz = flate2::read::GzDecoder::new(file); let mut archive = tar::Archive::new(gz); archive .entries() - .expect("entries") - .map(|e| { - let entry = e.expect("valid tar entry"); - entry + .map_err(|error| format!("list tgz entries: {error}"))? + .map(|entry| { + let archive_entry = entry.map_err(|error| format!("read tar entry: {error}"))?; + let entry_path = archive_entry .path() - .expect("valid entry path") - .to_string_lossy() - .into_owned() + .map_err(|error| format!("read tar entry path: {error}"))?; + Ok(entry_path.to_string_lossy().into_owned()) }) .collect() } /// Read entry paths from a `.zip` archive. -fn read_zip_entries(path: &std::path::Path) -> Vec { - let file = std::fs::File::open(path).expect("open zip"); - let archive = zip::ZipArchive::new(file).expect("open zip archive"); +fn read_zip_entries(path: &std::path::Path) -> Result, String> { + let file = std::fs::File::open(path).map_err(|error| format!("open zip: {error}"))?; + let archive = + zip::ZipArchive::new(file).map_err(|error| format!("open zip archive: {error}"))?; (0..archive.len()) - .map(|i| { - let entry = archive.name_for_index(i).expect("entry name"); - entry.to_owned() + .map(|index| { + archive + .name_for_index(index) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("zip entry {index} has no name")) }) .collect() } @@ -198,46 +233,34 @@ fn read_zip_entries(path: &std::path::Path) -> Vec { path = "tests/features/installer_release.feature", name = "Archive filename uses tgz for Linux target" )] -fn scenario_archive_filename_tgz(world: InstallerReleaseWorld) { - let _ = world; -} +fn scenario_archive_filename_tgz(world: InstallerReleaseWorld) { let _ = world; } #[scenario( path = "tests/features/installer_release.feature", name = "Archive filename uses zip for Windows target" )] -fn scenario_archive_filename_zip(world: InstallerReleaseWorld) { - let _ = world; -} +fn scenario_archive_filename_zip(world: InstallerReleaseWorld) { let _ = world; } #[scenario( path = "tests/features/installer_release.feature", name = "Archive contains correct directory structure for Unix" )] -fn scenario_archive_structure_unix(world: InstallerReleaseWorld) { - let _ = world; -} +fn scenario_archive_structure_unix(world: InstallerReleaseWorld) { let _ = world; } #[scenario( path = "tests/features/installer_release.feature", name = "Windows archive contains exe binary" )] -fn scenario_archive_structure_windows(world: InstallerReleaseWorld) { - let _ = world; -} +fn scenario_archive_structure_windows(world: InstallerReleaseWorld) { let _ = world; } #[scenario( path = "tests/features/installer_release.feature", name = "Archive filename matches binstall pkg-url template" )] -fn scenario_binstall_url_match(world: InstallerReleaseWorld) { - let _ = world; -} +fn scenario_binstall_url_match(world: InstallerReleaseWorld) { let _ = world; } #[scenario( path = "tests/features/installer_release.feature", name = "Packaging rejects missing binary" )] -fn scenario_packaging_rejects_missing(world: InstallerReleaseWorld) { - let _ = world; -} +fn scenario_packaging_rejects_missing(world: InstallerReleaseWorld) { let _ = world; } diff --git a/installer/tests/behaviour_prebuilt.rs b/installer/tests/behaviour_prebuilt.rs index b51185f8..6176c553 100644 --- a/installer/tests/behaviour_prebuilt.rs +++ b/installer/tests/behaviour_prebuilt.rs @@ -1,17 +1,24 @@ //! BDD tests for the prebuilt artefact download and verification workflow. +use std::{ + path::Path, + sync::{Mutex, PoisonError}, +}; + use camino::Utf8PathBuf; use clap::Parser; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::path::Path; -use std::sync::Mutex; -use whitaker_installer::artefact::download::{ArtefactDownloader, DownloadError}; -use whitaker_installer::artefact::extraction::{ArtefactExtractor, ExtractionError}; -use whitaker_installer::cli::{Cli, InstallArgs}; -use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt_with}; -use whitaker_installer::resolution::{CrateResolutionOptions, resolve_crates}; -use whitaker_installer::test_utils::{prebuilt_manifest_json, sha256_hex}; +use whitaker_installer::{ + artefact::{ + download::{ArtefactDownloader, DownloadError}, + extraction::{ArtefactExtractor, ExtractionError}, + }, + cli::{Cli, InstallArgs}, + prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebuilt_with}, + resolution::{CrateResolutionOptions, resolve_crates}, + test_utils::{prebuilt_manifest_json, sha256_hex}, +}; const FAKE_ARCHIVE: &[u8] = b"fake archive content"; const DEFAULT_TARGET: &str = "x86_64-unknown-linux-gnu"; @@ -44,7 +51,7 @@ struct StubDownloader { } impl StubDownloader { - fn new(manifest: ManifestBehaviour, archive: ArchiveBehaviour) -> Self { + const fn new(manifest: ManifestBehaviour, archive: ArchiveBehaviour) -> Self { Self { manifest: Mutex::new(Some(manifest)), archive: Mutex::new(Some(archive)), @@ -57,9 +64,13 @@ impl ArtefactDownloader for StubDownloader { let behaviour = self .manifest .lock() - .expect("lock") + .unwrap_or_else(PoisonError::into_inner) .take() - .expect("manifest behaviour not set"); + .ok_or_else(|| { + DownloadError::Io(std::io::Error::other( + "stub manifest behaviour was not configured", + )) + })?; match behaviour { ManifestBehaviour::Ok(json) => Ok(json), ManifestBehaviour::HttpError { url, reason } => { @@ -73,9 +84,9 @@ impl ArtefactDownloader for StubDownloader { let behaviour = self .archive .lock() - .expect("lock") + .unwrap_or_else(PoisonError::into_inner) .take() - .unwrap_or(ArchiveBehaviour::CorrectChecksum); + .unwrap_or_default(); match behaviour { ArchiveBehaviour::CorrectChecksum => { std::fs::write(dest, FAKE_ARCHIVE).map_err(DownloadError::Io) @@ -117,10 +128,16 @@ struct PrebuiltWorld { attempted_destination: Option, } +// A fixture cannot report failure to the scenario binding, so setup problems +// abort the scenario with a descriptive panic. #[fixture] fn world() -> PrebuiltWorld { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let staging_root = Utf8PathBuf::try_from(temp_dir.path().to_path_buf()).expect("UTF-8 path"); + let Ok(temp_dir) = tempfile::tempdir() else { + panic!("failed to create staging temp dir"); + }; + let Ok(staging_root) = Utf8PathBuf::try_from(temp_dir.path().to_path_buf()) else { + panic!("staging temp dir path is not valid UTF-8"); + }; PrebuiltWorld { _temp_dir: Some(temp_dir), staging_root: Some(staging_root), @@ -205,16 +222,20 @@ fn given_destination_path_conflict(world: &mut PrebuiltWorld) { } #[when("prebuilt download is attempted")] -fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { +fn when_prebuilt_attempted(world: &mut PrebuiltWorld) -> Result<(), String> { let toolchain = world .expected_toolchain .as_deref() .unwrap_or(DEFAULT_TOOLCHAIN); let target = world.requested_target.as_deref().unwrap_or(DEFAULT_TARGET); - let staging_root = world.staging_root.as_ref().expect("staging_root set"); + let staging_root = world + .staging_root + .as_ref() + .ok_or_else(|| String::from("staging_root set"))?; let destination_dir = if world.force_destination_conflict { let occupied = staging_root.join("occupied"); - std::fs::write(occupied.as_std_path(), b"occupied file").expect("write occupied file"); + std::fs::write(occupied.as_std_path(), b"occupied file") + .map_err(|error| format!("write occupied file: {error}"))?; occupied.join("child").join("lib") } else { staging_root @@ -234,7 +255,7 @@ fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { let manifest_behaviour = world .manifest_behaviour .take() - .expect("manifest_behaviour set"); + .ok_or_else(|| String::from("manifest_behaviour set"))?; let archive_behaviour = world .archive_behaviour .take() @@ -246,144 +267,150 @@ fn when_prebuilt_attempted(world: &mut PrebuiltWorld) { let mut stderr = Vec::new(); let result = attempt_prebuilt_with(&config, &downloader, &extractor, &mut stderr); world.result = Some(result); + Ok(()) } #[when("the install configuration is checked")] fn when_install_config_checked(world: &mut PrebuiltWorld) { let install_args = world.install_args.clone().unwrap_or_default(); let options = CrateResolutionOptions { - individual_lints: install_args.individual_lints, - experimental: install_args.experimental, + individual_lints: install_args.lint_selection.individual_lints, + experimental: install_args.lint_selection.experimental, }; let requested_crates = resolve_crates(&[], &options); world.should_attempt_prebuilt = Some(install_args.should_attempt_prebuilt(&requested_crates)); } +/// Borrow the recorded prebuilt outcome, failing when the When step has not run. +fn prebuilt_result(world: &PrebuiltWorld) -> Result<&PrebuiltResult, String> { + world + .result + .as_ref() + .ok_or_else(|| String::from("result set")) +} + #[then("the prebuilt result is success")] -fn then_result_is_success(world: &mut PrebuiltWorld) { - let result = world.result.as_ref().expect("result set"); - assert!( - matches!(result, PrebuiltResult::Success { .. }), - "expected Success, got {result:?}" - ); +fn then_result_is_success(world: &mut PrebuiltWorld) -> Result<(), String> { + let result = prebuilt_result(world)?; + if matches!(result, PrebuiltResult::Success { .. }) { + Ok(()) + } else { + Err(format!("expected Success, got {result:?}")) + } } #[then("the staging path uses toolchain, target, and lib directories")] -fn then_staging_path_uses_expected_layout(world: &mut PrebuiltWorld) { - let result = world.result.as_ref().expect("result set"); - if let PrebuiltResult::Success { staging_path } = result { - let toolchain = world - .expected_toolchain - .as_deref() - .unwrap_or(DEFAULT_TOOLCHAIN); - let target = world.requested_target.as_deref().unwrap_or(DEFAULT_TARGET); - let expected_suffix = format!("{toolchain}/{target}/lib"); - assert!( - staging_path.ends_with(&expected_suffix), - "staging path {staging_path} does not end with {expected_suffix}" - ); +fn then_staging_path_uses_expected_layout(world: &mut PrebuiltWorld) -> Result<(), String> { + let result = prebuilt_result(world)?; + let PrebuiltResult::Success { staging_path } = result else { + return Err(format!("expected Success, got {result:?}")); + }; + let toolchain = world + .expected_toolchain + .as_deref() + .unwrap_or(DEFAULT_TOOLCHAIN); + let target = world.requested_target.as_deref().unwrap_or(DEFAULT_TARGET); + let expected_suffix = format!("{toolchain}/{target}/lib"); + if staging_path.ends_with(&expected_suffix) { + Ok(()) } else { - panic!("expected Success, got {result:?}"); + Err(format!( + "staging path {staging_path} does not end with {expected_suffix}" + )) } } #[then("the prebuilt result is fallback")] -fn then_result_is_fallback(world: &mut PrebuiltWorld) { - let result = world.result.as_ref().expect("result set"); - assert!( - matches!(result, PrebuiltResult::Fallback { .. }), - "expected Fallback, got {result:?}" - ); +fn then_result_is_fallback(world: &mut PrebuiltWorld) -> Result<(), String> { + let result = prebuilt_result(world)?; + if matches!(result, PrebuiltResult::Fallback { .. }) { + Ok(()) + } else { + Err(format!("expected Fallback, got {result:?}")) + } } #[then("the fallback reason mentions \"{keyword}\"")] -fn then_fallback_reason_mentions(world: &mut PrebuiltWorld, keyword: String) { - let result = world.result.as_ref().expect("result set"); - match result { - PrebuiltResult::Fallback { reason } => { - let lower_reason = reason.to_lowercase(); - let lower_keyword = keyword.to_lowercase(); - assert!( - lower_reason.contains(&lower_keyword), - "expected reason to contain '{keyword}', got: {reason}" - ); - } - other => panic!("expected Fallback, got {other:?}"), +fn then_fallback_reason_mentions(world: &mut PrebuiltWorld, keyword: String) -> Result<(), String> { + let result = prebuilt_result(world)?; + let PrebuiltResult::Fallback { reason } = result else { + return Err(format!("expected Fallback, got {result:?}")); + }; + let lower_reason = reason.to_lowercase(); + let lower_keyword = keyword.to_lowercase(); + if lower_reason.contains(&lower_keyword) { + Ok(()) + } else { + Err(format!( + "expected reason to contain '{keyword}', got: {reason}" + )) } } #[then("no prebuilt download is attempted")] -fn then_no_prebuilt_attempted(world: &mut PrebuiltWorld) { - assert!( - world.should_attempt_prebuilt == Some(false), - "expected no prebuilt download attempt when --build-only is set" - ); +fn then_no_prebuilt_attempted(world: &mut PrebuiltWorld) -> Result<(), String> { + if world.should_attempt_prebuilt == Some(false) { + Ok(()) + } else { + Err(String::from( + "expected no prebuilt download attempt when --build-only is set", + )) + } } #[then("the destination directory is not created")] -fn then_destination_is_not_created(world: &mut PrebuiltWorld) { +fn then_destination_is_not_created(world: &mut PrebuiltWorld) -> Result<(), String> { let destination = world .attempted_destination .as_ref() - .expect("attempted destination should be set"); - assert!( - !destination.exists(), - "destination directory should not exist: {destination}" - ); + .ok_or_else(|| String::from("attempted destination should be set"))?; + if destination.exists() { + Err(format!( + "destination directory should not exist: {destination}" + )) + } else { + Ok(()) + } } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Successful prebuilt download and verification" )] -fn scenario_successful_download(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_successful_download(world: PrebuiltWorld) { let _ = world; } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Checksum mismatch triggers fallback" )] -fn scenario_checksum_mismatch(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_checksum_mismatch(world: PrebuiltWorld) { let _ = world; } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Network failure triggers fallback" )] -fn scenario_network_failure(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_network_failure(world: PrebuiltWorld) { let _ = world; } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Missing artefact triggers fallback" )] -fn scenario_not_found(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_not_found(world: PrebuiltWorld) { let _ = world; } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Destination path creation failure triggers fallback" )] -fn scenario_destination_creation_failure(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_destination_creation_failure(world: PrebuiltWorld) { let _ = world; } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Toolchain mismatch triggers fallback" )] -fn scenario_toolchain_mismatch(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_toolchain_mismatch(world: PrebuiltWorld) { let _ = world; } #[scenario( path = "tests/features/prebuilt_download.feature", name = "Build-only flag skips prebuilt" )] -fn scenario_build_only(world: PrebuiltWorld) { - let _ = world; -} +fn scenario_build_only(world: PrebuiltWorld) { let _ = world; } diff --git a/installer/tests/behaviour_staging.rs b/installer/tests/behaviour_staging.rs index 4736bb94..69c6d039 100644 --- a/installer/tests/behaviour_staging.rs +++ b/installer/tests/behaviour_staging.rs @@ -3,15 +3,15 @@ //! These scenarios cover staged filename conventions and non-writable target //! handling. -use camino::Utf8PathBuf; -use rstest::fixture; -use rstest_bdd_macros::{given, scenario, then, when}; #[cfg(unix)] use std::cell::Cell; use std::cell::RefCell; + +use camino::Utf8PathBuf; +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; use tempfile::TempDir; -use whitaker_installer::builder::CrateName; -use whitaker_installer::stager::Stager; +use whitaker_installer::{builder::CrateName, stager::Stager}; // --------------------------------------------------------------------------- // Staging world @@ -24,10 +24,9 @@ struct StagingWorld { staged_name: RefCell, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn staging_world() -> StagingWorld { - StagingWorld::default() -} +fn staging_world() -> StagingWorld { StagingWorld::default() } #[given("a built library")] fn given_built_library(staging_world: &StagingWorld) { @@ -44,19 +43,22 @@ fn given_staging_dir(staging_world: &StagingWorld) { } #[when("the library is staged")] -fn when_library_staged(staging_world: &StagingWorld) { - let crate_name = staging_world.crate_name.borrow(); - let crate_name = crate_name.as_ref().expect("crate name not set"); +fn when_library_staged(staging_world: &StagingWorld) -> Result<(), String> { + let crate_name_slot = staging_world.crate_name.borrow(); + let crate_name = crate_name_slot + .as_ref() + .ok_or_else(|| String::from("crate name not set"))?; let toolchain = staging_world.toolchain.borrow(); // Use the production Stager to compute the filename. - let temp_dir = TempDir::new().expect("failed to create temp dir"); - let utf8_path = - Utf8PathBuf::try_from(temp_dir.path().to_path_buf()).expect("temp dir path not UTF-8"); + let temp_dir = TempDir::new().map_err(|error| format!("failed to create temp dir: {error}"))?; + let utf8_path = Utf8PathBuf::try_from(temp_dir.path().to_path_buf()) + .map_err(|error| format!("temp dir path not UTF-8: {error}"))?; let stager = Stager::new(utf8_path, toolchain.as_str()); let staged_name = stager.staged_filename(crate_name); staging_world.staged_name.replace(staged_name); + Ok(()) } #[then("the staged filename includes the toolchain")] @@ -77,18 +79,21 @@ use staging_failure::staging_failure_world; #[cfg(unix)] mod staging_failure { - use super::*; - use std::fs; - use std::os::unix::fs::PermissionsExt; + //! Unix-only world and steps for staging permission failures. + + use std::{fs, os::unix::fs::PermissionsExt}; + use tempfile::TempDir; use whitaker_installer::error::InstallerError; + use super::*; + pub struct StagingFailureWorld { stager: RefCell>, result: RefCell>>, skip_assertions: Cell, - // Keep temp_dir alive for the lifetime of the test. - _temp_dir: RefCell>, + // Keep the temporary directory alive for the lifetime of the test. + temp_dir: RefCell>, } impl Default for StagingFailureWorld { @@ -97,89 +102,104 @@ mod staging_failure { stager: RefCell::new(None), result: RefCell::new(None), skip_assertions: Cell::new(false), - _temp_dir: RefCell::new(None), + temp_dir: RefCell::new(None), } } } + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] - pub fn staging_failure_world() -> StagingFailureWorld { - StagingFailureWorld::default() - } + pub fn staging_failure_world() -> StagingFailureWorld { StagingFailureWorld::default() } #[given("a non-writable staging directory")] - pub fn given_non_writable_dir(staging_failure_world: &StagingFailureWorld) { + pub fn given_non_writable_dir( + staging_failure_world: &StagingFailureWorld, + ) -> Result<(), String> { // Create a temp directory and make it read-only. - let temp_dir = TempDir::new().expect("failed to create temp dir"); + let temp_dir = + TempDir::new().map_err(|error| format!("failed to create temp dir: {error}"))?; let dir_path = temp_dir.path(); // Create the nested staging path structure that Stager expects. let staging_path = dir_path.join("nightly-2026-05-28").join("release"); - fs::create_dir_all(&staging_path).expect("failed to create staging path"); + fs::create_dir_all(&staging_path) + .map_err(|error| format!("failed to create staging path: {error}"))?; // Make the directory read-only (no write permission). let mut perms = fs::metadata(&staging_path) - .expect("failed to get metadata") + .map_err(|error| format!("failed to get metadata: {error}"))? .permissions(); perms.set_mode(0o555); // readable/traversable, not writable - fs::set_permissions(&staging_path, perms).expect("failed to set permissions"); + fs::set_permissions(&staging_path, perms) + .map_err(|error| format!("failed to set permissions: {error}"))?; - let utf8_path = - Utf8PathBuf::try_from(dir_path.to_path_buf()).expect("temp dir path not UTF-8"); + let utf8_path = Utf8PathBuf::try_from(dir_path.to_path_buf()) + .map_err(|error| format!("temp dir path not UTF-8: {error}"))?; let stager = Stager::new(utf8_path, "nightly-2026-05-28"); staging_failure_world.stager.replace(Some(stager)); - staging_failure_world._temp_dir.replace(Some(temp_dir)); + staging_failure_world.temp_dir.replace(Some(temp_dir)); + Ok(()) } #[when("the staging directory is prepared")] - pub fn when_staging_prepared(staging_failure_world: &StagingFailureWorld) { - let stager = staging_failure_world.stager.borrow(); - let stager = stager.as_ref().expect("stager not set"); + pub fn when_staging_prepared( + staging_failure_world: &StagingFailureWorld, + ) -> Result<(), String> { + let stager_slot = staging_failure_world.stager.borrow(); + let stager = stager_slot + .as_ref() + .ok_or_else(|| String::from("stager not set"))?; // Best-effort probe to avoid flakes on filesystems that ignore directory // permissions. If we can unexpectedly create a file in the staging // directory, mark assertions as skipped for this scenario. let probe_path = stager.staging_path().as_std_path().join("write-probe"); - match std::fs::OpenOptions::new() + if let Ok(file) = std::fs::OpenOptions::new() .create_new(true) .write(true) .open(&probe_path) { - Ok(file) => { - drop(file); - let _ = std::fs::remove_file(&probe_path); - staging_failure_world.skip_assertions.set(true); - } - Err(_) => { - // Expected: directory is not writable, continue. - } + drop(file); + std::fs::remove_file(&probe_path).map_err(|error| { + format!( + "failed to remove write probe {}: {error}", + probe_path.display() + ) + })?; + staging_failure_world.skip_assertions.set(true); + } else { + // Expected: directory is not writable, continue. } let result = stager.prepare(); staging_failure_world.result.replace(Some(result)); + Ok(()) } #[then("staging fails with a target not writable error")] - pub fn then_staging_fails_not_writable(staging_failure_world: &StagingFailureWorld) { + pub fn then_staging_fails_not_writable( + staging_failure_world: &StagingFailureWorld, + ) -> Result<(), String> { if staging_failure_world.skip_assertions.get() { - return; + return Ok(()); } // Skip this assertion when running as root (uid 0) since root can bypass // filesystem permissions. This is similar to how CI containers often run. - // SAFETY: `libc::geteuid()` is a simple FFI call with no preconditions; - // it returns the effective user ID without modifying any state. - if unsafe { libc::geteuid() } == 0 { - return; + if rustix::process::geteuid().is_root() { + return Ok(()); } - let result = staging_failure_world.result.borrow(); - let result = result.as_ref().expect("result not set"); - assert!( - matches!(result, Err(InstallerError::TargetNotWritable { .. })), - "expected TargetNotWritable error, got {result:?}" - ); + let result_slot = staging_failure_world.result.borrow(); + let result = result_slot + .as_ref() + .ok_or_else(|| String::from("result not set"))?; + if matches!(result, Err(InstallerError::TargetNotWritable { .. })) { + Ok(()) + } else { + Err(format!("expected TargetNotWritable error, got {result:?}")) + } } } @@ -188,9 +208,7 @@ mod staging_failure { // --------------------------------------------------------------------------- #[scenario(path = "tests/features/installer.feature", index = 10)] -fn scenario_stage_with_toolchain_suffix(staging_world: StagingWorld) { - let _ = staging_world; -} +fn scenario_stage_with_toolchain_suffix(staging_world: StagingWorld) { let _ = staging_world; } #[cfg(unix)] #[scenario(path = "tests/features/installer.feature", index = 11)] diff --git a/installer/tests/behaviour_toolchain.rs b/installer/tests/behaviour_toolchain.rs index 02da80e6..39fa3240 100644 --- a/installer/tests/behaviour_toolchain.rs +++ b/installer/tests/behaviour_toolchain.rs @@ -5,8 +5,8 @@ //! //! The tests include: //! - Dry-run scenarios that test toolchain detection (skipped if toolchain missing) -//! - Install scenarios that exercise auto-install using an isolated rustup -//! environment (RUSTUP_HOME/CARGO_HOME set to temp directories) +//! - Install scenarios that exercise auto-install using an isolated rustup environment +//! (`RUSTUP_HOME/CARGO_HOME` set to temp directories) //! - Failure scenarios that test error handling with a non-existent toolchain mod prebuilt_markers; @@ -15,20 +15,33 @@ mod toolchain_steps; use rstest_bdd_macros::scenario; use toolchain_steps::{ToolchainWorld, world}; - // Import step definitions so rstest-bdd's scenario macro can discover them. // These imports appear unused to clippy because they're consumed by macro // expansion, not direct source-level calls. -#[allow(unused_imports)] +#[expect( + unused_imports, + reason = "step definitions are consumed by rstest-bdd macro expansion, not by direct calls" +)] use toolchain_steps::{ - given_auto_detect_toolchain, given_auto_detect_toolchain_install, - given_auto_detect_toolchain_quiet, given_isolated_rustup_auto_install, - given_isolated_rustup_quiet, given_nonexistent_toolchain, given_nonexistent_toolchain_quiet, - then_cli_exits_successfully, then_cli_exits_with_error, then_dry_run_shows_toolchain, - then_error_includes_toolchain_name, then_error_mentions_install_failure, - then_error_output_is_minimal, then_install_message_shown, - then_installation_succeeds_or_is_skipped, then_no_install_message, - then_suite_library_is_staged, then_toolchain_installed_in_isolated_env, when_installer_cli_run, + given_auto_detect_toolchain, + given_auto_detect_toolchain_install, + given_auto_detect_toolchain_quiet, + given_isolated_rustup_auto_install, + given_isolated_rustup_quiet, + given_nonexistent_toolchain, + given_nonexistent_toolchain_quiet, + then_cli_exits_successfully, + then_cli_exits_with_error, + then_dry_run_shows_toolchain, + then_error_includes_toolchain_name, + then_error_mentions_install_failure, + then_error_output_is_minimal, + then_install_message_shown, + then_installation_succeeds_or_is_skipped, + then_no_install_message, + then_suite_library_is_staged, + then_toolchain_installed_in_isolated_env, + when_installer_cli_run, }; // --------------------------------------------------------------------------- @@ -36,36 +49,22 @@ use toolchain_steps::{ // --------------------------------------------------------------------------- #[scenario(path = "tests/features/toolchain.feature", index = 0)] -fn scenario_auto_detect_toolchain_dry_run(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_detect_toolchain_dry_run(world: ToolchainWorld) { let _ = world; } #[scenario(path = "tests/features/toolchain.feature", index = 1)] -fn scenario_auto_detect_toolchain_quiet_mode(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_detect_toolchain_quiet_mode(world: ToolchainWorld) { let _ = world; } #[scenario(path = "tests/features/toolchain.feature", index = 2)] -fn scenario_auto_detect_toolchain_install(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_detect_toolchain_install(world: ToolchainWorld) { let _ = world; } #[scenario(path = "tests/features/toolchain.feature", index = 3)] -fn scenario_auto_install_success_emits_message(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_install_success_emits_message(world: ToolchainWorld) { let _ = world; } #[scenario(path = "tests/features/toolchain.feature", index = 4)] -fn scenario_auto_install_success_quiet_mode(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_install_success_quiet_mode(world: ToolchainWorld) { let _ = world; } #[scenario(path = "tests/features/toolchain.feature", index = 5)] -fn scenario_auto_install_failure_reports_error(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_install_failure_reports_error(world: ToolchainWorld) { let _ = world; } #[scenario(path = "tests/features/toolchain.feature", index = 6)] -fn scenario_auto_install_failure_quiet_mode(world: ToolchainWorld) { - let _ = world; -} +fn scenario_auto_install_failure_quiet_mode(world: ToolchainWorld) { let _ = world; } diff --git a/installer/tests/behaviour_workflows.rs b/installer/tests/behaviour_workflows.rs index 1644bf56..9e34c768 100644 --- a/installer/tests/behaviour_workflows.rs +++ b/installer/tests/behaviour_workflows.rs @@ -3,14 +3,17 @@ //! These scenarios test the --skip-deps, --no-update, and --skip-wrapper flags //! added to support standalone installation without a pre-cloned repository. +use std::{ + cell::{Cell, RefCell}, + io::Write as _, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; -use std::cell::{Cell, RefCell}; -use std::path::PathBuf; -use std::process::{Command, Output}; use tempfile::TempDir; -use whitaker_installer::test_support::TEST_STAGE_SUITE_ENV; -use whitaker_installer::toolchain::parse_toolchain_channel; +use whitaker_installer::{test_support::TEST_STAGE_SUITE_ENV, toolchain::parse_toolchain_channel}; #[derive(Default)] struct WorkflowWorld { @@ -19,31 +22,31 @@ struct WorkflowWorld { skip_assertions: Cell, requires_toolchain: Cell, use_test_staged_suite: Cell, - _temp_dir: RefCell>, + /// Owns the scenario's temporary target directory so it outlives the run. + temp_dir: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> WorkflowWorld { - WorkflowWorld::default() -} +fn world() -> WorkflowWorld { WorkflowWorld::default() } -fn workspace_root() -> PathBuf { +fn workspace_root() -> Result { PathBuf::from(std::env!("CARGO_MANIFEST_DIR")) .parent() - .expect("manifest dir should have parent") - .to_owned() + .map(Path::to_path_buf) + .ok_or_else(|| String::from("manifest dir should have parent")) } -fn pinned_toolchain_channel() -> String { - let toolchain_path = workspace_root().join("rust-toolchain.toml"); - let contents = std::fs::read_to_string(&toolchain_path).unwrap_or_else(|err| { - panic!( +fn pinned_toolchain_channel() -> Result { + let toolchain_path = workspace_root()?.join("rust-toolchain.toml"); + let contents = std::fs::read_to_string(&toolchain_path).map_err(|err| { + format!( "failed to read rust-toolchain.toml at {}: {err}", toolchain_path.display() ) - }); - parse_toolchain_channel(&contents).unwrap_or_else(|err| { - panic!( + })?; + parse_toolchain_channel(&contents).map_err(|err| { + format!( "failed to parse rust-toolchain.toml at {}: {err}", toolchain_path.display() ) @@ -54,64 +57,100 @@ fn is_toolchain_installed(channel: &str) -> bool { Command::new("rustup") .args(["run", channel, "rustc", "--version"]) .output() - .map(|o| o.status.success()) - .unwrap_or(false) + .is_ok_and(|o| o.status.success()) +} + +/// Reports a skipped scenario on stderr without tripping `print_stderr`. +fn report_skip(reason: &str) -> Result<(), String> { + writeln!(std::io::stderr(), "{reason}") + .map_err(|error| format!("failed to report skipped scenario: {error}")) } -fn skip_scenario_when_toolchain_missing(world: &WorkflowWorld, channel: &str) { +fn skip_scenario_when_toolchain_missing( + world: &WorkflowWorld, + channel: &str, +) -> Result<(), String> { if !is_toolchain_installed(channel) { - eprintln!( - "Skipping scenario because rustup toolchain '{}' is not installed.", - channel - ); + report_skip(&format!( + "Skipping scenario because rustup toolchain '{channel}' is not installed." + ))?; world.skip_assertions.set(true); rstest_bdd::skip!( "rustup toolchain '{channel}' is not installed.", channel = channel ); } + Ok(()) } -fn ensure_required_toolchain_available(world: &WorkflowWorld) -> Option { - let channel = pinned_toolchain_channel(); +fn ensure_required_toolchain_available(world: &WorkflowWorld) -> Result, String> { + let channel = pinned_toolchain_channel()?; world.requires_toolchain.set(true); - skip_scenario_when_toolchain_missing(world, &channel); + skip_scenario_when_toolchain_missing(world, &channel)?; - if world.skip_assertions.get() { - None - } else { - Some(channel) - } + Ok((!world.skip_assertions.get()).then_some(channel)) } macro_rules! skip_if_needed { ($world:expr) => { if $world.skip_assertions.get() { - return; + return Ok(()); } }; } -fn setup_temp_dir(world: &WorkflowWorld) -> String { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn setup_temp_dir(world: &WorkflowWorld) -> Result { + let temp_dir = TempDir::new().map_err(|error| format!("failed to create temp dir: {error}"))?; let target_dir = temp_dir.path().to_string_lossy().to_string(); - world._temp_dir.replace(Some(temp_dir)); - target_dir + world.temp_dir.replace(Some(temp_dir)); + Ok(target_dir) } -fn get_output(world: &WorkflowWorld) -> std::cell::Ref<'_, Output> { +/// Borrows the captured CLI output, failing when no command has run yet. +fn get_output(world: &WorkflowWorld) -> Result, String> { let output = world.output.borrow(); - std::cell::Ref::map(output, |opt| opt.as_ref().expect("output not set")) + std::cell::Ref::filter_map(output, Option::as_ref) + .map_err(|_| String::from("CLI output not set; run the installer step first")) +} + +fn require_successful_output(world: &WorkflowWorld, failure_prefix: &str) -> Result<(), String> { + skip_if_needed!(world); + + let output = get_output(world)?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{failure_prefix}, stderr: {}", + String::from_utf8_lossy(&output.stderr) + )) + } +} + +fn require_stderr_contains( + world: &WorkflowWorld, + expected: &str, + failure_prefix: &str, +) -> Result<(), String> { + skip_if_needed!(world); + + let output = get_output(world)?; + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains(expected) { + Ok(()) + } else { + Err(format!("{failure_prefix}, stderr: {stderr}")) + } } // --------------------------------------------------------------------------- // Step definitions // --------------------------------------------------------------------------- -fn given_dry_run_with_flag(world: &WorkflowWorld, flag: &str) { - let Some(channel) = ensure_required_toolchain_available(world) else { - return; +fn given_dry_run_with_flag(world: &WorkflowWorld, flag: &str) -> Result<(), String> { + let Some(channel) = ensure_required_toolchain_available(world)? else { + return Ok(()); }; world.args.replace(vec![ @@ -120,30 +159,31 @@ fn given_dry_run_with_flag(world: &WorkflowWorld, flag: &str) { channel, flag.to_owned(), ]); + Ok(()) } #[given("the installer is invoked with dry-run and skip-deps")] -fn given_dry_run_skip_deps(world: &WorkflowWorld) { - given_dry_run_with_flag(world, "--skip-deps"); +fn given_dry_run_skip_deps(world: &WorkflowWorld) -> Result<(), String> { + given_dry_run_with_flag(world, "--skip-deps") } #[given("the installer is invoked with dry-run and no-update")] -fn given_dry_run_no_update(world: &WorkflowWorld) { - given_dry_run_with_flag(world, "--no-update"); +fn given_dry_run_no_update(world: &WorkflowWorld) -> Result<(), String> { + given_dry_run_with_flag(world, "--no-update") } #[given("the installer is invoked with dry-run and skip-wrapper")] -fn given_dry_run_skip_wrapper(world: &WorkflowWorld) { - given_dry_run_with_flag(world, "--skip-wrapper"); +fn given_dry_run_skip_wrapper(world: &WorkflowWorld) -> Result<(), String> { + given_dry_run_with_flag(world, "--skip-wrapper") } #[given("the installer is invoked with skip-wrapper to a temporary directory")] -fn given_skip_wrapper_install(world: &WorkflowWorld) { - let Some(_channel) = ensure_required_toolchain_available(world) else { - return; - }; +fn given_skip_wrapper_install(world: &WorkflowWorld) -> Result<(), String> { + if ensure_required_toolchain_available(world)?.is_none() { + return Ok(()); + } - let target_dir = setup_temp_dir(world); + let target_dir = setup_temp_dir(world)?; world.use_test_staged_suite.set(true); // The behavioural test sets a dedicated env var so the installer stages a @@ -155,98 +195,72 @@ fn given_skip_wrapper_install(world: &WorkflowWorld) { "--skip-wrapper".to_owned(), "--skip-deps".to_owned(), ]); + Ok(()) } #[when("the installer CLI is run")] -fn when_installer_cli_run(world: &WorkflowWorld) { +fn when_installer_cli_run(world: &WorkflowWorld) -> Result<(), String> { skip_if_needed!(world); let args = world.args.borrow(); let mut cmd = Command::new(env!("CARGO_BIN_EXE_whitaker-installer")); cmd.args(args.iter()); - cmd.current_dir(workspace_root()); + cmd.current_dir(workspace_root()?); if world.use_test_staged_suite.get() { cmd.env(TEST_STAGE_SUITE_ENV, "1"); } - let output = cmd.output().expect("failed to run whitaker-installer"); + let output = cmd + .output() + .map_err(|error| format!("failed to run whitaker-installer: {error}"))?; world.output.replace(Some(output)); + Ok(()) } #[then("the CLI exits successfully")] -fn then_cli_exits_successfully(world: &WorkflowWorld) { - skip_if_needed!(world); - - let output = get_output(world); - assert!( - output.status.success(), - "expected success, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); +fn then_cli_exits_successfully(world: &WorkflowWorld) -> Result<(), String> { + require_successful_output(world, "expected success") } #[then("installation succeeds or is skipped")] -fn then_installation_succeeds_or_is_skipped(world: &WorkflowWorld) { - skip_if_needed!(world); - - let output = get_output(world); - assert!( - output.status.success(), - "installation failed: {}", - String::from_utf8_lossy(&output.stderr) - ); +fn then_installation_succeeds_or_is_skipped(world: &WorkflowWorld) -> Result<(), String> { + require_successful_output(world, "installation failed") } #[then("dry-run output shows skip_deps is true")] -fn then_skip_deps_is_true(world: &WorkflowWorld) { - skip_if_needed!(world); - - let output = get_output(world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stderr.contains("Skip deps: true"), - "expected skip_deps to be true in output, stderr: {stderr}" - ); +fn then_skip_deps_is_true(world: &WorkflowWorld) -> Result<(), String> { + require_stderr_contains( + world, + "Skip deps: true", + "expected skip_deps to be true in output", + ) } #[then("dry-run output shows no_update is true")] -fn then_no_update_is_true(world: &WorkflowWorld) { - skip_if_needed!(world); - - let output = get_output(world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stderr.contains("No update: true"), - "expected no_update to be true in output, stderr: {stderr}" - ); +fn then_no_update_is_true(world: &WorkflowWorld) -> Result<(), String> { + require_stderr_contains( + world, + "No update: true", + "expected no_update to be true in output", + ) } #[then("dry-run output shows skip_wrapper is true")] -fn then_skip_wrapper_is_true(world: &WorkflowWorld) { - skip_if_needed!(world); - - let output = get_output(world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stderr.contains("Skip wrapper: true"), - "expected skip_wrapper to be true in output, stderr: {stderr}" - ); +fn then_skip_wrapper_is_true(world: &WorkflowWorld) -> Result<(), String> { + require_stderr_contains( + world, + "Skip wrapper: true", + "expected skip_wrapper to be true in output", + ) } #[then("output includes DYLINT_LIBRARY_PATH instructions")] -fn then_output_includes_library_path_instructions(world: &WorkflowWorld) { - skip_if_needed!(world); - - let output = get_output(world); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stderr.contains("DYLINT_LIBRARY_PATH"), - "expected DYLINT_LIBRARY_PATH instructions in output, stderr: {stderr}" - ); +fn then_output_includes_library_path_instructions(world: &WorkflowWorld) -> Result<(), String> { + require_stderr_contains( + world, + "DYLINT_LIBRARY_PATH", + "expected DYLINT_LIBRARY_PATH instructions in output", + ) } // --------------------------------------------------------------------------- @@ -254,21 +268,13 @@ fn then_output_includes_library_path_instructions(world: &WorkflowWorld) { // --------------------------------------------------------------------------- #[scenario(path = "tests/features/installer.feature", index = 15)] -fn scenario_dry_run_skip_deps(world: WorkflowWorld) { - let _ = world; -} +fn scenario_dry_run_skip_deps(world: WorkflowWorld) { let _ = world; } #[scenario(path = "tests/features/installer.feature", index = 16)] -fn scenario_dry_run_no_update(world: WorkflowWorld) { - let _ = world; -} +fn scenario_dry_run_no_update(world: WorkflowWorld) { let _ = world; } #[scenario(path = "tests/features/installer.feature", index = 17)] -fn scenario_dry_run_skip_wrapper(world: WorkflowWorld) { - let _ = world; -} +fn scenario_dry_run_skip_wrapper(world: WorkflowWorld) { let _ = world; } #[scenario(path = "tests/features/installer.feature", index = 18)] -fn scenario_skip_wrapper_outputs_shell_snippet(world: WorkflowWorld) { - let _ = world; -} +fn scenario_skip_wrapper_outputs_shell_snippet(world: WorkflowWorld) { let _ = world; } diff --git a/installer/tests/doc_extraction/extraction.rs b/installer/tests/doc_extraction/extraction.rs index 575c65bb..f4bf8596 100644 --- a/installer/tests/doc_extraction/extraction.rs +++ b/installer/tests/doc_extraction/extraction.rs @@ -12,15 +12,16 @@ const DOC_PATHS: &[&str] = &["docs/users-guide.md", "docs/developers-guide.md"]; /// Extracted TOML code blocks from documentation, loaded once at test startup. pub static DOC_TOML_BLOCKS: LazyLock> = LazyLock::new(|| { let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let workspace_root = std::path::Path::new(manifest_dir) - .parent() - .expect("installer crate should be in workspace"); + let Some(workspace_root) = std::path::Path::new(manifest_dir).parent() else { + panic!("installer crate should be in workspace"); + }; let mut all_blocks = Vec::new(); for path in DOC_PATHS { let guide_path = workspace_root.join(path); - let content = std::fs::read_to_string(&guide_path) - .unwrap_or_else(|_| panic!("failed to read {path}")); + let Ok(content) = std::fs::read_to_string(&guide_path) else { + panic!("documentation file {path} should be readable"); + }; all_blocks.extend(extract_toml_blocks(&content)); } all_blocks @@ -52,8 +53,8 @@ fn accumulate_toml_line(line: &str, current_block: &mut String) { /// key = "value" /// ``` /// "#; -/// let blocks = extract_toml_blocks(markdown); -/// assert_eq!(blocks.len(), 1); +/// let blocks = `extract_toml_blocks(markdown)`; +/// `assert_eq!(blocks.len()`, 1); /// assert!(!blocks[0].contains("# A comment")); /// ``` pub fn extract_toml_blocks(markdown: &str) -> Vec { @@ -86,15 +87,16 @@ pub fn extract_toml_blocks(markdown: &str) -> Vec { /// Find a TOML block containing the specified marker text. pub fn find_block_containing(marker: &str) -> String { - DOC_TOML_BLOCKS - .iter() - .find(|block| block.contains(marker)) - .unwrap_or_else(|| panic!("no TOML block containing '{marker}' found in documentation")) - .clone() + let Some(block) = DOC_TOML_BLOCKS.iter().find(|block| block.contains(marker)) else { + panic!("no TOML block containing '{marker}' found in documentation"); + }; + block.clone() } #[cfg(test)] mod tests { + //! Tests for extracting TOML blocks from documentation. + use super::*; #[test] @@ -116,8 +118,10 @@ other = true let blocks = extract_toml_blocks(markdown); assert_eq!(blocks.len(), 2); - assert!(blocks[0].contains("key = \"value\"")); - assert!(blocks[1].contains("other = true")); + let first = blocks.first().expect("first TOML block should be present"); + let second = blocks.get(1).expect("second TOML block should be present"); + assert!(first.contains("key = \"value\"")); + assert!(second.contains("other = true")); } #[test] @@ -132,11 +136,12 @@ key = "value" let blocks = extract_toml_blocks(markdown); assert_eq!(blocks.len(), 1); + let block = blocks.first().expect("TOML block should be present"); assert!( - !blocks[0].contains("# This is a comment"), + !block.contains("# This is a comment"), "expected comment to be skipped" ); - assert!(blocks[0].contains("key = \"value\"")); + assert!(block.contains("key = \"value\"")); } #[test] diff --git a/installer/tests/features/install_metrics.feature b/installer/tests/features/install_metrics.feature index 69519137..693724d1 100644 --- a/installer/tests/features/install_metrics.feature +++ b/installer/tests/features/install_metrics.feature @@ -9,8 +9,8 @@ Feature: Installer metrics recording Then total installs is 1 And download installs is 1 And build installs is 0 - And download rate is 1.0 - And build rate is 0.0 + And download rate is 1000 permille + And build rate is 0 permille And total installation time is 1200 milliseconds And summary line contains "download 1/1 (100.0%)" And summary line contains "build 0/1 (0.0%)" @@ -21,8 +21,8 @@ Feature: Installer metrics recording Then total installs is 1 And download installs is 0 And build installs is 1 - And download rate is 0.0 - And build rate is 1.0 + And download rate is 0 permille + And build rate is 1000 permille And total installation time is 900 milliseconds And summary line contains "download 0/1 (0.0%)" And summary line contains "build 1/1 (100.0%)" @@ -34,8 +34,8 @@ Feature: Installer metrics recording Then total installs is 2 And download installs is 1 And build installs is 1 - And download rate is 0.5 - And build rate is 0.5 + And download rate is 500 permille + And build rate is 500 permille And total installation time is 3000 milliseconds And summary line contains "download 1/2 (50.0%)" And summary line contains "build 1/2 (50.0%)" @@ -57,5 +57,5 @@ Feature: Installer metrics recording Scenario: Zero-state rates are zero Given an in-memory zero metrics aggregate When download and build rates are calculated - Then download rate is 0.0 - And build rate is 0.0 + Then download rate is 0 permille + And build rate is 0 permille diff --git a/installer/tests/support/mod.rs b/installer/tests/support/mod.rs index fbd8d874..c16c923e 100644 --- a/installer/tests/support/mod.rs +++ b/installer/tests/support/mod.rs @@ -4,39 +4,50 @@ //! including workspace path resolution, toolchain detection, and isolated rustup //! environment setup. -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + use tempfile::TempDir; use whitaker_installer::toolchain::parse_toolchain_channel; /// Returns the workspace root directory (parent of the installer crate). -pub fn workspace_root() -> PathBuf { +/// +/// # Errors +/// +/// Returns an error when the installer manifest directory has no parent. +pub fn workspace_root() -> Result { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() - .expect("installer crate is not at workspace root") - .to_path_buf() + .map(Path::to_path_buf) + .ok_or_else(|| String::from("installer crate is not at workspace root")) } /// Parses and returns the toolchain channel from rust-toolchain.toml. -pub fn pinned_toolchain_channel() -> String { - let toolchain_path = workspace_root().join("rust-toolchain.toml"); - let contents = - std::fs::read_to_string(&toolchain_path).expect("failed to read rust-toolchain.toml"); - parse_toolchain_channel(&contents).expect("failed to parse rust-toolchain.toml") +/// +/// # Errors +/// +/// Returns an error when rust-toolchain.toml cannot be read or parsed. +pub fn pinned_toolchain_channel() -> Result { + let toolchain_path = workspace_root()?.join("rust-toolchain.toml"); + let contents = std::fs::read_to_string(&toolchain_path) + .map_err(|error| format!("failed to read {}: {error}", toolchain_path.display()))?; + parse_toolchain_channel(&contents) + .map_err(|error| format!("failed to parse {}: {error}", toolchain_path.display())) } /// Checks if a toolchain is installed on the host system. /// -/// Sanitizes rustup environment by always setting RUSTUP_AUTO_INSTALL=0 and -/// RUSTUP_TOOLCHAIN to prevent host settings from leaking into tests. +/// Sanitizes rustup environment by always setting `RUSTUP_AUTO_INSTALL=0` and +/// `RUSTUP_TOOLCHAIN` to prevent host settings from leaking into tests. pub fn is_toolchain_installed(channel: &str) -> bool { Command::new("rustup") .args(["run", channel, "rustc", "--version"]) .env("RUSTUP_AUTO_INSTALL", "0") .env_remove("RUSTUP_TOOLCHAIN") .output() - .map(|o| o.status.success()) - .unwrap_or(false) + .is_ok_and(|o| o.status.success()) } /// Checks if a toolchain is installed in an isolated rustup environment. @@ -55,8 +66,7 @@ pub fn is_toolchain_installed_in_env( .env("RUSTUP_AUTO_INSTALL", "0") .env_remove("RUSTUP_TOOLCHAIN") .output() - .map(|o| o.status.success()) - .unwrap_or(false) + .is_ok_and(|o| o.status.success()) } /// Result of setting up an isolated rustup environment. @@ -68,13 +78,17 @@ pub struct IsolatedRustupEnv { /// Initializes an isolated rustup environment by running `rustup show`. /// /// This creates necessary settings files that rustup expects to exist. -/// The function sets RUSTUP_AUTO_INSTALL=0 to prevent auto-installing any -/// toolchain during initialization, clears RUSTUP_TOOLCHAIN to avoid +/// The function sets `RUSTUP_AUTO_INSTALL=0` to prevent auto-installing any +/// toolchain during initialization, clears `RUSTUP_TOOLCHAIN` to avoid /// rust-toolchain.toml files affecting initialization, and runs from -/// rustup_home as a current directory to prevent rustup from walking up +/// `rustup_home` as a current directory to prevent rustup from walking up /// to the workspace and discovering a project's rust-toolchain.toml /// (which would affect toolchain selection). -fn init_isolated_rustup(rustup_home: &Path, cargo_home: &Path) { +/// +/// # Errors +/// +/// Returns an error when rustup cannot be run or reports a failure. +fn init_isolated_rustup(rustup_home: &Path, cargo_home: &Path) -> Result<(), String> { let init_output = Command::new("rustup") .arg("show") .current_dir(rustup_home) // Prevent rustup from discovering workspace rust-toolchain.toml @@ -83,13 +97,14 @@ fn init_isolated_rustup(rustup_home: &Path, cargo_home: &Path) { .env("RUSTUP_AUTO_INSTALL", "0") .env_remove("RUSTUP_TOOLCHAIN") .output() - .expect("failed to initialise isolated rustup environment"); + .map_err(|error| format!("failed to initialize isolated rustup environment: {error}"))?; - assert!( - init_output.status.success(), - "failed to initialise isolated rustup: {}", - String::from_utf8_lossy(&init_output.stderr) - ); + if !init_output.status.success() { + return Err(format!( + "failed to initialize isolated rustup: {}", + String::from_utf8_lossy(&init_output.stderr) + )); + } let self_update_output = Command::new("rustup") .args(["set", "auto-self-update", "disable"]) @@ -99,80 +114,101 @@ fn init_isolated_rustup(rustup_home: &Path, cargo_home: &Path) { .env("RUSTUP_AUTO_INSTALL", "0") .env_remove("RUSTUP_TOOLCHAIN") .output() - .expect("failed to disable rustup self-update in isolated environment"); + .map_err(|error| { + format!("failed to disable rustup self-update in isolated environment: {error}") + })?; + + if !self_update_output.status.success() { + return Err(format!( + "failed to disable rustup self-update: {}", + String::from_utf8_lossy(&self_update_output.stderr) + )); + } - assert!( - self_update_output.status.success(), - "failed to disable rustup self-update: {}", - String::from_utf8_lossy(&self_update_output.stderr) - ); + Ok(()) } /// Parses the output of a command that locates rustup, extracting the first path. -fn parse_rustup_location_output(output: &std::process::Output) -> String { +/// +/// # Errors +/// +/// Returns an error when the command produced no output lines. +fn parse_rustup_location_output(output: &std::process::Output) -> Result { String::from_utf8_lossy(&output.stdout) .lines() .next() - .expect("rustup not found in PATH") - .trim() - .to_string() + .map(|line| line.trim().to_owned()) + .ok_or_else(|| String::from("rustup not found in PATH")) } /// Locates the system rustup binary path. +/// +/// # Errors +/// +/// Returns an error when the lookup command cannot run or rustup is absent. #[cfg(unix)] -fn find_system_rustup() -> String { +fn find_system_rustup() -> Result { let output = Command::new("which") .arg("rustup") .output() - .expect("failed to run which rustup"); + .map_err(|error| format!("failed to run which rustup: {error}"))?; parse_rustup_location_output(&output) } #[cfg(windows)] -fn find_system_rustup() -> String { +fn find_system_rustup() -> Result { let output = Command::new("where") .arg("rustup") .output() - .expect("failed to run where rustup"); + .map_err(|error| format!("failed to run where rustup: {error}"))?; parse_rustup_location_output(&output) } -/// Installs rustup into the isolated cargo_bin directory. +/// Installs rustup into the isolated `cargo_bin` directory. +/// +/// # Errors +/// +/// Returns an error when rustup cannot be linked or copied into `cargo_bin`. #[cfg(unix)] -fn install_rustup_to_cargo_bin(rustup_path: &str, cargo_bin: &Path) { +fn install_rustup_to_cargo_bin(rustup_path: &str, cargo_bin: &Path) -> Result<(), String> { std::os::unix::fs::symlink(rustup_path, cargo_bin.join("rustup")) - .expect("failed to symlink rustup to CARGO_HOME/bin"); + .map_err(|error| format!("failed to symlink rustup to CARGO_HOME/bin: {error}")) } #[cfg(windows)] -fn install_rustup_to_cargo_bin(rustup_path: &str, cargo_bin: &Path) { +fn install_rustup_to_cargo_bin(rustup_path: &str, cargo_bin: &Path) -> Result<(), String> { std::fs::copy(rustup_path, cargo_bin.join("rustup.exe")) - .expect("failed to copy rustup to CARGO_HOME/bin"); + .map(|_| ()) + .map_err(|error| format!("failed to copy rustup to CARGO_HOME/bin: {error}")) } -/// Sets up isolated RUSTUP_HOME and CARGO_HOME directories for testing. +/// Sets up isolated `RUSTUP_HOME` and `CARGO_HOME` directories for testing. /// /// This ensures the auto-install code path is exercised regardless of host state. /// The function initializes rustup in the isolated environment and makes the system /// rustup binary available (via symlink on Unix, copy on Windows). /// -/// # Panics +/// # Errors /// -/// Panics if the isolated environment cannot be created or initialized. -pub fn setup_isolated_rustup() -> IsolatedRustupEnv { - let rustup_home = TempDir::new().expect("failed to create RUSTUP_HOME temp dir"); - let cargo_home = TempDir::new().expect("failed to create CARGO_HOME temp dir"); +/// Returns an error if the isolated environment cannot be created or +/// initialized. +pub fn setup_isolated_rustup() -> Result { + let rustup_home = TempDir::new() + .map_err(|error| format!("failed to create RUSTUP_HOME temp dir: {error}"))?; + let cargo_home = + TempDir::new().map_err(|error| format!("failed to create CARGO_HOME temp dir: {error}"))?; - init_isolated_rustup(rustup_home.path(), cargo_home.path()); + init_isolated_rustup(rustup_home.path(), cargo_home.path())?; let cargo_bin = cargo_home.path().join("bin"); - std::fs::create_dir_all(&cargo_bin).expect("failed to create CARGO_HOME/bin"); + std::fs::create_dir_all(&cargo_bin) + .map_err(|error| format!("failed to create CARGO_HOME/bin: {error}"))?; - let rustup_path = find_system_rustup(); - install_rustup_to_cargo_bin(&rustup_path, &cargo_bin); + let rustup_path = find_system_rustup()?; + install_rustup_to_cargo_bin(&rustup_path, &cargo_bin)?; - IsolatedRustupEnv { + Ok(IsolatedRustupEnv { rustup_home, cargo_home, - } + }) } diff --git a/installer/tests/toolchain_steps/mod.rs b/installer/tests/toolchain_steps/mod.rs index 3a220dc7..eb2eaf64 100644 --- a/installer/tests/toolchain_steps/mod.rs +++ b/installer/tests/toolchain_steps/mod.rs @@ -3,20 +3,24 @@ //! These step implementations are used by the scenarios in //! `behaviour_toolchain.rs` via rstest-bdd macros. +mod scenario_setup; + +use std::process::Command; + use rstest::fixture; use rstest_bdd_macros::{given, then, when}; -use std::cell::{Cell, RefCell}; -use std::process::{Command, Output}; -use tempfile::TempDir; - -use super::prebuilt_markers::PREBUILT_INSTALL_MARKER; -use super::support::{ - is_toolchain_installed, is_toolchain_installed_in_env, pinned_toolchain_channel, - setup_isolated_rustup, workspace_root, +pub use scenario_setup::{FAKE_TOOLCHAIN, ToolchainWorld, setup_install_scenario}; +use scenario_setup::{ + ensure_toolchain_installed_in_isolated_env, + get_combined_output_string, + get_output, + get_stderr_string, + setup_auto_install_scenario, + setup_dry_run_scenario, + setup_failure_scenario, }; -/// Non-existent toolchain channel used to exercise auto-install failure paths. -pub const FAKE_TOOLCHAIN: &str = "nonexistent-nightly-2024-01-01"; +use super::{prebuilt_markers::PREBUILT_INSTALL_MARKER, support::workspace_root}; /// Output marker indicating successful library staging (build-from-source path). const STAGING_OUTPUT_MARKER: &str = "Staging libraries to"; @@ -30,185 +34,106 @@ const TOOLCHAIN_ERROR_MARKER: &str = "installation failed"; /// Maximum output lines expected in quiet mode error scenarios. const QUIET_MODE_MAX_LINES: usize = 5; -#[derive(Default)] -pub struct ToolchainWorld { - pub args: RefCell>, - pub output: RefCell>, - pub should_skip_assertions: Cell, - pub temp_dir: RefCell>, - pub rustup_home: RefCell>, - pub cargo_home: RefCell>, - pub pinned_channel: RefCell, -} - -fn get_output(world: &ToolchainWorld) -> std::cell::Ref<'_, Output> { - let output = world.output.borrow(); - std::cell::Ref::map(output, |opt| opt.as_ref().expect("output not set")) -} - -fn get_combined_output_string(world: &ToolchainWorld) -> String { - let output = get_output(world); - format!( - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ) -} - -fn get_stderr_string(world: &ToolchainWorld) -> String { - let output = get_output(world); - String::from_utf8_lossy(&output.stderr).to_string() -} - macro_rules! skip_if_needed { ($world:expr) => { if $world.should_skip_assertions.get() { - return; + return Ok(()); } }; } -fn skip_scenario_when_toolchain_missing(world: &ToolchainWorld, channel: &str) { - if !is_toolchain_installed(channel) { - eprintln!("Skipping scenario: toolchain '{channel}' not installed."); - world.should_skip_assertions.set(true); - rstest_bdd::skip!("toolchain '{channel}' is not installed.", channel = channel); - } -} - -fn setup_temp_dir(world: &ToolchainWorld) -> String { - let temp_dir = TempDir::new().expect("failed to create temp dir"); - let target_dir = temp_dir.path().to_string_lossy().to_string(); - world.temp_dir.replace(Some(temp_dir)); - target_dir -} - -fn setup_dry_run_scenario(world: &ToolchainWorld, extra_args: &[&str]) { - let channel = pinned_toolchain_channel(); - skip_scenario_when_toolchain_missing(world, &channel); - world.pinned_channel.replace(channel.clone()); - - let target_dir = setup_temp_dir(world); - let mut args: Vec = extra_args.iter().map(|s| (*s).to_owned()).collect(); - args.extend(["--target-dir".to_owned(), target_dir]); - world.args.replace(args); -} - -pub fn setup_install_scenario(world: &ToolchainWorld, extra_args: &[&str]) { - let env = setup_isolated_rustup(); - world.rustup_home.replace(Some(env.rustup_home)); - world.cargo_home.replace(Some(env.cargo_home)); - world.pinned_channel.replace(pinned_toolchain_channel()); +fn assert_toolchain_install_message_presence( + world: &ToolchainWorld, + expected_presence: bool, +) -> Result<(), String> { + skip_if_needed!(world); - let target_dir = setup_temp_dir(world); - let mut args: Vec = extra_args.iter().map(|s| (*s).to_owned()).collect(); - args.extend(["--target-dir".to_owned(), target_dir]); - world.args.replace(args); + let output = get_combined_output_string(world)?; + let channel = world.pinned_channel.borrow().clone(); + let output_lowercase = output.to_lowercase(); + let channel_lowercase = channel.to_lowercase(); + let expected_message = format!("toolchain {channel} installed successfully").to_lowercase(); + let has_install_message = output_lowercase.contains(&expected_message) + || output_lowercase.contains(&channel_lowercase) + && output_lowercase.contains(TOOLCHAIN_INSTALLED_MARKER); + + if has_install_message == expected_presence { + Ok(()) + } else if expected_presence { + Err(format!( + "expected success marker for channel '{channel}' in output, got:\n{output}" + )) + } else { + Err(format!( + "expected no installation message for channel '{channel}' in output, got:\n{output}" + )) + } } -fn setup_failure_scenario(world: &ToolchainWorld, extra_args: &[&str]) { - // Use isolated rustup environment so the install failure path is exercised - // without affecting the host system. - let env = setup_isolated_rustup(); - world.rustup_home.replace(Some(env.rustup_home)); - world.cargo_home.replace(Some(env.cargo_home)); - - let target_dir = setup_temp_dir(world); - // Filter out --dry-run to exercise the real install path - let mut args: Vec = extra_args - .iter() - .filter(|s| **s != "--dry-run") - .map(|s| (*s).to_owned()) - .collect(); - args.extend([ - "--toolchain".to_owned(), - FAKE_TOOLCHAIN.to_owned(), - "--target-dir".to_owned(), - target_dir, - "--skip-deps".to_owned(), - ]); - world.args.replace(args); -} +fn assert_stderr_contains( + world: &ToolchainWorld, + expected: &str, + failure_message: impl FnOnce(&str) -> String, +) -> Result<(), String> { + skip_if_needed!(world); -fn assert_toolchain_installed_in_isolated_env(world: &ToolchainWorld) { - let rustup_home = world.rustup_home.borrow(); - let cargo_home = world.cargo_home.borrow(); - assert!( - rustup_home.is_some() && cargo_home.is_some(), - "isolated rustup environment must be configured for install scenario" - ); - let rustup = rustup_home.as_ref().expect("rustup_home"); - let cargo = cargo_home.as_ref().expect("cargo_home"); - let channel = pinned_toolchain_channel(); - assert!( - is_toolchain_installed_in_env(&channel, rustup, cargo), - "toolchain '{channel}' was not installed in isolated environment" - ); + let stderr = get_stderr_string(world)?; + if stderr.contains(expected) { + Ok(()) + } else { + Err(failure_message(&stderr)) + } } // --------------------------------------------------------------------------- // Fixture // --------------------------------------------------------------------------- +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -pub fn world() -> ToolchainWorld { - ToolchainWorld::default() -} +pub fn world() -> ToolchainWorld { ToolchainWorld::default() } // --------------------------------------------------------------------------- // Given steps // --------------------------------------------------------------------------- #[given("the installer is invoked with auto-detect toolchain")] -pub fn given_auto_detect_toolchain(world: &ToolchainWorld) { - setup_dry_run_scenario(world, &["--dry-run"]); +pub fn given_auto_detect_toolchain(world: &ToolchainWorld) -> Result<(), String> { + setup_dry_run_scenario(world, &["--dry-run"]) } #[given("the installer is invoked with auto-detect toolchain in quiet mode")] -pub fn given_auto_detect_toolchain_quiet(world: &ToolchainWorld) { - setup_dry_run_scenario(world, &["--dry-run", "--quiet"]); -} - -fn setup_auto_install_scenario(world: &ToolchainWorld) { - // Skip auto-install tests on Windows - toolchain downloads are extremely slow - // due to Windows Defender scanning and larger binaries. The code path is - // identical to Linux; we're testing rustup behaviour rather than installer logic. - if cfg!(windows) { - eprintln!("Skipping auto-install scenario on Windows (toolchain downloads too slow)."); - world.should_skip_assertions.set(true); - rstest_bdd::skip!("auto-install tests skipped on Windows"); - } - // Use --skip-wrapper to prevent writing to the user's real ~/.local/bin. - setup_install_scenario(world, &["--jobs", "1", "--skip-deps", "--skip-wrapper"]); +pub fn given_auto_detect_toolchain_quiet(world: &ToolchainWorld) -> Result<(), String> { + setup_dry_run_scenario(world, &["--dry-run", "--quiet"]) } #[given("the installer is invoked with auto-detect toolchain to a temporary directory")] -pub fn given_auto_detect_toolchain_install(world: &ToolchainWorld) { - setup_auto_install_scenario(world); +pub fn given_auto_detect_toolchain_install(world: &ToolchainWorld) -> Result<(), String> { + setup_auto_install_scenario(world) } #[given("the installer is invoked with isolated rustup to force auto-install")] -pub fn given_isolated_rustup_auto_install(world: &ToolchainWorld) { - setup_auto_install_scenario(world); +pub fn given_isolated_rustup_auto_install(world: &ToolchainWorld) -> Result<(), String> { + setup_auto_install_scenario(world) } #[given("the installer is invoked with isolated rustup in quiet mode")] -pub fn given_isolated_rustup_quiet(world: &ToolchainWorld) { +pub fn given_isolated_rustup_quiet(world: &ToolchainWorld) -> Result<(), String> { // Use --skip-wrapper to prevent writing to the user's real ~/.local/bin. setup_install_scenario( world, &["--jobs", "1", "--quiet", "--skip-deps", "--skip-wrapper"], - ); + ) } #[given("the installer is invoked with a non-existent toolchain")] -pub fn given_nonexistent_toolchain(world: &ToolchainWorld) { - setup_failure_scenario(world, &[]); +pub fn given_nonexistent_toolchain(world: &ToolchainWorld) -> Result<(), String> { + setup_failure_scenario(world, &[]) } #[given("the installer is invoked with a non-existent toolchain in quiet mode")] -pub fn given_nonexistent_toolchain_quiet(world: &ToolchainWorld) { - setup_failure_scenario(world, &["--quiet"]); +pub fn given_nonexistent_toolchain_quiet(world: &ToolchainWorld) -> Result<(), String> { + setup_failure_scenario(world, &["--quiet"]) } // --------------------------------------------------------------------------- @@ -216,13 +141,13 @@ pub fn given_nonexistent_toolchain_quiet(world: &ToolchainWorld) { // --------------------------------------------------------------------------- #[when("the installer CLI is run")] -pub fn when_installer_cli_run(world: &ToolchainWorld) { +pub fn when_installer_cli_run(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); let args = world.args.borrow(); let mut cmd = Command::new(env!("CARGO_BIN_EXE_whitaker-installer")); cmd.args(args.iter()); - cmd.current_dir(workspace_root()); + cmd.current_dir(workspace_root()?); // Sanitize rustup environment to prevent host settings from leaking // into tests: always disable auto-install and remove toolchain overrides @@ -236,8 +161,11 @@ pub fn when_installer_cli_run(world: &ToolchainWorld) { cmd.env("CARGO_HOME", cargo_home.path()); } - let output = cmd.output().expect("failed to run whitaker-installer"); + let output = cmd + .output() + .map_err(|error| format!("failed to run whitaker-installer: {error}"))?; world.output.replace(Some(output)); + Ok(()) } // --------------------------------------------------------------------------- @@ -245,131 +173,123 @@ pub fn when_installer_cli_run(world: &ToolchainWorld) { // --------------------------------------------------------------------------- #[then("the CLI exits successfully")] -pub fn then_cli_exits_successfully(world: &ToolchainWorld) { +pub fn then_cli_exits_successfully(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - let output = get_output(world); - assert!( - output.status.success(), - "expected success, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); + let output = get_output(world)?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "expected success, stderr: {}", + String::from_utf8_lossy(&output.stderr) + )) + } } #[then("dry-run output shows the detected toolchain")] -pub fn then_dry_run_shows_toolchain(world: &ToolchainWorld) { +pub fn then_dry_run_shows_toolchain(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - let output = get_output(world); + let output = get_output(world)?; let stderr = String::from_utf8_lossy(&output.stderr); let expected_channel = world.pinned_channel.borrow().clone(); - assert!( - stderr.contains(&expected_channel), - "expected toolchain '{expected_channel}' in output, stderr: {stderr}" - ); + if stderr.contains(&expected_channel) { + Ok(()) + } else { + Err(format!( + "expected toolchain '{expected_channel}' in output, stderr: {stderr}" + )) + } } #[then("no toolchain installation message is shown")] -pub fn then_no_install_message(world: &ToolchainWorld) { - skip_if_needed!(world); - let out = get_combined_output_string(world); - let channel = world.pinned_channel.borrow().clone(); - let out_lc = out.to_lowercase(); - let needle = format!("toolchain {channel} installed successfully").to_lowercase(); - assert!( - !(out_lc.contains(&needle) - || out_lc.contains(&channel.to_lowercase()) - && out_lc.contains(TOOLCHAIN_INSTALLED_MARKER)), - "expected no installation message for channel '{channel}' in output, got:\n{out}" - ); +pub fn then_no_install_message(world: &ToolchainWorld) -> Result<(), String> { + assert_toolchain_install_message_presence(world, false) } #[then("the toolchain installation message is shown")] -pub fn then_install_message_shown(world: &ToolchainWorld) { - skip_if_needed!(world); - let out = get_combined_output_string(world); - let channel = world.pinned_channel.borrow().clone(); - let out_lc = out.to_lowercase(); - let needle = format!("toolchain {channel} installed successfully").to_lowercase(); - let ok = out_lc.contains(&needle) - || (out_lc.contains("installed successfully") && out_lc.contains(&channel.to_lowercase())); - assert!( - ok, - "expected success marker for channel '{channel}' in output, got:\n{out}" - ); +pub fn then_install_message_shown(world: &ToolchainWorld) -> Result<(), String> { + assert_toolchain_install_message_presence(world, true) } #[then("installation succeeds or is skipped")] -pub fn then_installation_succeeds_or_is_skipped(world: &ToolchainWorld) { +pub fn then_installation_succeeds_or_is_skipped(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - let output = get_output(world); - assert!( - output.status.success(), - "installation failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_toolchain_installed_in_isolated_env(world); + { + let output = get_output(world)?; + if !output.status.success() { + return Err(format!( + "installation failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + ensure_toolchain_installed_in_isolated_env(world) } #[then("the toolchain is installed in the isolated environment")] -pub fn then_toolchain_installed_in_isolated_env(world: &ToolchainWorld) { +pub fn then_toolchain_installed_in_isolated_env(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - assert_toolchain_installed_in_isolated_env(world); + ensure_toolchain_installed_in_isolated_env(world) } #[then("the suite library is staged")] -pub fn then_suite_library_is_staged(world: &ToolchainWorld) { +pub fn then_suite_library_is_staged(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - let output = get_output(world); + let output = get_output(world)?; let stderr = String::from_utf8_lossy(&output.stderr); // Accept either the build-from-source staging marker or the prebuilt // success marker — when prebuilt artefacts are available the installer // downloads them instead of building locally. let has_local_staging_marker = stderr.contains(STAGING_OUTPUT_MARKER); let has_prebuilt_staging_marker = stderr.contains(PREBUILT_INSTALL_MARKER); - assert!( - has_local_staging_marker || has_prebuilt_staging_marker, - "expected '{STAGING_OUTPUT_MARKER}' or '{PREBUILT_INSTALL_MARKER}' in staging output, stderr: {stderr}" - ); + if has_local_staging_marker || has_prebuilt_staging_marker { + Ok(()) + } else { + Err(format!( + "expected '{STAGING_OUTPUT_MARKER}' or '{PREBUILT_INSTALL_MARKER}' in staging output, \ + stderr: {stderr}" + )) + } } #[then("the CLI exits with an error")] -pub fn then_cli_exits_with_error(world: &ToolchainWorld) { +pub fn then_cli_exits_with_error(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - let output = get_output(world); - assert!( - !output.status.success(), - "expected failure exit code, but command succeeded" - ); + let output = get_output(world)?; + if output.status.success() { + return Err(String::from( + "expected failure exit code, but command succeeded", + )); + } + Ok(()) } #[then("the error mentions toolchain installation failure")] -pub fn then_error_mentions_install_failure(world: &ToolchainWorld) { - skip_if_needed!(world); - let stderr = get_stderr_string(world); - assert!( - stderr.contains(TOOLCHAIN_ERROR_MARKER), - "expected '{}' in stderr: {stderr}", - TOOLCHAIN_ERROR_MARKER - ); +pub fn then_error_mentions_install_failure(world: &ToolchainWorld) -> Result<(), String> { + assert_stderr_contains(world, TOOLCHAIN_ERROR_MARKER, |stderr| { + format!("expected '{TOOLCHAIN_ERROR_MARKER}' in stderr: {stderr}") + }) } #[then("the error includes the toolchain name")] -pub fn then_error_includes_toolchain_name(world: &ToolchainWorld) { - skip_if_needed!(world); - let stderr = get_stderr_string(world); - assert!( - stderr.contains(FAKE_TOOLCHAIN), - "expected toolchain name '{FAKE_TOOLCHAIN}' in error output, stderr: {stderr}" - ); +pub fn then_error_includes_toolchain_name(world: &ToolchainWorld) -> Result<(), String> { + assert_stderr_contains(world, FAKE_TOOLCHAIN, |stderr| { + format!("expected toolchain name '{FAKE_TOOLCHAIN}' in error output, stderr: {stderr}") + }) } #[then("the error output is minimal")] -pub fn then_error_output_is_minimal(world: &ToolchainWorld) { +pub fn then_error_output_is_minimal(world: &ToolchainWorld) -> Result<(), String> { skip_if_needed!(world); - let output = get_output(world); + let output = get_output(world)?; let stderr = String::from_utf8_lossy(&output.stderr); let line_count = stderr.lines().count(); - assert!( - line_count <= QUIET_MODE_MAX_LINES, - "expected at most {QUIET_MODE_MAX_LINES} lines in quiet mode, got {line_count}: {stderr}" - ); + if line_count <= QUIET_MODE_MAX_LINES { + Ok(()) + } else { + Err(format!( + "expected at most {QUIET_MODE_MAX_LINES} lines in quiet mode, got {line_count}: \ + {stderr}" + )) + } } diff --git a/installer/tests/toolchain_steps/scenario_setup.rs b/installer/tests/toolchain_steps/scenario_setup.rs new file mode 100644 index 00000000..ccba940c --- /dev/null +++ b/installer/tests/toolchain_steps/scenario_setup.rs @@ -0,0 +1,176 @@ +//! Scenario setup helpers and shared world state for toolchain behaviour tests. +//! +//! The step definitions in the parent module drive these helpers; keeping them +//! here keeps each module within the repository's size budget. + +use std::{ + cell::{Cell, RefCell}, + io::Write as _, + process::Output, +}; + +use tempfile::TempDir; + +use crate::support::{ + is_toolchain_installed, + is_toolchain_installed_in_env, + pinned_toolchain_channel, + setup_isolated_rustup, +}; + +/// Non-existent toolchain channel used to exercise auto-install failure paths. +pub const FAKE_TOOLCHAIN: &str = "nonexistent-nightly-2024-01-01"; + +#[derive(Default)] +pub struct ToolchainWorld { + pub args: RefCell>, + pub output: RefCell>, + pub should_skip_assertions: Cell, + pub temp_dir: RefCell>, + pub rustup_home: RefCell>, + pub cargo_home: RefCell>, + pub pinned_channel: RefCell, +} + +/// Borrows the captured CLI output, failing when no command has run yet. +pub(super) fn get_output(world: &ToolchainWorld) -> Result, String> { + let output = world.output.borrow(); + std::cell::Ref::filter_map(output, Option::as_ref) + .map_err(|_| String::from("CLI output not set; run the installer step first")) +} + +pub(super) fn get_combined_output_string(world: &ToolchainWorld) -> Result { + let output = get_output(world)?; + Ok(format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )) +} + +pub(super) fn get_stderr_string(world: &ToolchainWorld) -> Result { + let output = get_output(world)?; + Ok(String::from_utf8_lossy(&output.stderr).to_string()) +} + +/// Reports a skipped scenario on stderr without tripping `print_stderr`. +fn report_skip(reason: &str) -> Result<(), String> { + writeln!(std::io::stderr(), "{reason}") + .map_err(|error| format!("failed to report skipped scenario: {error}")) +} + +fn skip_scenario_when_toolchain_missing( + world: &ToolchainWorld, + channel: &str, +) -> Result<(), String> { + if !is_toolchain_installed(channel) { + report_skip(&format!( + "Skipping scenario: toolchain '{channel}' not installed." + ))?; + world.should_skip_assertions.set(true); + rstest_bdd::skip!("toolchain '{channel}' is not installed.", channel = channel); + } + Ok(()) +} + +fn setup_temp_dir(world: &ToolchainWorld) -> Result { + let temp_dir = TempDir::new().map_err(|error| format!("failed to create temp dir: {error}"))?; + let target_dir = temp_dir.path().to_string_lossy().to_string(); + world.temp_dir.replace(Some(temp_dir)); + Ok(target_dir) +} + +pub(super) fn setup_dry_run_scenario( + world: &ToolchainWorld, + extra_args: &[&str], +) -> Result<(), String> { + let channel = pinned_toolchain_channel()?; + skip_scenario_when_toolchain_missing(world, &channel)?; + world.pinned_channel.replace(channel.clone()); + + let target_dir = setup_temp_dir(world)?; + let mut args: Vec = extra_args.iter().map(|s| (*s).to_owned()).collect(); + args.extend(["--target-dir".to_owned(), target_dir]); + world.args.replace(args); + Ok(()) +} + +/// Prepares an isolated rustup environment and CLI arguments for an install run. +/// +/// # Errors +/// +/// Returns an error when the isolated environment or temporary directory +/// cannot be created, or the pinned toolchain cannot be resolved. +pub fn setup_install_scenario(world: &ToolchainWorld, extra_args: &[&str]) -> Result<(), String> { + let env = setup_isolated_rustup()?; + world.rustup_home.replace(Some(env.rustup_home)); + world.cargo_home.replace(Some(env.cargo_home)); + world.pinned_channel.replace(pinned_toolchain_channel()?); + + let target_dir = setup_temp_dir(world)?; + let mut args: Vec = extra_args.iter().map(|s| (*s).to_owned()).collect(); + args.extend(["--target-dir".to_owned(), target_dir]); + world.args.replace(args); + Ok(()) +} + +pub(super) fn setup_failure_scenario( + world: &ToolchainWorld, + extra_args: &[&str], +) -> Result<(), String> { + // Use isolated rustup environment so the install failure path is exercised + // without affecting the host system. + let env = setup_isolated_rustup()?; + world.rustup_home.replace(Some(env.rustup_home)); + world.cargo_home.replace(Some(env.cargo_home)); + + let target_dir = setup_temp_dir(world)?; + // Filter out --dry-run to exercise the real install path + let mut args: Vec = extra_args + .iter() + .filter(|s| **s != "--dry-run") + .map(|s| (*s).to_owned()) + .collect(); + args.extend([ + "--toolchain".to_owned(), + FAKE_TOOLCHAIN.to_owned(), + "--target-dir".to_owned(), + target_dir, + "--skip-deps".to_owned(), + ]); + world.args.replace(args); + Ok(()) +} + +pub(super) fn ensure_toolchain_installed_in_isolated_env( + world: &ToolchainWorld, +) -> Result<(), String> { + let rustup_home = world.rustup_home.borrow(); + let cargo_home = world.cargo_home.borrow(); + let (Some(rustup), Some(cargo)) = (rustup_home.as_ref(), cargo_home.as_ref()) else { + return Err(String::from( + "isolated rustup environment must be configured for install scenario", + )); + }; + let channel = pinned_toolchain_channel()?; + if is_toolchain_installed_in_env(&channel, rustup, cargo) { + Ok(()) + } else { + Err(format!( + "toolchain '{channel}' was not installed in isolated environment" + )) + } +} + +pub(super) fn setup_auto_install_scenario(world: &ToolchainWorld) -> Result<(), String> { + // Skip auto-install tests on Windows - toolchain downloads are extremely slow + // due to Windows Defender scanning and larger binaries. The code path is + // identical to Linux; we're testing rustup behaviour rather than installer logic. + if cfg!(windows) { + report_skip("Skipping auto-install scenario on Windows (toolchain downloads too slow).")?; + world.should_skip_assertions.set(true); + rstest_bdd::skip!("auto-install tests skipped on Windows"); + } + // Use --skip-wrapper to prevent writing to the user's real ~/.local/bin. + setup_install_scenario(world, &["--jobs", "1", "--skip-deps", "--skip-wrapper"]) +} diff --git a/installer/tests/ui.rs b/installer/tests/ui.rs index dc04ac2e..5bf32653 100644 --- a/installer/tests/ui.rs +++ b/installer/tests/ui.rs @@ -3,11 +3,10 @@ //! `sha2` 0.11 made two source-breaking changes that the installer had to work //! around: //! -//! - `Sha256::finalize()` / `Sha256::digest()` now return -//! `hybrid_array::Array`, which does not implement -//! [`core::fmt::LowerHex`], so `format!("{:x}", digest)` no longer compiles. -//! - `Sha256` no longer implements [`std::io::Write`], so -//! `io::copy(reader, &mut hasher)` no longer compiles. +//! - `Sha256::finalize()` / `Sha256::digest()` now return `hybrid_array::Array`, which does +//! not implement [`core::fmt::LowerHex`], so `format!("{:x}", digest)` no longer compiles. +//! - `Sha256` no longer implements [`std::io::Write`], so `io::copy(reader, &mut hasher)` no longer +//! compiles. //! //! These `trybuild` cases pin those breaks: if a future change reintroduces the //! pre-0.11 pattern — for example by downgrading `sha2` back to 0.10 — the diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 39266bc6..ee754346 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "nightly-2026-05-28" -components = ["rustfmt", "clippy", "rustc-dev", "llvm-tools-preview", "rust-src"] +components = ["rustfmt", "clippy", "rustc-dev", "llvm-tools-preview", "rust-src", "rust-analyzer"] diff --git a/src/config.rs b/src/config.rs index fe7955f7..0314448f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,7 @@ //! `dylint.toml` when present and fall back to sensible defaults otherwise. use serde::Deserialize; -use whitaker_common::i18n::normalise_locale; +use whitaker_common::i18n::normalize_locale; /// Shared configuration for the workspace-level crate. #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] @@ -56,7 +56,8 @@ impl SharedConfig { #[cfg(not(feature = "dylint-driver"))] { panic!( - "`SharedConfig::load` uses the Dylint loader; use `SharedConfig::load_with` to inject a stub when testing" + "`SharedConfig::load` uses the Dylint loader; use `SharedConfig::load_with` to \ + inject a stub when testing" ); } } @@ -91,9 +92,7 @@ impl SharedConfig { /// Whitespace-only values are treated as absent to avoid surprising /// behaviour when `dylint.toml` is templated or patched. #[must_use] - pub fn locale(&self) -> Option<&str> { - normalise_locale(self.locale.as_deref()) - } + pub fn locale(&self) -> Option<&str> { normalize_locale(self.locale.as_deref()) } } /// Settings that influence the forthcoming `module_max_lines` lint. @@ -106,9 +105,7 @@ pub struct ModuleMaxLinesConfig { } impl ModuleMaxLinesConfig { - const fn default_max_lines() -> usize { - 400 - } + const fn default_max_lines() -> usize { 400 } } impl Default for ModuleMaxLinesConfig { @@ -121,9 +118,10 @@ impl Default for ModuleMaxLinesConfig { #[cfg(test)] mod tests { - use super::*; use rstest::rstest; + use super::*; + #[rstest] fn defaults_match_the_suite_baseline() { let config = SharedConfig::default(); @@ -133,7 +131,7 @@ mod tests { } #[rstest] - fn deserialises_overrides_from_toml() { + fn deserializes_overrides_from_toml() { let source = "[module_max_lines]\nmax_lines = 120\n"; // Panic with the TOML parser's message so broken overrides are easy to debug. @@ -144,7 +142,7 @@ mod tests { } #[rstest] - fn deserialises_locale_override() { + fn deserializes_locale_override() { let source = "locale = \"cy\"\n"; let config = toml::from_str::(source) @@ -164,7 +162,7 @@ mod tests { } #[rstest] - fn propagates_deserialisation_failures() { + fn propagates_deserialization_failures() { let source = "[module_max_lines]\nmax_lines = \"a lot\"\n"; let outcome: Result = toml::from_str(source); diff --git a/src/hir/mod.rs b/src/hir/mod.rs index 0d2de1ec..908be583 100644 --- a/src/hir/mod.rs +++ b/src/hir/mod.rs @@ -1,7 +1,6 @@ //! Helpers for working with HIR constructs shared across Whitaker lints. -use std::collections::HashSet; -use std::sync::LazyLock; +use std::{collections::HashSet, sync::LazyLock}; use rustc_ast::AttrStyle; use rustc_hir as hir; @@ -188,7 +187,7 @@ pub fn collect_harness_test_functions(cx: &LateContext<'_>) -> HashSet( /// /// Two independent kinds of evidence qualify a module: /// -/// 1. An explicit `RSTEST_HARNESS_DESCRIPTOR` const. This marker is unambiguous -/// rstest-synthesis evidence — no hand-authored test module emits it — so it -/// qualifies regardless of the module's expansion provenance. Manual -/// regression fixtures that cannot run the real proc-macro rely on it. -/// 2. The inner `fn`/`const` harness-descriptor pair that `rustc --test` -/// synthesizes for a `#[test]` function. This pair, on its own, is *not* -/// rstest-specific: the `--test` harness emits the same-named, same-span -/// `const` descriptor for **any** `#[test]` function, so a hand-authored -/// `mod foo { #[test] fn bar() {} }` sitting next to an ordinary `fn foo` -/// has an identical HIR shape and would otherwise wrongly exempt `fn foo`. +/// 1. An explicit `RSTEST_HARNESS_DESCRIPTOR` const. This marker is unambiguous rstest-synthesis +/// evidence — no hand-authored test module emits it — so it qualifies regardless of the module's +/// expansion provenance. Manual regression fixtures that cannot run the real proc-macro rely on +/// it. +/// 2. The inner `fn`/`const` harness-descriptor pair that `rustc --test` synthesizes for a +/// `#[test]` function. This pair, on its own, is *not* rstest-specific: the `--test` harness +/// emits the same-named, same-span `const` descriptor for **any** `#[test]` function, so a +/// hand-authored `mod foo { #[test] fn bar() {} }` sitting next to an ordinary `fn foo` has an +/// identical HIR shape and would otherwise wrongly exempt `fn foo`. /// /// The distinguishing invariant is expansion provenance: rstest generates the /// companion module through its attribute proc-macro, so `module_item.span` diff --git a/src/hir/tests.rs b/src/hir/tests.rs index 8456daf1..97151478 100644 --- a/src/hir/tests.rs +++ b/src/hir/tests.rs @@ -12,22 +12,31 @@ //! regressions, which exercise this detection path end-to-end with real rstest //! expansion output: //! - `crates/no_expect_outside_tests/examples/pass_expect_in_rstest_harness.rs` -//! - `crates/no_expect_outside_tests/src/lib_ui_tests.rs` -//! (`example_compiles_under_test_harness`) +//! - `crates/no_expect_outside_tests/src/lib_ui_tests.rs` (`example_compiles_under_test_harness`) -use super::{recover_user_editable_hir_span, span_recovery_frames}; use rstest::{fixture, rstest}; use rustc_data_structures::stable_hash::{ - RawDefId, RawDefPathHash, RawSpan, StableHashControls, StableHashCtxt, StableHasher, + RawDefId, + RawDefPathHash, + RawSpan, + StableHashControls, + StableHashCtxt, + StableHasher, +}; +use rustc_span::{ + BytePos, + DUMMY_SP, + Span, + SyntaxContext, + edition::Edition, + hygiene::{ExpnData, ExpnKind, LocalExpnId, MacroKind, Transparency}, + sym, }; -use rustc_span::edition::Edition; -use rustc_span::hygiene::{ExpnData, ExpnKind, LocalExpnId, MacroKind, Transparency}; -use rustc_span::{BytePos, DUMMY_SP, Span, SyntaxContext, sym}; use whitaker_common::SpanRecoveryFrame; -fn test_span(lo: u32, hi: u32) -> Span { - Span::with_root_ctxt(BytePos(lo), BytePos(hi)) -} +use super::{recover_user_editable_hir_span, span_recovery_frames}; + +fn test_span(lo: u32, hi: u32) -> Span { Span::with_root_ctxt(BytePos(lo), BytePos(hi)) } #[derive(Clone, Copy)] struct TestHashStableContext; @@ -35,9 +44,7 @@ struct TestHashStableContext; impl StableHashCtxt for TestHashStableContext { fn stable_hash_span(&mut self, _span: RawSpan, _hasher: &mut StableHasher) {} - fn def_path_hash(&self, _def_id: RawDefId) -> RawDefPathHash { - RawDefPathHash([0; 16]) - } + fn def_path_hash(&self, _def_id: RawDefId) -> RawDefPathHash { RawDefPathHash([0; 16]) } fn stable_hash_controls(&self) -> StableHashControls { StableHashControls { hash_spans: false } diff --git a/src/lib.rs b/src/lib.rs index 73d71456..0e8eedd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,12 +18,13 @@ 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, + 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!" -} +pub const fn greet() -> &'static str { "Hello from Whitaker!" } diff --git a/src/lints/template/mod.rs b/src/lints/template/mod.rs index fb7bd9d6..4cf6db5e 100644 --- a/src/lints/template/mod.rs +++ b/src/lints/template/mod.rs @@ -34,9 +34,7 @@ impl TemplateFiles { /// assert!(files.manifest().contains("name = \"demo_lint\"")); /// ``` #[must_use] - pub fn manifest(&self) -> &str { - &self.manifest - } + pub fn manifest(&self) -> &str { &self.manifest } /// Returns the generated `src/lib.rs` source. /// @@ -52,9 +50,7 @@ impl TemplateFiles { /// assert!(files.lib_rs().contains("pub struct DemoLint")); /// ``` #[must_use] - pub fn lib_rs(&self) -> &str { - &self.lib_rs - } + pub fn lib_rs(&self) -> &str { &self.lib_rs } /// Parses the manifest into a TOML [`toml::Value`] for inspection. /// @@ -74,10 +70,7 @@ impl TemplateFiles { /// .manifest_document() /// .expect("generated manifest parses"); /// - /// assert_eq!( - /// manifest["package"]["name"].as_str(), - /// Some("demo_lint") - /// ); + /// assert_eq!(manifest["package"]["name"].as_str(), Some("demo_lint")); /// ``` pub fn manifest_document(&self) -> Result { toml::from_str(self.manifest()) @@ -98,7 +91,8 @@ pub enum TemplateError { }, /// Crate names may contain only ASCII lowercase letters, digits, `-`, or `_`. #[error( - "crate name may only contain lowercase ASCII letters, digits, '-' or '_' (invalid `{character}`)" + "crate name may only contain lowercase ASCII letters, digits, '-' or '_' (invalid \ + `{character}`)" )] InvalidCrateNameCharacter { /// Character that violated the allowed set. @@ -171,27 +165,19 @@ impl LintCrateTemplate { /// Returns the crate name used by the template. #[must_use] - pub fn crate_name(&self) -> &str { - &self.crate_name - } + pub fn crate_name(&self) -> &str { &self.crate_name } /// Returns the lint constant derived from the crate name. #[must_use] - pub fn lint_constant(&self) -> &str { - &self.lint_constant - } + pub fn lint_constant(&self) -> &str { &self.lint_constant } /// Returns the name of the lint pass struct. #[must_use] - pub fn pass_struct(&self) -> &str { - &self.pass_struct - } + pub fn pass_struct(&self) -> &str { &self.pass_struct } /// Returns the UI tests directory used by the template. #[must_use] - pub fn ui_tests_directory(&self) -> &str { - &self.ui_tests_directory - } + pub fn ui_tests_directory(&self) -> &str { &self.ui_tests_directory } /// Render the template into manifest and source files. #[must_use] @@ -210,9 +196,10 @@ impl LintCrateTemplate { #[cfg(test)] mod tests { - use super::*; use toml::Value; + use super::*; + #[test] fn template_rejects_invalid_crate_name() { let Err(error) = LintCrateTemplate::new("1invalid") else { diff --git a/src/lints/template/validation.rs b/src/lints/template/validation.rs index 04f6a4cc..1a2b1bad 100644 --- a/src/lints/template/validation.rs +++ b/src/lints/template/validation.rs @@ -97,23 +97,23 @@ pub(crate) fn lint_constant(crate_name: &str) -> String { .collect() } -fn capitalise_segment(segment: &str) -> Option { +fn capitalize_segment(segment: &str) -> Option { let mut characters = segment.chars(); let first = characters.next()?; - let mut capitalised = String::with_capacity(segment.len()); - capitalised.push(first.to_ascii_uppercase()); + let mut capitalized = String::with_capacity(segment.len()); + capitalized.push(first.to_ascii_uppercase()); for character in characters { - capitalised.push(character.to_ascii_lowercase()); + capitalized.push(character.to_ascii_lowercase()); } - Some(capitalised) + Some(capitalized) } pub(crate) fn pass_struct_name(crate_name: &str) -> String { crate_name .split(['-', '_']) - .filter_map(capitalise_segment) + .filter_map(capitalize_segment) .collect() } @@ -142,10 +142,10 @@ pub(crate) fn normalize_ui_directory(input: &str) -> Result Ok(()), - Err(message) => Err(HarnessError::RunnerFailure { - crate_name: crate_name_owned.into_inner(), - directory, - message, - }), - } + match runner(crate_name_str, directory.as_ref()) { + Ok(()) => Ok(()), + Err(message) => Err(HarnessError::RunnerFailure { + crate_name: crate_name_owned.clone().into_inner(), + directory: directory.clone(), + message, + }), + } + }) } -/// Serializes environment mutations required by `run_with_runner`. +/// Runs `callback` with the environment `run_with_runner` requires. /// /// `RUSTC_WRAPPER` must be cleared on every platform when set (for example to /// `sccache`), because `dylint_testing::Test::example` scans @@ -184,90 +186,32 @@ pub fn run_with_runner( /// /// On Windows, one additional environment variable needs temporary adjustment: /// -/// - `VCPKG_ROOT`: must be set to `C:\vcpkg` when that directory exists and the -/// variable is otherwise absent, so downstream `cargo` invocations resolve vcpkg. +/// - `VCPKG_ROOT`: must be set to `C:\vcpkg` when that directory exists and the variable is +/// otherwise absent, so downstream `cargo` invocations resolve vcpkg. /// -/// Each mutation and restoration step acquires `env_test_guard()` only for the -/// environment write itself. The guard deliberately does not hold that mutex -/// across the UI runner callback, because runner closures can perform their -/// own environment-guarded setup. -struct RunnerEnvGuard { - #[cfg(windows)] - vcpkg_root_was_absent: bool, - rustc_wrapper_previous: Option, -} - -impl Drop for RunnerEnvGuard { - fn drop(&mut self) { - let _env_guard = env_test_guard(); - - // SAFETY: `env_test_guard` serializes the restoration writes below. - #[cfg(windows)] - { - if self.vcpkg_root_was_absent { - unsafe { - env::remove_var("VCPKG_ROOT"); - } - } - } - if let Some(prev) = &self.rustc_wrapper_previous { - unsafe { - env::set_var("RUSTC_WRAPPER", prev); - } - } - } +/// The scoped mutations are serialized by `temp_env`'s re-entrant global lock, +/// so runner closures that perform their own scoped environment setup nest +/// without deadlocking, and every prior value is restored when the callback +/// returns or panics. +fn with_runner_env(callback: impl FnOnce() -> R) -> R { + with_vcpkg_root(|| with_env_var_removed("RUSTC_WRAPPER", callback)) } -fn runner_env_guard() -> Option { - #[cfg(windows)] - let vcpkg_candidate = Utf8Path::new(r"C:\vcpkg"); - #[cfg(windows)] - let vcpkg_applicable = vcpkg_candidate.is_dir(); - - let _env_guard = env_test_guard(); - let has_rustc_wrapper = env::var_os("RUSTC_WRAPPER").is_some(); - - #[cfg(windows)] - if !vcpkg_applicable && !has_rustc_wrapper { - return None; - } - #[cfg(not(windows))] - if !has_rustc_wrapper { - return None; - } +#[cfg(windows)] +fn with_vcpkg_root(callback: impl FnOnce() -> R) -> R { + use whitaker_common::test_support::with_env_var; - // All environment reads and writes below are serialized by `_env_guard`. - #[cfg(windows)] - let vcpkg_root_was_absent = if vcpkg_applicable && env::var_os("VCPKG_ROOT").is_none() { - // SAFETY: `_env_guard` serializes concurrent environment mutations. - unsafe { - env::set_var("VCPKG_ROOT", vcpkg_candidate.as_std_path()); - } - true + let candidate = Utf8Path::new(r"C:\vcpkg"); + if candidate.is_dir() && std::env::var_os("VCPKG_ROOT").is_none() { + with_env_var("VCPKG_ROOT", candidate.as_std_path(), callback) } else { - false - }; - - let rustc_wrapper_previous = env::var_os("RUSTC_WRAPPER").inspect(|_| { - // SAFETY: `_env_guard` serializes concurrent environment mutations. - unsafe { - env::remove_var("RUSTC_WRAPPER"); - } - }); - - #[cfg(windows)] - if !vcpkg_root_was_absent && rustc_wrapper_previous.is_none() { - // Nothing was mutated; release the guard early. - return None; + callback() } - - Some(RunnerEnvGuard { - #[cfg(windows)] - vcpkg_root_was_absent, - rustc_wrapper_previous, - }) } +#[cfg(not(windows))] +fn with_vcpkg_root(callback: impl FnOnce() -> R) -> R { callback() } + fn directory_is_rooted(path: &Utf8Path) -> bool { #[cfg(windows)] { diff --git a/src/testing/ui/tests.rs b/src/testing/ui/tests.rs index bcdf9d98..956e4509 100644 --- a/src/testing/ui/tests.rs +++ b/src/testing/ui/tests.rs @@ -1,11 +1,15 @@ //! Tests that verify the UI harness runner validates inputs and propagates //! errors from custom runners. -use super::{HarnessError, run_with_runner}; +use std::{ + env, + sync::{Mutex, MutexGuard, OnceLock}, +}; + use camino::{Utf8Path, Utf8PathBuf}; use rstest::rstest; -use std::env; -use std::sync::{Mutex, MutexGuard, OnceLock}; -use whitaker_common::test_support::EnvVarGuard; +use whitaker_common::test_support::with_env_var; + +use super::{HarnessError, run_with_runner}; #[rstest] #[case( @@ -97,31 +101,34 @@ fn propagates_runner_failures() { #[test] fn runner_env_guard_clears_and_restores_rustc_wrapper() { let _serial_guard = runner_env_guard_test_lock(); - let _guard = EnvVarGuard::set("RUSTC_WRAPPER", "sccache"); - - run_with_runner("lint", "ui", |_, _| { - assert_eq!(env::var_os("RUSTC_WRAPPER"), None); - Ok(()) - }) - .expect("runner should execute with RUSTC_WRAPPER cleared"); - - assert_eq!(env::var_os("RUSTC_WRAPPER"), Some("sccache".into())); + with_env_var("RUSTC_WRAPPER", "sccache", || { + run_with_runner("lint", "ui", |_, _| { + assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + Ok(()) + }) + .expect("runner should execute with RUSTC_WRAPPER cleared"); + + assert_eq!(env::var_os("RUSTC_WRAPPER"), Some("sccache".into())); + }); } #[cfg(windows)] #[test] fn windows_env_guard_leaves_absent_rustc_wrapper_untouched() { - let _serial_guard = runner_env_guard_test_lock(); - let _vcpkg_root = EnvVarGuard::set("VCPKG_ROOT", r"C:\vcpkg"); - let _rustc_wrapper = EnvVarGuard::remove("RUSTC_WRAPPER"); - - run_with_runner("lint", "ui", |_, _| { - assert_eq!(env::var_os("RUSTC_WRAPPER"), None); - Ok(()) - }) - .expect("runner should execute without installing RUSTC_WRAPPER"); + use whitaker_common::test_support::with_env_var_removed; - assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + let _serial_guard = runner_env_guard_test_lock(); + with_env_var("VCPKG_ROOT", r"C:\vcpkg", || { + with_env_var_removed("RUSTC_WRAPPER", || { + run_with_runner("lint", "ui", |_, _| { + assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + Ok(()) + }) + .expect("runner should execute without installing RUSTC_WRAPPER"); + + assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + }); + }); } #[test] diff --git a/src/testing/ui/toolchain.rs b/src/testing/ui/toolchain.rs index 4d7ed8d8..c502b21e 100644 --- a/src/testing/ui/toolchain.rs +++ b/src/testing/ui/toolchain.rs @@ -6,7 +6,9 @@ //! to refresh that copy. use std::{ - env, fmt, fs, + env, + fmt, + fs, io::Cursor, path::PathBuf, process::{Command, Output}, @@ -27,17 +29,11 @@ impl CrateName { clippy::missing_const_for_fn, reason = "String allocations require runtime heap access" )] - fn new_unchecked(name: String) -> Self { - Self(name) - } + fn new_unchecked(name: String) -> Self { Self(name) } - pub const fn as_str(&self) -> &str { - self.0.as_str() - } + pub const fn as_str(&self) -> &str { self.0.as_str() } - pub fn into_inner(self) -> String { - self.0 - } + pub fn into_inner(self) -> String { self.0 } } impl TryFrom<&str> for CrateName { @@ -55,15 +51,11 @@ impl TryFrom<&str> for CrateName { impl TryFrom for CrateName { type Error = CrateNameError; - fn try_from(value: String) -> Result { - Self::try_from(value.as_str()) - } + fn try_from(value: String) -> Result { Self::try_from(value.as_str()) } } impl AsRef for CrateName { - fn as_ref(&self) -> &str { - self.as_str() - } + fn as_ref(&self) -> &str { self.as_str() } } impl fmt::Display for CrateName { diff --git a/suite/Cargo.toml b/suite/Cargo.toml index 780108db..aada2f3e 100644 --- a/suite/Cargo.toml +++ b/suite/Cargo.toml @@ -32,6 +32,7 @@ experimental-rstest-helper-should-be-fixture = [ ] [dependencies] +whitaker-common = { workspace = true } dylint_linting = { workspace = true, optional = true } rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } @@ -49,6 +50,10 @@ bumpy_road_function = { path = "../crates/bumpy_road_function", optional = true, rstest_helper_should_be_fixture = { path = "../crates/rstest_helper_should_be_fixture", optional = true, features = ["dylint-driver", "constituent"] } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } + +[lints] +workspace = true diff --git a/suite/src/driver.rs b/suite/src/driver.rs index 49a86227..1eedcdcb 100644 --- a/suite/src/driver.rs +++ b/suite/src/driver.rs @@ -1,13 +1,9 @@ //! Combined lint wiring for the suite cdylib. -use crate::lints::SUITE_LINT_DECLS; -use dylint_linting::dylint_library; -use rustc_lint::{Lint, LintStore, LintVec, declare_combined_late_lint_pass}; -use rustc_session::Session; - // Import constituent lint pass types required by `late_lint_methods!`. use bumpy_road_function::BumpyRoadFunction; use conditional_max_n_branches::ConditionalMaxNBranches; +use dylint_linting::dylint_library; use function_attrs_follow_docs::FunctionAttrsFollowDocs; use module_max_lines::ModuleMaxLines; use module_must_have_inner_docs::ModuleMustHaveInnerDocs; @@ -16,8 +12,12 @@ use no_std_fs_operations::NoStdFsOperations; use no_unwrap_or_else_panic::NoUnwrapOrElsePanic; #[cfg(feature = "experimental-rstest-helper-should-be-fixture")] use rstest_helper_should_be_fixture::RstestHelperShouldBeFixture; +use rustc_lint::{Lint, LintStore, LintVec, declare_combined_late_lint_pass}; +use rustc_session::Session; use test_must_not_have_example::TestMustNotHaveExample; +use crate::lints::SUITE_LINT_DECLS; + dylint_library!(); macro_rules! define_suite_pass { @@ -80,19 +80,12 @@ pub fn register_suite_lints(store: &mut LintStore) { /// assert!(names.contains(&"no_unwrap_or_else_panic".to_string())); /// ``` #[must_use] -pub fn suite_lint_decls() -> &'static [&'static Lint] { - SUITE_LINT_DECLS -} +pub const fn suite_lint_decls() -> &'static [&'static Lint] { SUITE_LINT_DECLS } -/// Dylint entrypoint that initializes configuration and registers lints. -/// -/// # Safety -/// -/// Callers must pass non-null, correctly initialized `Session` and -/// `LintStore` references from the host compiler context that remain valid on -/// this thread for the duration of the call. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn register_lints(sess: &Session, store: &mut LintStore) { +/// Initializes Dylint configuration then registers the suite lints. +fn register_suite_entry(sess: &Session, store: &mut LintStore) { dylint_linting::init_config(sess); register_suite_lints(store); } + +whitaker_common::declare_dylint_register_entry!(register_suite_entry); diff --git a/suite/tests/registration.rs b/suite/tests/registration.rs index d137100a..8611a350 100644 --- a/suite/tests/registration.rs +++ b/suite/tests/registration.rs @@ -1,12 +1,15 @@ +//! Behaviour-driven tests for the suite registration wiring. #![feature(rustc_private)] #![cfg(feature = "dylint-driver")] -//! Behaviour-driven tests for the suite registration wiring. + +use std::{ + cell::RefCell, + panic::{AssertUnwindSafe, catch_unwind}, +}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use rustc_lint::LintStore; -use std::cell::RefCell; -use std::panic::{AssertUnwindSafe, catch_unwind}; use whitaker_suite::{register_suite_lints, suite_lint_decls, suite_lint_names}; struct RegistrationWorld { @@ -28,15 +31,12 @@ impl RegistrationWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> RegistrationWorld { - RegistrationWorld::new() -} +fn world() -> RegistrationWorld { RegistrationWorld::new() } #[given("an empty lint store")] -fn given_empty_store(world: &RegistrationWorld) { - world.reset(); -} +fn given_empty_store(world: &RegistrationWorld) { world.reset(); } #[given("the suite lints are already registered")] fn given_already_registered(world: &RegistrationWorld) { @@ -49,15 +49,16 @@ fn when_register_suite(world: &RegistrationWorld) { let registration = catch_unwind(AssertUnwindSafe(|| { register_suite_lints(&mut world.store.borrow_mut()); })) - .map(|_| ()) .map_err(|panic| { - if let Some(message) = panic.downcast_ref::<&str>() { - (*message).to_string() - } else if let Some(message) = panic.downcast_ref::() { - message.clone() - } else { - "registration panicked with a non-string payload".to_string() - } + panic.downcast_ref::<&str>().map_or_else( + || { + panic.downcast_ref::().map_or_else( + || "registration panicked with a non-string payload".to_owned(), + Clone::clone, + ) + }, + |message| (*message).to_owned(), + ) }); *world.result.borrow_mut() = registration; @@ -83,7 +84,7 @@ fn then_names_match(world: &RegistrationWorld) { .iter() .map(|lint| lint.name_lower()) .collect(); - let expected: Vec = suite_lint_names().map(str::to_string).collect(); + let expected: Vec = suite_lint_names().map(str::to_owned).collect(); assert_eq!(registered, expected); } @@ -94,7 +95,7 @@ fn then_decls_align() { .iter() .map(|lint| lint.name_lower()) .collect(); - let expected: Vec = suite_lint_names().map(str::to_string).collect(); + let expected: Vec = suite_lint_names().map(str::to_owned).collect(); assert_eq!(declared, expected); } @@ -110,11 +111,7 @@ fn then_registration_succeeds(world: &RegistrationWorld) { } #[scenario(path = "tests/features/suite_registration.feature", index = 0)] -fn scenario_registers_cleanly(world: RegistrationWorld) { - let _ = world; -} +fn scenario_registers_cleanly(world: RegistrationWorld) { let _ = world; } #[scenario(path = "tests/features/suite_registration.feature", index = 1)] -fn scenario_double_registration(world: RegistrationWorld) { - let _ = world; -} +fn scenario_double_registration(world: RegistrationWorld) { let _ = world; } diff --git a/tests/build_config.rs b/tests/build_config.rs index d1aa5df9..bd772414 100644 --- a/tests/build_config.rs +++ b/tests/build_config.rs @@ -1,7 +1,6 @@ //! Build configuration guards for dynamic linking expectations. -use std::fs; -use std::path::Path; +use std::{fs, path::Path}; use toml::Value; diff --git a/tests/config_loading.rs b/tests/config_loading.rs index b763a1b1..9740a637 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -1,10 +1,12 @@ //! Behaviour-driven tests for shared configuration loading. -use std::any::Any; -use std::cell::RefCell; -use std::convert::Infallible; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::str::FromStr; +use std::{ + any::Any, + cell::RefCell, + convert::Infallible, + panic::{AssertUnwindSafe, catch_unwind}, + str::FromStr, +}; mod support; @@ -12,17 +14,15 @@ use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; use whitaker::SharedConfig; -use whitaker_common::i18n::normalise_locale; +use whitaker_common::i18n::normalize_locale; +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn config_source() -> RefCell> { - RefCell::new(None) -} +fn config_source() -> RefCell> { RefCell::new(None) } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn load_result() -> RefCell>> { - RefCell::new(None) -} +fn load_result() -> RefCell>> { RefCell::new(None) } fn panic_message(payload: Box) -> String { match payload.downcast::() { @@ -50,15 +50,11 @@ impl FromStr for ErrorSnippet { } impl AsRef for ErrorSnippet { - fn as_ref(&self) -> &str { - self.0.as_str() - } + fn as_ref(&self) -> &str { self.0.as_str() } } impl ErrorSnippet { - fn into_inner(self) -> String { - self.0 - } + fn into_inner(self) -> String { self.0 } } #[given("no configuration state has been prepared")] @@ -71,9 +67,7 @@ fn reset_state( } #[given("no workspace configuration overrides are provided")] -fn no_overrides(config_source: &RefCell>) { - config_source.borrow_mut().take(); -} +fn no_overrides(config_source: &RefCell>) { config_source.borrow_mut().take(); } #[given("the workspace config sets the module max line limit to {value}")] fn override_max_lines(config_source: &RefCell>, value: usize) { @@ -121,7 +115,10 @@ fn load_config( let maybe_source = config_source.borrow().clone(); let outcome = catch_unwind(AssertUnwindSafe(|| { SharedConfig::load_with("module_max_lines", |crate_name| { - assert_eq!(crate_name, "module_max_lines"); + assert_eq!( + crate_name, "module_max_lines", + "the loader should request configuration for the requested lint", + ); maybe_source .as_ref() .map_or_else(SharedConfig::default, |input| { @@ -154,7 +151,7 @@ fn assert_locale( expected: StepLocale, ) { let raw = expected.into_inner(); - let expected_value = normalise_locale(Some(raw.as_str())) + let expected_value = normalize_locale(Some(raw.as_str())) .unwrap_or_else(|| panic!("expected the step to provide a locale value")); let borrow = load_result.borrow(); let config = match borrow.as_ref() { diff --git a/tests/lint_template.rs b/tests/lint_template.rs index 57437ada..935183ec 100644 --- a/tests/lint_template.rs +++ b/tests/lint_template.rs @@ -16,17 +16,11 @@ struct TemplateWorld { } impl TemplateWorld { - fn set_crate_name(&self, value: String) { - *self.crate_name.borrow_mut() = value; - } + fn set_crate_name(&self, value: String) { *self.crate_name.borrow_mut() = value; } - fn crate_name(&self) -> String { - self.crate_name.borrow().clone() - } + fn crate_name(&self) -> String { self.crate_name.borrow().clone() } - fn set_ui_directory(&self, value: String) { - *self.ui_directory.borrow_mut() = value; - } + fn set_ui_directory(&self, value: String) { *self.ui_directory.borrow_mut() = value; } fn render(&self) { let crate_name = self.crate_name.borrow().clone(); @@ -62,29 +56,21 @@ impl TemplateWorld { struct StepString(String); impl StepString { - fn into_inner(self) -> String { - self.0 - } + fn into_inner(self) -> String { self.0 } } impl From for StepString { - fn from(value: String) -> Self { - Self(value) - } + fn from(value: String) -> Self { Self(value) } } impl From for String { - fn from(value: StepString) -> Self { - value.0 - } + fn from(value: StepString) -> Self { value.0 } } impl std::str::FromStr for StepString { type Err = std::convert::Infallible; - fn from_str(input: &str) -> Result { - Ok(Self(input.to_owned())) - } + fn from_str(input: &str) -> Result { Ok(Self(input.to_owned())) } } #[fixture] @@ -96,9 +82,7 @@ fn world() -> TemplateWorld { } #[given("the lint crate name is blank")] -fn given_blank_name(world: &TemplateWorld) { - world.set_crate_name(String::new()); -} +fn given_blank_name(world: &TemplateWorld) { world.set_crate_name(String::new()); } #[given("the lint crate name is {name}")] fn given_crate_name(world: &TemplateWorld, name: StepString) { @@ -111,14 +95,10 @@ fn given_ui_directory(world: &TemplateWorld, directory: StepString) { } #[given("the UI tests directory is blank")] -fn given_blank_ui_directory(world: &TemplateWorld) { - world.set_ui_directory(String::new()); -} +fn given_blank_ui_directory(world: &TemplateWorld) { world.set_ui_directory(String::new()); } #[when("I render the lint crate template")] -fn when_render(world: &TemplateWorld) { - world.render(); -} +fn when_render(world: &TemplateWorld) { world.render(); } #[then("the manifest declares a cdylib crate type")] fn then_manifest_declares_cdylib(world: &TemplateWorld) { @@ -294,71 +274,43 @@ fn then_parent_directory_error(world: &TemplateWorld) { } #[scenario(path = "tests/features/lint_template.feature", index = 0)] -fn scenario_renders_manifest_and_source(world: TemplateWorld) { - let _ = world; -} +fn scenario_renders_manifest_and_source(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 1)] -fn scenario_renders_nested_ui_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_renders_nested_ui_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 2)] -fn scenario_renders_windows_ui_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_renders_windows_ui_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 3)] -fn scenario_rejects_blank_name(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_blank_name(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 4)] -fn scenario_rejects_non_letter_start(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_non_letter_start(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 5)] -fn scenario_rejects_trailing_separator(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_trailing_separator(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 6)] -fn scenario_rejects_absolute_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_absolute_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 7)] -fn scenario_rejects_absolute_windows_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_absolute_windows_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 8)] -fn scenario_rejects_unc_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_unc_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 9)] -fn scenario_rejects_drive_relative_windows_ui_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_drive_relative_windows_ui_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 10)] -fn scenario_rejects_parent_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_parent_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 11)] -fn scenario_rejects_invalid_character(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_invalid_character(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 12)] -fn scenario_rejects_blank_ui_directory(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_blank_ui_directory(world: TemplateWorld) { let _ = world; } #[scenario(path = "tests/features/lint_template.feature", index = 13)] -fn scenario_rejects_trailing_underscore(world: TemplateWorld) { - let _ = world; -} +fn scenario_rejects_trailing_underscore(world: TemplateWorld) { let _ = world; } diff --git a/tests/locale_resolution.rs b/tests/locale_resolution.rs index 6b07dd7e..aacafb62 100644 --- a/tests/locale_resolution.rs +++ b/tests/locale_resolution.rs @@ -1,14 +1,13 @@ //! Behaviour-driven tests covering locale resolution semantics. -use std::cell::RefCell; -use std::str::FromStr; +use std::{cell::RefCell, str::FromStr}; mod support; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; -use whitaker_common::i18n::{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer}; +use whitaker_common::i18n::{LocaleSelection, LocaleSource, normalize_locale, resolve_localizer}; #[derive(Default)] struct LocaleWorld { @@ -18,10 +17,9 @@ struct LocaleWorld { resolution: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn world() -> LocaleWorld { - LocaleWorld::default() -} +fn world() -> LocaleWorld { LocaleWorld::default() } #[derive(Debug)] struct StepSource(LocaleSource); @@ -41,9 +39,7 @@ impl FromStr for StepSource { } impl StepSource { - const fn into_inner(self) -> LocaleSource { - self.0 - } + const fn into_inner(self) -> LocaleSource { self.0 } } fn resolved(world: &LocaleWorld) -> LocaleSelection { @@ -55,9 +51,7 @@ fn resolved(world: &LocaleWorld) -> LocaleSelection { } #[given("no explicit locale override is provided")] -fn no_explicit(world: &LocaleWorld) { - world.explicit.borrow_mut().take(); -} +fn no_explicit(world: &LocaleWorld) { world.explicit.borrow_mut().take(); } #[given("the explicit locale override is {value}")] fn set_explicit(world: &LocaleWorld, value: StepLocale) { @@ -65,9 +59,7 @@ fn set_explicit(world: &LocaleWorld, value: StepLocale) { } #[given("DYLINT_LOCALE is not set")] -fn clear_environment(world: &LocaleWorld) { - world.environment.borrow_mut().take(); -} +fn clear_environment(world: &LocaleWorld) { world.environment.borrow_mut().take(); } #[given("DYLINT_LOCALE is {value}")] fn set_environment(world: &LocaleWorld, value: StepLocale) { @@ -75,9 +67,7 @@ fn set_environment(world: &LocaleWorld, value: StepLocale) { } #[given("no configuration locale is provided")] -fn clear_configuration(world: &LocaleWorld) { - world.configuration.borrow_mut().take(); -} +fn clear_configuration(world: &LocaleWorld) { world.configuration.borrow_mut().take(); } #[given("the configuration locale is {value}")] fn set_configuration(world: &LocaleWorld, value: StepLocale) { @@ -105,7 +95,7 @@ fn assert_source(world: &LocaleWorld, source: StepSource) { fn assert_locale(world: &LocaleWorld, value: StepLocale) { let resolution = resolved(world); let raw = value.into_inner(); - let expected = normalise_locale(Some(raw.as_str())) + let expected = normalize_locale(Some(raw.as_str())) .unwrap_or_else(|| panic!("expected the step to provide a locale value")); assert_eq!(resolution.locale(), expected); @@ -126,26 +116,16 @@ fn assert_fallback_not_used(world: &LocaleWorld) { } #[scenario("tests/features/locale_resolution.feature", index = 0)] -fn scenario_fallback(world: LocaleWorld) { - let _ = world; -} +fn scenario_fallback(world: LocaleWorld) { let _ = world; } #[scenario("tests/features/locale_resolution.feature", index = 1)] -fn scenario_environment(world: LocaleWorld) { - let _ = world; -} +fn scenario_environment(world: LocaleWorld) { let _ = world; } #[scenario("tests/features/locale_resolution.feature", index = 2)] -fn scenario_configuration(world: LocaleWorld) { - let _ = world; -} +fn scenario_configuration(world: LocaleWorld) { let _ = world; } #[scenario("tests/features/locale_resolution.feature", index = 3)] -fn scenario_explicit(world: LocaleWorld) { - let _ = world; -} +fn scenario_explicit(world: LocaleWorld) { let _ = world; } #[scenario("tests/features/locale_resolution.feature", index = 4)] -fn scenario_whitespace(world: LocaleWorld) { - let _ = world; -} +fn scenario_whitespace(world: LocaleWorld) { let _ = world; } diff --git a/tests/nextest_ui_filter.rs b/tests/nextest_ui_filter.rs index b837e512..0f748690 100644 --- a/tests/nextest_ui_filter.rs +++ b/tests/nextest_ui_filter.rs @@ -12,8 +12,7 @@ //! `ui`, **not** `ui::ui`) and asserts that the nextest filter contains the //! clause needed to capture that pattern. -use std::fs; -use std::path::Path; +use std::{fs, path::Path}; use rstest::{fixture, rstest}; use toml::Value; @@ -112,9 +111,9 @@ fn serial_dylint_ui_filter_captures_integration_ui_binaries(serial_dylint_ui_ove // `test(ui::ui)` missed it because the test name is plain `ui`. assert!( filter.contains("(binary(ui) & test(=ui))"), - "the serial-dylint-ui filter must contain `(binary(ui) & test(=ui))` \ - to capture integration test binaries named `ui` with a top-level \ - `fn ui()` (e.g. {crates:?}); found filter: {filter}" + "the serial-dylint-ui filter must contain `(binary(ui) & test(=ui))` 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/tests/support/locale.rs index 77e71fa3..631edeb5 100644 --- a/tests/support/locale.rs +++ b/tests/support/locale.rs @@ -4,8 +4,7 @@ //! stripping whitespace and quotation marks to normalize values before they are //! passed to locale resolution and configuration helpers. -use std::convert::Infallible; -use std::str::FromStr; +use std::{convert::Infallible, str::FromStr}; /// Wrapper for locale values supplied via behaviour-driven test steps. #[derive(Clone, Debug)] @@ -28,7 +27,5 @@ impl FromStr for StepLocale { impl StepLocale { /// Consumes the step value, yielding the parsed string. - pub fn into_inner(self) -> String { - self.raw - } + pub fn into_inner(self) -> String { self.raw } } diff --git a/tests/ui_harness.rs b/tests/ui_harness.rs index a0e688d6..ae8b5ab0 100644 --- a/tests/ui_harness.rs +++ b/tests/ui_harness.rs @@ -11,29 +11,21 @@ use whitaker::testing::ui::{HarnessError, run_with_runner}; struct StepString(String); impl StepString { - fn into_inner(self) -> String { - self.0 - } + fn into_inner(self) -> String { self.0 } } impl From for StepString { - fn from(value: std::string::String) -> Self { - Self(value) - } + fn from(value: std::string::String) -> Self { Self(value) } } impl From for String { - fn from(value: StepString) -> Self { - value.0 - } + fn from(value: StepString) -> Self { value.0 } } impl std::str::FromStr for StepString { type Err = Infallible; - fn from_str(input: &str) -> Result { - Ok(Self(input.to_owned())) - } + fn from_str(input: &str) -> Result { Ok(Self(input.to_owned())) } } #[derive(Debug)] @@ -57,15 +49,12 @@ impl Default for HarnessWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] -fn harness_world() -> HarnessWorld { - HarnessWorld::default() -} +fn harness_world() -> HarnessWorld { HarnessWorld::default() } #[given("the harness has no crate name")] -fn clear_crate(harness_world: &HarnessWorld) { - harness_world.crate_name.borrow_mut().clear(); -} +fn clear_crate(harness_world: &HarnessWorld) { harness_world.crate_name.borrow_mut().clear(); } #[given("the harness is prepared for crate {name}")] fn prepare_crate(harness_world: &HarnessWorld, name: String) { @@ -139,7 +128,11 @@ fn assert_absolute_error(harness_world: &HarnessWorld, path: String) { let borrow = harness_world.harness_result.borrow(); match borrow.as_ref() { Some(Err(HarnessError::AbsoluteDirectory { directory })) => { - assert_eq!(directory, &Utf8PathBuf::from(path)); + assert_eq!( + directory, + &Utf8PathBuf::from(path), + "the rejected directory should be reported verbatim", + ); } Some(Ok(())) => panic!("expected an error but harness succeeded"), Some(Err(error)) => panic!("expected an absolute directory error, found {error}"), @@ -153,7 +146,10 @@ fn assert_runner_failure(harness_world: &HarnessWorld, snippet: StepString) { let snippet_value = snippet.into_inner(); match borrow.as_ref() { Some(Err(HarnessError::RunnerFailure { message, .. })) => { - assert!(message.contains(snippet_value.as_str())); + assert!( + message.contains(snippet_value.as_str()), + "runner failure should mention {snippet_value}, got: {message}", + ); } Some(Ok(())) => panic!("expected an error but harness succeeded"), Some(Err(error)) => panic!("expected a runner failure error, found {error}"), @@ -162,33 +158,21 @@ fn assert_runner_failure(harness_world: &HarnessWorld, snippet: StepString) { } #[scenario(path = "tests/features/ui_harness.feature", index = 0)] -fn scenario_runs_successfully(harness_world: HarnessWorld) { - let _ = harness_world; -} +fn scenario_runs_successfully(harness_world: HarnessWorld) { let _ = harness_world; } #[scenario(path = "tests/features/ui_harness.feature", index = 1)] -fn scenario_rejects_empty_crate(harness_world: HarnessWorld) { - let _ = harness_world; -} +fn scenario_rejects_empty_crate(harness_world: HarnessWorld) { let _ = harness_world; } #[scenario(path = "tests/features/ui_harness.feature", index = 2)] -fn scenario_rejects_absolute_directory(harness_world: HarnessWorld) { - let _ = harness_world; -} +fn scenario_rejects_absolute_directory(harness_world: HarnessWorld) { let _ = harness_world; } #[scenario(path = "tests/features/ui_harness.feature", index = 3)] -fn scenario_propagates_runner_failure(harness_world: HarnessWorld) { - let _ = harness_world; -} +fn scenario_propagates_runner_failure(harness_world: HarnessWorld) { let _ = harness_world; } #[cfg(windows)] #[scenario(path = "tests/features/ui_harness.feature", index = 4)] -fn scenario_rejects_unc_directory(harness_world: HarnessWorld) { - let _ = harness_world; -} +fn scenario_rejects_unc_directory(harness_world: HarnessWorld) { let _ = harness_world; } #[cfg(windows)] #[scenario(path = "tests/features/ui_harness.feature", index = 5)] -fn scenario_rejects_drive_relative_directory(harness_world: HarnessWorld) { - let _ = harness_world; -} +fn scenario_rejects_drive_relative_directory(harness_world: HarnessWorld) { let _ = harness_world; } diff --git a/typos.local.toml b/typos.local.toml index 94f5978b..876a0bfb 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -12,88 +12,21 @@ accepted = [] [patterns] ignore = [ - "(?m)^ \\.context\\(\"failed to serialise diagnostic for snapshot\"\\),$", - "(?m)^ Ok\\(parsed\\) => assert_eq!\\(artifact, parsed\\),$", - "(?m)^ \\.artifact_location$", - "(?m)^ artifact_location: ArtifactLocation \\{$", - "(?m)^ artifact_location: whitaker_sarif::ArtifactLocation \\{$", - "(?m)^ assert!\\(!json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\)\\);$", - "(?m)^ format!\\(\"bumpy\\-road signal rasterisation failed: \\{error\\}\"\\),$", - "(?m)^ Ok\\(json\\) => match serde_json::from_str::\\(&json\\) \\{$", - "(?m)^ \\.artifact_location$", - "(?m)^ \\.expect\\(\"Fluent source should be initialised\"\\);$", - "(?m)^ \\.unwrap_or_else\\(\\|\\| panic!\\(\"localizer should be initialised\"\\)\\)$", - "(?m)^ artifacts: Vec::new\\(\\),$", - "(?m)^ artifacts: self\\.artifacts,$", - "(?m)^ localised_messages\\(&FailingLookup::new\\(\"no_std_fs_operations\"\\), &op\\)$", - "(?m)^ localised_messages\\(localizer, &op\\)$", - "(?m)^ location: ArtifactLocation \\{$", + # SARIF 2.1.0 (OASIS) spells these properties `artifactLocation` and + # `artifacts`. The Rust fields use the repository's Oxford spelling and pin + # the wire names with `serde(rename)`, so these two literals are serialized + # values fixed by the external schema, not prose. + "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", + "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", - "(?m)^ \"failed to initialise isolated rustup: \\{\\}\",$", - "(?m)^ TraitItemMetrics::default_method\\(\"serialise\", 8\\),$", - "(?m)^ \\.expect\\(\"failed to initialise isolated rustup environment\"\\);$", - "(?m)^ \\.expect\\(\"settings should be normalised\"\\);$", - "(?m)^ \\.filter_map\\(capitalise_segment\\)$", - "(?m)^ \\.normalised$", - "(?m)^ all_artifacts\\.extend\\(run\\.artifacts\\.clone\\(\\)\\);$", - "(?m)^ artifacts: all_artifacts,$", - "(?m)^ assert_eq!\\(loc\\.physical_location\\.artifact_location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^ assert_eq!\\(location\\.physical_location\\.artifact_location\\.uri, file_uri\\);$", - "(?m)^ assert_eq!\\(normalise_locale\\(input\\), expected\\);$", - "(?m)^ capitalised\\.push\\(character\\.to_ascii_lowercase\\(\\)\\);$", - "(?m)^ depth: normalise_weight\\(settings\\.weights\\.depth, defaults\\.weights\\.depth\\),$", - "(?m)^ flow: normalise_weight\\(settings\\.weights\\.flow, defaults\\.weights\\.flow\\),$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", - "(?m)^ let artifact = Artifact \\{$", - "(?m)^ let summary = summarise_context\\($", - "(?m)^ let summary = summarise_context\\(entries\\.as_slice\\(\\), has_test_context_ancestry, additional\\);$", - "(?m)^ let summary = summarise_context_with_harness\\($", - "(?m)^ localised_messages\\(&lookup, kind, attribute\\)$", - "(?m)^ match serde_json::to_string\\(&artifact\\) \\{$", - "(?m)^ normalise_locale\\(self\\.locale\\.as_deref\\(\\)\\)$", - "(?m)^ predicate: normalise_weight\\(settings\\.weights\\.predicate, defaults\\.weights\\.predicate\\),$", - "(?m)^ result\\.locations\\[0\\]\\.physical_location\\.artifact_location\\.uri,$", - "(?m)^ self\\.artifacts\\.push\\(artifact\\);$", - "(?m)^ self\\.settings = normalise_settings\\(load_configuration\\(\\)\\.into_settings\\(\\)\\);$", - "(?m)^ uses: actions/download\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", - "(?m)^ world\\.with_localizer\\(\\|localizer\\| localised_messages\\(localizer, kind, attribute\\)\\)$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", "(?m)^ \\- Upload artefact \\(actions/upload\\-artifact@v4,$", - "(?m)^ /// Appends an artifact reference\\.$", "(?m)^ /// Categorises the input with a single cluster of conditional logic\\.$", - "(?m)^ /// Identifies the artifact \\(file\\)\\.$", - "(?m)^ /// Location of the artifact\\.$", - "(?m)^ /// Optional byte offset from the start of the artifact\\.$", - "(?m)^ /// Optional region within the artifact\\.$", - "(?m)^ /// Referenced source artifacts\\.$", - "(?m)^ /// Relative or absolute URI of the artifact\\.$", - "(?m)^ /// Returns the source artifact URI used in SARIF output\\.$", - "(?m)^ /// Sets the byte offset from the start of the artifact\\.$", - "(?m)^ /// Sets the region within the artifact\\.$", - "(?m)^ /// assert_eq!\\(run\\.artifacts\\.len\\(\\), 1\\);$", "(?m)^ /// executed \\(catches mis\\-specified scenarios missing the When step\\)\\.$", - "(?m)^ And I normalise the settings$", - "(?m)^ And a default method normalise with CC 7$", - "(?m)^ Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString,$", - "(?m)^ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages,$", - "(?m)^ DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,$", - "(?m)^ Given the lint recognises custom::test as a test attribute$", - "(?m)^ Some\\(capitalised\\)$", - "(?m)^ Then the function is recognised as an rstest fixture$", - "(?m)^ Then the function is recognised as an rstest test$", - "(?m)^ Then the function is recognised as not being an rstest test$", - "(?m)^ Then the function is recognised as not test\\-like$", - "(?m)^ Then the function is recognised as test\\-like$", - "(?m)^ When I localise the diagnostic$", - "(?m)^ When I localise the expect diagnostic$", - "(?m)^ When I localise the std::fs diagnostic$", - "(?m)^ When I summarise the context$", - "(?m)^ When the complexity is finalised$", - "(?m)^ \\.contains\\(\"unrecognised boolean value 'maybe'\"\\)\\);$", "(?m)^ \\.help = Cuir beachd doc ris a tha a’ mìneachadh giùlan \\{ \\$function \\}\\.$", "(?m)^ \\.help = Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", "(?m)^ \\.label = Tha bump iom\\-fhillteachd \\{ \\$index \\} a’ leudachadh thairis air \\{ \\$lines \\} \\{ \\$lines \\->$", @@ -105,153 +38,30 @@ ignore = [ "(?m)^ \\.note = Tha mòidealan mòra nas duilghe an ath\\-sgrùdadh\\.$", "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} a’ gabhail a\\-steach ceann eisimpleirean no bloca còd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gàidhlig\\.$", - "(?m)^ \\.note = The call originates within \\{ \\$context \\} which is not recognised as a test\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", - "(?m)^ artifacts: Vec,$", - "(?m)^ assert_eq!\\(normalised, expected\\);$", - "(?m)^ builder\\.add_default_method\\(\"normalise\", 7, false\\);$", - "(?m)^ capitalised\\.push\\(first\\.to_ascii_uppercase\\(\\)\\);$", - "(?m)^ context_label, fallback_messages, localised_messages,$", - "(?m)^ fn artifact_round_trip\\(\\) \\{$", - "(?m)^ fn deserialises_locale_override\\(\\) \\{$", - "(?m)^ fn deserialises_overrides_from_toml\\(\\) \\{$", - "(?m)^ fn normalise_weight\\(candidate: f64, fallback: f64\\) \\-> f64 \\{$", - "(?m)^ fn normalises_candidates\\(#\\[case\\] input: Option<&str>, #\\[case\\] expected: Option<&str>\\) \\{$", - "(?m)^ fn propagates_deserialisation_failures\\(\\) \\{$", - "(?m)^ fn success_message_pluralises_correctly\\(#\\[case\\] count: usize, #\\[case\\] expected: &str\\) \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", - "(?m)^ let candidate = normalise_locale\\(raw\\)\\?;$", - "(?m)^ let expected = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let expected_value = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let file = location\\.physical_location\\.artifact_location\\.uri\\.clone\\(\\);$", - "(?m)^ let help = normalise_isolation_marks\\(messages\\.help\\(\\)\\);$", - "(?m)^ let messages = localised_messages\\(&lookup, &receiver_label, &context_label, category\\)$", - "(?m)^ let mut all_artifacts = Vec::new\\(\\);$", - "(?m)^ let mut capitalised = String::with_capacity\\(segment\\.len\\(\\)\\);$", - "(?m)^ let mut summary = crate::context::summarise_context\\(cx, hir_id\\);$", - "(?m)^ let normalised = normalise_settings\\(settings\\);$", - "(?m)^ let note = format!\\(\"The call originates within \\{context\\} which is not recognised as a test\\.\",\\);$", - "(?m)^ let note = normalise_isolation_marks\\(messages\\.note\\(\\)\\);$", - "(?m)^ let primary = normalise_isolation_marks\\(messages\\.primary\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, &\\[\\]\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, additional\\.as_slice\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, true, &\\[\\]\\);$", - "(?m)^ localised_messages,$", - "(?m)^ localised_messages\\(lookup, receiver, &context, category\\)$", - "(?m)^ normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale,$", - "(?m)^ normalised: RefCell>,$", - "(?m)^ pub artifact_location: ArtifactLocation,$", - "(?m)^ pub artifacts: Vec,$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", - "(?m)^ pub fn with_artifact\\(mut self, artifact: Artifact\\) \\-> Self \\{$", - "(?m)^ pub location: super::location::ArtifactLocation,$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", - "(?m)^ use crate::model::location::ArtifactLocation;$", - "(?m)^ validate_pluralisation_coverage\\(locale, max_branches\\);$", - "(?m)^ world\\.normalised\\.replace\\(Some\\(normalised\\)\\);$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", - "(?m)^ = note: The call originates within function `fail_result` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `handler` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `main` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse_config` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `process` which is not recognised as a test\\.$", "(?m)^ CARGO_TERM_COLOR: always$", - "(?m)^ Scenario: Function recognised via configured attribute$", - "(?m)^ Scenario: Recognise configured custom test attribute$", - "(?m)^ Scenario: Recognise rstest decorated functions$", - "(?m)^ Scenario: Recognise tokio::test decorated functions$", - "(?m)^ Scenario: Type methods split into parsing, serialisation, and filesystem groups$", - "(?m)^ Whitaker lints require localised diagnostics with predictable fallbacks\\.$", "(?m)^ `download\\-artifact` merge step would clobber all but the last\\.$", - "(?m)^# Borrowed English nouns typically pluralise with \\-iau \\(Modern Welsh, Gareth$", - "(?m)^# Serialise dylint UI tests that build lint libraries and use$", - "(?m)^# Serialise ignored exclusion integration tests when they are explicitly run\\.$", - "(?m)^## Artifacts and Notes$", - "(?m)^## Artifacts and notes$", + "(?m)^# Borrowed English nouns typically pluralize with \\-iau \\(Modern Welsh, Gareth$", "(?m)^## Cuidhtearan breithneachaidh ga roinn thar linteran Whitaker\\.$", "(?m)^## Seachain `unwrap_or_else` a tha a’ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e a’ toirt iomradh air am buadh roimhe\\.$", - "(?m)^#\\[given\\(\"the lint recognises \\{path\\} as a test attribute\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest fixture\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not being an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not test\\-like\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as test\\-like\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the expect diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the std::fs diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I normalise the settings\"\\)\\]$", - "(?m)^#\\[when\\(\"I summarise the context\"\\)\\]$", - "(?m)^#\\[when\\(\"the complexity is finalised\"\\)\\]$", - "(?m)^//! SARIF run, tool, invocation, and artifact types\\.$", - "(?m)^//! The fixture config recognises `#\\[tokio::test\\]`, but `parse_config` remains$", - "(?m)^/// artifacts: Vec::new\\(\\),$", - "(?m)^/// A location within an artifact \\(source file\\)\\.$", - "(?m)^/// A reference to an artifact by URI\\.$", - "(?m)^/// A region within an artifact, identified by line and column numbers\\.$", - "(?m)^/// A source artifact referenced by results\\.$", - "(?m)^/// and deduplicated\\. Artifacts and invocations are concatenated\\.$", - "(?m)^/// arbitrary `const`\\-only module for a synthesised rstest harness descriptor\\.$", - "(?m)^/// assert_eq!\\(artifact\\.location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^/// let artifact = Artifact \\{$", - "(?m)^Feature: Localised diagnostics for expect usage$", - "(?m)^Feature: Localised diagnostics for function attribute ordering$", - "(?m)^Feature: Localised diagnostics for std::fs usage$", - "(?m)^Feature: Localiser helpers$", - "(?m)^Feature: Summarise traversal context for `\\.expect\\(\\.\\.\\)` linting$", - "(?m)^\\- Helpers to build rules, results, locations, artifacts, and invocations\\.$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- \\*\\*Checkboxes:\\*\\* Precede task and sub\\-task items with a GitHub Flavored$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", "(?m)^careful setup before a test can execute and, sometimes, teardown afterward\\.$", "(?m)^cargo dylint list \\-\\-color never \\| Where\\-Object \\{\\{$", - "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{suite_crate\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", + "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{SUITE_CRATE\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", "(?m)^error: Tha gnìomh std::fs `std::fs::read` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^fn capitalise_segment\\(segment: &str\\) \\-> Option \\{$", - "(?m)^fn localised_help_attributes_are_complete\\(\\) \\{$", - "(?m)^fn localised_messages\\($", - "(?m)^fn normalise_isolation_marks\\(text: &str\\) \\-> String \\{$", - "(?m)^fn normalise_settings_falls_back_to_defaults\\($", - "(?m)^fn pluralisation_covers_sample_range\\(#\\[case\\] locale: &str, #\\[case\\] max_branches: i64\\) \\{$", - "(?m)^fn recognises_std_fs_paths\\(#\\[case\\] path: &str, #\\[case\\] expected: bool\\) \\{$", - "(?m)^fn recognises_test_attribute\\(\\) \\{$", - "(?m)^fn resolve_additional_components_parametrised\\(#\\[case\\] cranelift: bool, #\\[case\\] expected: &\\[&str\\]\\) \\{$", - "(?m)^fn scenario_recognises_custom\\(function: FunctionFixture, evaluation: Evaluation\\) \\{$", - "(?m)^fn summarise_context_with_harness<'tcx>\\($", - "(?m)^fn summarises_plain_context\\(\\) \\{$", - "(?m)^fn validate_pluralisation_coverage\\(locale: &str, max_branches: i64\\) \\{$", - "(?m)^fn when_finalised\\(world: &CcWorld\\) \\{$", - "(?m)^fn when_localise\\(world: &WorldCell\\) \\{$", - "(?m)^fn when_normalise\\(world: &World\\) \\{$", - "(?m)^fn when_summarise\\(world: &ContextWorld\\) \\{$", "(?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\\.$", - "(?m)^pub fn normalise_locale\\(input: Option<&str>\\) \\-> Option<&str> \\{$", - "(?m)^pub fn normalise_settings\\(settings: Settings\\) \\-> Settings \\{$", - "(?m)^pub fn rasterise_signal\\($", - "(?m)^pub struct Artifact \\{$", - "(?m)^pub struct ArtifactLocation \\{$", - "(?m)^pub use location::\\{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation\\};$", - "(?m)^pub use run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^pub use selection::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^pub\\(crate\\) fn localised_messages\\($", - "(?m)^pub\\(crate\\) fn summarise_context<'tcx>\\($", - "(?m)^pub\\(crate\\) fn summarise_context\\($", - "(?m)^use crate::analysis::\\{Settings, detect_bumps, normalise_settings\\};$", - "(?m)^use crate::context::\\{ContextSummary, summarise_context\\};$", - "(?m)^use crate::context::\\{collect_context, is_cfg_test_attribute, summarise_context\\};$", - "(?m)^use crate::context::summarise_context;$", - "(?m)^use crate::diagnostics::\\{StdFsMessages, localised_messages\\};$", - "(?m)^use crate::model::location::\\{ArtifactLocation, Location, PhysicalLocation, Region\\};$", - "(?m)^use crate::model::run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^use whitaker_common::i18n::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^use whitaker_common::i18n::normalise_locale;$", ] [files] diff --git a/typos.toml b/typos.toml index fdcc5080..5a0c01a2 100644 --- a/typos.toml +++ b/typos.toml @@ -34,88 +34,17 @@ extend-exclude = [ [default] locale = "en-gb" extend-ignore-re = [ - "(?m)^ \\.context\\(\"failed to serialise diagnostic for snapshot\"\\),$", - "(?m)^ Ok\\(parsed\\) => assert_eq!\\(artifact, parsed\\),$", - "(?m)^ \\.artifact_location$", - "(?m)^ artifact_location: ArtifactLocation \\{$", - "(?m)^ artifact_location: whitaker_sarif::ArtifactLocation \\{$", - "(?m)^ assert!\\(!json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\)\\);$", - "(?m)^ format!\\(\"bumpy\\-road signal rasterisation failed: \\{error\\}\"\\),$", - "(?m)^ Ok\\(json\\) => match serde_json::from_str::\\(&json\\) \\{$", - "(?m)^ \\.artifact_location$", - "(?m)^ \\.expect\\(\"Fluent source should be initialised\"\\);$", - "(?m)^ \\.unwrap_or_else\\(\\|\\| panic!\\(\"localizer should be initialised\"\\)\\)$", - "(?m)^ artifacts: Vec::new\\(\\),$", - "(?m)^ artifacts: self\\.artifacts,$", - "(?m)^ localised_messages\\(&FailingLookup::new\\(\"no_std_fs_operations\"\\), &op\\)$", - "(?m)^ localised_messages\\(localizer, &op\\)$", - "(?m)^ location: ArtifactLocation \\{$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", - "(?m)^ \"failed to initialise isolated rustup: \\{\\}\",$", - "(?m)^ TraitItemMetrics::default_method\\(\"serialise\", 8\\),$", - "(?m)^ \\.expect\\(\"failed to initialise isolated rustup environment\"\\);$", - "(?m)^ \\.expect\\(\"settings should be normalised\"\\);$", - "(?m)^ \\.filter_map\\(capitalise_segment\\)$", - "(?m)^ \\.normalised$", - "(?m)^ all_artifacts\\.extend\\(run\\.artifacts\\.clone\\(\\)\\);$", - "(?m)^ artifacts: all_artifacts,$", - "(?m)^ assert_eq!\\(loc\\.physical_location\\.artifact_location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^ assert_eq!\\(location\\.physical_location\\.artifact_location\\.uri, file_uri\\);$", - "(?m)^ assert_eq!\\(normalise_locale\\(input\\), expected\\);$", - "(?m)^ capitalised\\.push\\(character\\.to_ascii_lowercase\\(\\)\\);$", - "(?m)^ depth: normalise_weight\\(settings\\.weights\\.depth, defaults\\.weights\\.depth\\),$", - "(?m)^ flow: normalise_weight\\(settings\\.weights\\.flow, defaults\\.weights\\.flow\\),$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", - "(?m)^ let artifact = Artifact \\{$", - "(?m)^ let summary = summarise_context\\($", - "(?m)^ let summary = summarise_context\\(entries\\.as_slice\\(\\), has_test_context_ancestry, additional\\);$", - "(?m)^ let summary = summarise_context_with_harness\\($", - "(?m)^ localised_messages\\(&lookup, kind, attribute\\)$", - "(?m)^ match serde_json::to_string\\(&artifact\\) \\{$", - "(?m)^ normalise_locale\\(self\\.locale\\.as_deref\\(\\)\\)$", - "(?m)^ predicate: normalise_weight\\(settings\\.weights\\.predicate, defaults\\.weights\\.predicate\\),$", - "(?m)^ result\\.locations\\[0\\]\\.physical_location\\.artifact_location\\.uri,$", - "(?m)^ self\\.artifacts\\.push\\(artifact\\);$", - "(?m)^ self\\.settings = normalise_settings\\(load_configuration\\(\\)\\.into_settings\\(\\)\\);$", - "(?m)^ uses: actions/download\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", - "(?m)^ world\\.with_localizer\\(\\|localizer\\| localised_messages\\(localizer, kind, attribute\\)\\)$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", "(?m)^ \\- Upload artefact \\(actions/upload\\-artifact@v4,$", - "(?m)^ /// Appends an artifact reference\\.$", + "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", + "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", "(?m)^ /// Categorises the input with a single cluster of conditional logic\\.$", - "(?m)^ /// Identifies the artifact \\(file\\)\\.$", - "(?m)^ /// Location of the artifact\\.$", - "(?m)^ /// Optional byte offset from the start of the artifact\\.$", - "(?m)^ /// Optional region within the artifact\\.$", - "(?m)^ /// Referenced source artifacts\\.$", - "(?m)^ /// Relative or absolute URI of the artifact\\.$", - "(?m)^ /// Returns the source artifact URI used in SARIF output\\.$", - "(?m)^ /// Sets the byte offset from the start of the artifact\\.$", - "(?m)^ /// Sets the region within the artifact\\.$", - "(?m)^ /// assert_eq!\\(run\\.artifacts\\.len\\(\\), 1\\);$", "(?m)^ /// executed \\(catches mis\\-specified scenarios missing the When step\\)\\.$", - "(?m)^ And I normalise the settings$", - "(?m)^ And a default method normalise with CC 7$", - "(?m)^ Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString,$", - "(?m)^ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages,$", - "(?m)^ DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,$", - "(?m)^ Given the lint recognises custom::test as a test attribute$", - "(?m)^ Some\\(capitalised\\)$", - "(?m)^ Then the function is recognised as an rstest fixture$", - "(?m)^ Then the function is recognised as an rstest test$", - "(?m)^ Then the function is recognised as not being an rstest test$", - "(?m)^ Then the function is recognised as not test\\-like$", - "(?m)^ Then the function is recognised as test\\-like$", - "(?m)^ When I localise the diagnostic$", - "(?m)^ When I localise the expect diagnostic$", - "(?m)^ When I localise the std::fs diagnostic$", - "(?m)^ When I summarise the context$", - "(?m)^ When the complexity is finalised$", - "(?m)^ \\.contains\\(\"unrecognised boolean value 'maybe'\"\\)\\);$", "(?m)^ \\.help = Cuir beachd doc ris a tha a’ mìneachadh giùlan \\{ \\$function \\}\\.$", "(?m)^ \\.help = Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", "(?m)^ \\.label = Tha bump iom\\-fhillteachd \\{ \\$index \\} a’ leudachadh thairis air \\{ \\$lines \\} \\{ \\$lines \\->$", @@ -127,153 +56,30 @@ extend-ignore-re = [ "(?m)^ \\.note = Tha mòidealan mòra nas duilghe an ath\\-sgrùdadh\\.$", "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} a’ gabhail a\\-steach ceann eisimpleirean no bloca còd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gàidhlig\\.$", - "(?m)^ \\.note = The call originates within \\{ \\$context \\} which is not recognised as a test\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", - "(?m)^ artifacts: Vec,$", - "(?m)^ assert_eq!\\(normalised, expected\\);$", - "(?m)^ builder\\.add_default_method\\(\"normalise\", 7, false\\);$", - "(?m)^ capitalised\\.push\\(first\\.to_ascii_uppercase\\(\\)\\);$", - "(?m)^ context_label, fallback_messages, localised_messages,$", - "(?m)^ fn artifact_round_trip\\(\\) \\{$", - "(?m)^ fn deserialises_locale_override\\(\\) \\{$", - "(?m)^ fn deserialises_overrides_from_toml\\(\\) \\{$", - "(?m)^ fn normalise_weight\\(candidate: f64, fallback: f64\\) \\-> f64 \\{$", - "(?m)^ fn normalises_candidates\\(#\\[case\\] input: Option<&str>, #\\[case\\] expected: Option<&str>\\) \\{$", - "(?m)^ fn propagates_deserialisation_failures\\(\\) \\{$", - "(?m)^ fn success_message_pluralises_correctly\\(#\\[case\\] count: usize, #\\[case\\] expected: &str\\) \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", - "(?m)^ let candidate = normalise_locale\\(raw\\)\\?;$", - "(?m)^ let expected = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let expected_value = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let file = location\\.physical_location\\.artifact_location\\.uri\\.clone\\(\\);$", - "(?m)^ let help = normalise_isolation_marks\\(messages\\.help\\(\\)\\);$", - "(?m)^ let messages = localised_messages\\(&lookup, &receiver_label, &context_label, category\\)$", - "(?m)^ let mut all_artifacts = Vec::new\\(\\);$", - "(?m)^ let mut capitalised = String::with_capacity\\(segment\\.len\\(\\)\\);$", - "(?m)^ let mut summary = crate::context::summarise_context\\(cx, hir_id\\);$", - "(?m)^ let normalised = normalise_settings\\(settings\\);$", - "(?m)^ let note = format!\\(\"The call originates within \\{context\\} which is not recognised as a test\\.\",\\);$", - "(?m)^ let note = normalise_isolation_marks\\(messages\\.note\\(\\)\\);$", - "(?m)^ let primary = normalise_isolation_marks\\(messages\\.primary\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, &\\[\\]\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, additional\\.as_slice\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, true, &\\[\\]\\);$", - "(?m)^ localised_messages,$", - "(?m)^ localised_messages\\(lookup, receiver, &context, category\\)$", - "(?m)^ normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale,$", - "(?m)^ normalised: RefCell>,$", - "(?m)^ pub artifact_location: ArtifactLocation,$", - "(?m)^ pub artifacts: Vec,$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", - "(?m)^ pub fn with_artifact\\(mut self, artifact: Artifact\\) \\-> Self \\{$", - "(?m)^ pub location: super::location::ArtifactLocation,$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", - "(?m)^ use crate::model::location::ArtifactLocation;$", - "(?m)^ validate_pluralisation_coverage\\(locale, max_branches\\);$", - "(?m)^ world\\.normalised\\.replace\\(Some\\(normalised\\)\\);$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", - "(?m)^ = note: The call originates within function `fail_result` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `handler` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `main` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse_config` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `process` which is not recognised as a test\\.$", "(?m)^ CARGO_TERM_COLOR: always$", - "(?m)^ Scenario: Function recognised via configured attribute$", - "(?m)^ Scenario: Recognise configured custom test attribute$", - "(?m)^ Scenario: Recognise rstest decorated functions$", - "(?m)^ Scenario: Recognise tokio::test decorated functions$", - "(?m)^ Scenario: Type methods split into parsing, serialisation, and filesystem groups$", - "(?m)^ Whitaker lints require localised diagnostics with predictable fallbacks\\.$", "(?m)^ `download\\-artifact` merge step would clobber all but the last\\.$", - "(?m)^# Borrowed English nouns typically pluralise with \\-iau \\(Modern Welsh, Gareth$", - "(?m)^# Serialise dylint UI tests that build lint libraries and use$", - "(?m)^# Serialise ignored exclusion integration tests when they are explicitly run\\.$", - "(?m)^## Artifacts and Notes$", - "(?m)^## Artifacts and notes$", + "(?m)^# Borrowed English nouns typically pluralize with \\-iau \\(Modern Welsh, Gareth$", "(?m)^## Cuidhtearan breithneachaidh ga roinn thar linteran Whitaker\\.$", "(?m)^## Seachain `unwrap_or_else` a tha a’ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e a’ toirt iomradh air am buadh roimhe\\.$", - "(?m)^#\\[given\\(\"the lint recognises \\{path\\} as a test attribute\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest fixture\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not being an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not test\\-like\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as test\\-like\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the expect diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the std::fs diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I normalise the settings\"\\)\\]$", - "(?m)^#\\[when\\(\"I summarise the context\"\\)\\]$", - "(?m)^#\\[when\\(\"the complexity is finalised\"\\)\\]$", - "(?m)^//! SARIF run, tool, invocation, and artifact types\\.$", - "(?m)^//! The fixture config recognises `#\\[tokio::test\\]`, but `parse_config` remains$", - "(?m)^/// artifacts: Vec::new\\(\\),$", - "(?m)^/// A location within an artifact \\(source file\\)\\.$", - "(?m)^/// A reference to an artifact by URI\\.$", - "(?m)^/// A region within an artifact, identified by line and column numbers\\.$", - "(?m)^/// A source artifact referenced by results\\.$", - "(?m)^/// and deduplicated\\. Artifacts and invocations are concatenated\\.$", - "(?m)^/// arbitrary `const`\\-only module for a synthesised rstest harness descriptor\\.$", - "(?m)^/// assert_eq!\\(artifact\\.location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^/// let artifact = Artifact \\{$", - "(?m)^Feature: Localised diagnostics for expect usage$", - "(?m)^Feature: Localised diagnostics for function attribute ordering$", - "(?m)^Feature: Localised diagnostics for std::fs usage$", - "(?m)^Feature: Localiser helpers$", - "(?m)^Feature: Summarise traversal context for `\\.expect\\(\\.\\.\\)` linting$", - "(?m)^\\- Helpers to build rules, results, locations, artifacts, and invocations\\.$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- \\*\\*Checkboxes:\\*\\* Precede task and sub\\-task items with a GitHub Flavored$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", "(?m)^careful setup before a test can execute and, sometimes, teardown afterward\\.$", "(?m)^cargo dylint list \\-\\-color never \\| Where\\-Object \\{\\{$", - "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{suite_crate\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", + "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{SUITE_CRATE\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", "(?m)^error: Tha gnìomh std::fs `std::fs::read` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^fn capitalise_segment\\(segment: &str\\) \\-> Option \\{$", - "(?m)^fn localised_help_attributes_are_complete\\(\\) \\{$", - "(?m)^fn localised_messages\\($", - "(?m)^fn normalise_isolation_marks\\(text: &str\\) \\-> String \\{$", - "(?m)^fn normalise_settings_falls_back_to_defaults\\($", - "(?m)^fn pluralisation_covers_sample_range\\(#\\[case\\] locale: &str, #\\[case\\] max_branches: i64\\) \\{$", - "(?m)^fn recognises_std_fs_paths\\(#\\[case\\] path: &str, #\\[case\\] expected: bool\\) \\{$", - "(?m)^fn recognises_test_attribute\\(\\) \\{$", - "(?m)^fn resolve_additional_components_parametrised\\(#\\[case\\] cranelift: bool, #\\[case\\] expected: &\\[&str\\]\\) \\{$", - "(?m)^fn scenario_recognises_custom\\(function: FunctionFixture, evaluation: Evaluation\\) \\{$", - "(?m)^fn summarise_context_with_harness<'tcx>\\($", - "(?m)^fn summarises_plain_context\\(\\) \\{$", - "(?m)^fn validate_pluralisation_coverage\\(locale: &str, max_branches: i64\\) \\{$", - "(?m)^fn when_finalised\\(world: &CcWorld\\) \\{$", - "(?m)^fn when_localise\\(world: &WorldCell\\) \\{$", - "(?m)^fn when_normalise\\(world: &World\\) \\{$", - "(?m)^fn when_summarise\\(world: &ContextWorld\\) \\{$", "(?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\\.$", - "(?m)^pub fn normalise_locale\\(input: Option<&str>\\) \\-> Option<&str> \\{$", - "(?m)^pub fn normalise_settings\\(settings: Settings\\) \\-> Settings \\{$", - "(?m)^pub fn rasterise_signal\\($", - "(?m)^pub struct Artifact \\{$", - "(?m)^pub struct ArtifactLocation \\{$", - "(?m)^pub use location::\\{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation\\};$", - "(?m)^pub use run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^pub use selection::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^pub\\(crate\\) fn localised_messages\\($", - "(?m)^pub\\(crate\\) fn summarise_context<'tcx>\\($", - "(?m)^pub\\(crate\\) fn summarise_context\\($", - "(?m)^use crate::analysis::\\{Settings, detect_bumps, normalise_settings\\};$", - "(?m)^use crate::context::\\{ContextSummary, summarise_context\\};$", - "(?m)^use crate::context::\\{collect_context, is_cfg_test_attribute, summarise_context\\};$", - "(?m)^use crate::context::summarise_context;$", - "(?m)^use crate::diagnostics::\\{StdFsMessages, localised_messages\\};$", - "(?m)^use crate::model::location::\\{ArtifactLocation, Location, PhysicalLocation, Region\\};$", - "(?m)^use crate::model::run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^use whitaker_common::i18n::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^use whitaker_common::i18n::normalise_locale;$", "(?s)```.*?```", "\\brust-analyzer\\b", "`[^`\\n]+`",