From e07c484442ff18eacd48bf8558bc131db31365c2 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 25 Aug 2026 18:11:13 +0200 Subject: [PATCH 01/12] feat(coverage-gate): add target-specific policies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 +- Cargo.toml | 1 + crates/cargo-anvil/docs/design/checks.md | 2 +- .../justfiles/anvil/checks/llvm-cov.just | 46 +-- .../templates/justfiles/anvil/versions.just | 2 +- crates/cargo-anvil/tests/recipe_contracts.rs | 64 ++++- crates/cargo-coverage-gate/Cargo.toml | 3 +- crates/cargo-coverage-gate/README.md | 29 +- .../cargo-coverage-gate/docs/design/README.md | 61 +++- crates/cargo-coverage-gate/src/attribute.rs | 1 + .../src/bin/cargo-coverage-gate/cli.rs | 13 + .../src/bin/cargo-coverage-gate/run.rs | 12 +- crates/cargo-coverage-gate/src/error.rs | 55 ++++ crates/cargo-coverage-gate/src/lib.rs | 64 ++++- crates/cargo-coverage-gate/src/target.rs | 109 +++++++ crates/cargo-coverage-gate/src/threshold.rs | 1 + crates/cargo-coverage-gate/src/verdict.rs | 9 +- crates/cargo-coverage-gate/src/workspace.rs | 272 ++++++++++++++++-- crates/cargo-coverage-gate/tests/cli.rs | 85 ++++++ 19 files changed, 780 insertions(+), 52 deletions(-) create mode 100644 crates/cargo-coverage-gate/src/target.rs diff --git a/Cargo.lock b/Cargo.lock index 2b96212c..ca292571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,9 +393,10 @@ dependencies = [ [[package]] name = "cargo-coverage-gate" -version = "0.3.0" +version = "0.4.0" dependencies = [ "assert_cmd", + "cargo-platform", "cargo_metadata", "clap", "lcov", diff --git a/Cargo.toml b/Cargo.toml index bc5fab38..5ee476ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ bytes = { version = "1.11.1", default-features = false } camino = { version = "1.2.5", default-features = false } # local dependencies cargo-aprz-lib = { path = "crates/cargo-aprz-lib", default-features = false, version = "1.1.0" } +cargo-platform = { version = "0.3.3", default-features = false } cargo_metadata = { version = "0.23.1", default-features = false } cel-interpreter = { version = "0.10.0", default-features = false } chrono = { version = "0.4.40", default-features = false } diff --git a/crates/cargo-anvil/docs/design/checks.md b/crates/cargo-anvil/docs/design/checks.md index 69d2be0a..0ca15310 100644 --- a/crates/cargo-anvil/docs/design/checks.md +++ b/crates/cargo-anvil/docs/design/checks.md @@ -186,7 +186,7 @@ matrix overhead. | Check | Invocation | Source | |--------------|-----------------------------------------------------------------------------|--------| -| `llvm-cov` | Runs tests for every affected package under both feature configurations. Packages with a positive coverage threshold run through self-contained `cargo + llvm-cov nextest --no-report` invocations and produce per-config lcov/cobertura reports. Packages declaring `min-lines-percent = 0` still run through plain `cargo nextest`; the opt-out disables measurement and gating, never tests. Per-config reports avoid Windows command-line overflow and are reconciled downstream by cargo-coverage-gate, Codecov, and ADO. Codecov is display-only; the local coverage gate is authoritative. | oxidizer, oxidizer-github; gate via [`cargo-coverage-gate`](../../../cargo-coverage-gate) | +| `llvm-cov` | Runs tests for every affected package under both feature configurations. Packages with an enabled positive coverage threshold run through self-contained `cargo + llvm-cov nextest --no-report` invocations and produce per-config lcov/cobertura reports. Packages declaring `min-lines-percent = 0`, plus packages disabled by a matching `[package.metadata.coverage-gate.target.'cfg(...)']` policy, still run through plain `cargo nextest`; the opt-out disables measurement and gating, never tests. Cargo Anvil asks cargo-coverage-gate to resolve target selectors so measurement and gating use one policy implementation. Per-config reports avoid Windows command-line overflow and are reconciled downstream by cargo-coverage-gate, Codecov, and ADO. Codecov is display-only; the local coverage gate is authoritative. | oxidizer, oxidizer-github; gate via [`cargo-coverage-gate`](../../../cargo-coverage-gate) | | `doc-test` | Two cargo-test runs over the same affected set: `cargo test --doc --workspace --all-features --locked` and `cargo test --doc --workspace --locked` (default features). Running both catches doctests that only compile under one feature configuration (oxidizer-github runs both). nextest does not run doctests, so this stays a separate cargo-test invocation. | oxidizer, oxidizer-github | | `examples` | `cargo build --workspace --examples --all-features --locked` -- verifies that example targets compile. Running each example is intentionally not part of the check (examples are not test scaffolding; their runtime behavior isn't part of what we gate on). | oxidizer, oxidizer-github | diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just index 1e6098f0..16d98bef 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just @@ -30,23 +30,37 @@ anvil-llvm-cov: anvil-llvm-cov-validate-prereqs # and `llvm-cov export` aborts with "no coverage data found" whenever # such a package is the *only* object in the report (e.g. it is the # sole affected crate). Opt-out is declared via - # [package.metadata.coverage-gate] min-lines-percent = 0 -- the same - # marker cargo-coverage-gate already treats as NO DATA -> OK. Opted-out - # packages still run through plain nextest; opting out of measurement - # never opts a changed package out of tests. + # [package.metadata.coverage-gate] min-lines-percent = 0, or by a matching + # target policy with enabled = false. cargo-coverage-gate resolves target + # selectors so measurement and gating cannot disagree. Opted-out packages + # still run through plain nextest; opting out of measurement never opts a + # changed package out of tests. $testOnlyPkg = [System.Collections.Generic.List[string]]::new() - if ($pkg -contains '--package') { - $metadataJson = & cargo metadata --no-deps --format-version 1 - if ($LASTEXITCODE -ne 0) { - Write-Error 'anvil-llvm-cov: cargo metadata failed' - exit $LASTEXITCODE - } - $optedOut = @( - ($metadataJson | ConvertFrom-Json).packages | - Where-Object { $null -ne $_.metadata.'coverage-gate'.'min-lines-percent' -and [double]$_.metadata.'coverage-gate'.'min-lines-percent' -eq 0 } | - ForEach-Object { $_.name } - ) - if ($optedOut.Count -gt 0) { + $metadataJson = & cargo metadata --no-deps --format-version 1 + if ($LASTEXITCODE -ne 0) { + Write-Error 'anvil-llvm-cov: cargo metadata failed' + exit $LASTEXITCODE + } + $metadata = $metadataJson | ConvertFrom-Json + $optedOut = @(& cargo coverage-gate --print-test-only-packages) + if ($LASTEXITCODE -ne 0) { + Write-Error 'anvil-llvm-cov: cargo coverage-gate target resolution failed' + exit $LASTEXITCODE + } + if ($optedOut.Count -gt 0) { + if ($pkg -contains '--workspace') { + $pkg = @( + $metadata.packages | + Where-Object { $optedOut -notcontains $_.name } | + ForEach-Object { '--package'; "$($_.name)@$($_.version)" } + ) + $metadata.packages | + Where-Object { $optedOut -contains $_.name } | + ForEach-Object { + $testOnlyPkg.Add('--package') + $testOnlyPkg.Add("$($_.name)@$($_.version)") + } + } elseif ($pkg -contains '--package') { $filtered = [System.Collections.Generic.List[string]]::new() for ($i = 0; $i -lt $pkg.Count; $i++) { # Impact emits version-qualified specs (name@version); diff --git a/crates/cargo-anvil/templates/justfiles/anvil/versions.just b/crates/cargo-anvil/templates/justfiles/anvil/versions.just index 564a3b90..25cef735 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/versions.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/versions.just @@ -54,7 +54,7 @@ cargo_audit_version := "0.22.2" cargo_bolero_version := "0.13.4" cargo_careful_version := "0.4.10" cargo_check_external_types_version := "0.5.0" -cargo_coverage_gate_version := "0.2.0" +cargo_coverage_gate_version := "0.4.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.19.8" cargo_doc2readme_version := "0.7.2" diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 52925110..1d2a092c 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -50,7 +50,13 @@ if ($args -contains 'metadata') { manifest_path = $manifestPath targets = @([pscustomobject]@{ name = $libName; kind = @('lib') }) metadata = [pscustomobject]@{ - 'coverage-gate' = [pscustomobject]@{ 'min-lines-percent' = 0 } + 'coverage-gate' = [pscustomobject]@{ + 'min-lines-percent' = if ($env:FAKE_MIN_LINES_PERCENT) { + [double]$env:FAKE_MIN_LINES_PERCENT + } else { + 0 + } + } } } ) @@ -81,6 +87,14 @@ if ($args -contains 'semver-checks') { if ($env:FAKE_SEMVER_OUTPUT) { Write-Output $env:FAKE_SEMVER_OUTPUT } exit [int]$env:FAKE_SEMVER_EXIT } +if ($args -contains 'coverage-gate') { + if ($env:FAKE_DISABLED_PACKAGES) { + $env:FAKE_DISABLED_PACKAGES -split ',' | ForEach-Object { Write-Output $_ } + } elseif (-not $env:FAKE_MIN_LINES_PERCENT -or [double]$env:FAKE_MIN_LINES_PERCENT -eq 0) { + if ($env:FAKE_PACKAGE_NAME) { Write-Output $env:FAKE_PACKAGE_NAME } else { Write-Output 'fixture' } + } + exit [int]$env:FAKE_COVERAGE_GATE_EXIT +} if ($args -contains 'bolero' -and $args -contains 'list') { exit [int]$env:FAKE_BOLERO_LIST_EXIT } @@ -645,7 +659,12 @@ fn all_coverage_opted_out_packages_run_both_test_configurations() { assert!(calls.contains("--no-default-features"), "calls:\n{calls}"); assert_eq!(calls.matches("--no-tests=pass").count(), 2, "calls:\n{calls}"); assert!(!calls.contains("llvm-cov"), "coverage commands must not run:\n{calls}"); - assert!(!calls.contains("coverage-gate"), "the coverage gate must not run:\n{calls}"); + assert_eq!( + calls.matches("coverage-gate").count(), + 1, + "only the metadata-only target-policy query may run:\n{calls}" + ); + assert!(calls.contains("coverage-gate --print-test-only-packages"), "calls:\n{calls}"); let no_tests = run_just( tmp.path(), @@ -673,6 +692,47 @@ fn all_coverage_opted_out_packages_run_both_test_configurations() { assert_failed(&failed, "plain nextest failure for an opted-out package"); } +#[test] +fn target_disabled_packages_run_tests_without_coverage() { + if !tools_available() { + return; + } + let tmp = fixture( + &[("llvm-cov.just", LLVM_COV)], + &[ + "anvil-component-nightly-llvm-tools-validate-prereqs", + "anvil-tool-cargo-llvm-cov-validate-prereqs", + "anvil-tool-cargo-nextest-validate-prereqs", + "anvil-tool-cargo-coverage-gate-validate-prereqs", + "anvil-component-nightly-llvm-tools-install", + "anvil-tool-cargo-llvm-cov-install installer", + "anvil-tool-cargo-nextest-install installer", + "anvil-tool-cargo-coverage-gate-install installer", + ], + ); + let log = tmp.path().join("cargo.log"); + let output = run_just( + tmp.path(), + &["anvil-llvm-cov"], + &[ + ("ANVIL_INCLUDE_AFFECTED", OsStr::new("--package fixture@0.1.0")), + ("FAKE_MIN_LINES_PERCENT", OsStr::new("100")), + ("FAKE_DISABLED_PACKAGES", OsStr::new("fixture")), + ("FAKE_NEXTEST_EXIT", OsStr::new("0")), + ("FAKE_CARGO_LOG", log.as_os_str()), + ], + ); + assert!( + output.status.success(), + "target-disabled coverage path should succeed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let calls = std::fs::read_to_string(&log).unwrap(); + assert_eq!(calls.matches("nextest run").count(), 2, "calls:\n{calls}"); + assert!(!calls.contains("llvm-cov nextest"), "coverage must not run:\n{calls}"); + assert!(calls.contains("coverage-gate --print-test-only-packages"), "calls:\n{calls}"); +} + #[cfg(windows)] #[test] fn windows_arm64_fallback_accepts_empty_nextest_sets_in_both_configurations() { diff --git a/crates/cargo-coverage-gate/Cargo.toml b/crates/cargo-coverage-gate/Cargo.toml index 3b575723..9ac8730c 100644 --- a/crates/cargo-coverage-gate/Cargo.toml +++ b/crates/cargo-coverage-gate/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "cargo-coverage-gate" description = "A cargo subcommand that gates pull requests on per-package line coverage measured by cargo-llvm-cov" -version = "0.3.0" +version = "0.4.0" readme = "README.md" keywords = ["oxidizer", "cargo", "subcommand", "coverage", "ci"] categories = ["command-line-utilities", "development-tools::cargo-plugins"] @@ -31,6 +31,7 @@ name = "cargo-coverage-gate" path = "src/bin/cargo-coverage-gate/main.rs" [dependencies] +cargo-platform = { workspace = true } cargo_metadata = { workspace = true } clap = { workspace = true, features = ["derive", "std", "help", "usage", "error-context"] } lcov = { workspace = true } diff --git a/crates/cargo-coverage-gate/README.md b/crates/cargo-coverage-gate/README.md index 0554abcb..bdcaf889 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -42,6 +42,20 @@ holds and fails — as a regression — if coverable lines later appear. The two keys are mutually exclusive, and `expect-no-coverable-lines` is package-scoped only. +A package can replace that policy for a Cargo-style target selector: + +```toml +[package.metadata.coverage-gate] +min-lines-percent = 100 + +[package.metadata.coverage-gate.target.'cfg(not(windows))'] +enabled = false +``` + +`enabled = false` disables coverage measurement and gating on the +matching target, but does not disable test execution in automation +such as Cargo Anvil. + ### Why lcov, not the JSON? `cargo-llvm-cov` exports the same instrumentation run in several @@ -60,6 +74,7 @@ Codecov / ADO numbers confusing. ```text cargo coverage-gate [--lcov ]... [-p|--package ]... + [--target ] [--summary-file ] [--quiet] ``` @@ -105,11 +120,11 @@ plus the appropriate exit code. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbDzRwf0qddWQbQiTzhu0-bE0bX-rutkvfDuYbITgXvtMXiRVhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC4zLjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbNxCFhIlcaZ4bsa602UxpyRQbgFr106dXJHcbhevnlfEoou9hZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__link0]: https://github.com/taiki-e/cargo-llvm-cov - [__link1]: https://docs.rs/cargo-coverage-gate/0.3.0/cargo_coverage_gate/fn.evaluate.html - [__link2]: https://docs.rs/cargo-coverage-gate/0.3.0/cargo_coverage_gate/struct.EvaluatedReport.html - [__link3]: https://docs.rs/cargo-coverage-gate/0.3.0/cargo_coverage_gate/?search=EvaluatedReport::render_text - [__link4]: https://docs.rs/cargo-coverage-gate/0.3.0/cargo_coverage_gate/?search=EvaluatedReport::render_markdown - [__link5]: https://docs.rs/cargo-coverage-gate/0.3.0/cargo_coverage_gate/enum.Verdict.html - [__link6]: https://docs.rs/cargo-coverage-gate/0.3.0/cargo_coverage_gate/?search=EvaluatedReport::verdict + [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html + [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/struct.EvaluatedReport.html + [__link3]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_text + [__link4]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_markdown + [__link5]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/enum.Verdict.html + [__link6]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::verdict diff --git a/crates/cargo-coverage-gate/docs/design/README.md b/crates/cargo-coverage-gate/docs/design/README.md index 7d6b1d14..db3b5c7d 100644 --- a/crates/cargo-coverage-gate/docs/design/README.md +++ b/crates/cargo-coverage-gate/docs/design/README.md @@ -161,9 +161,10 @@ min-lines-percent = 75.0 min-lines-percent = 80.0 ``` -The schema today is one key, `min-lines-percent`, an integer or float percentage -(`0.0`–`100.0` inclusive). Future extensions can add `min-functions`, -`min-regions` symmetrically. +The base schema accepts `min-lines-percent`, an integer or float percentage +(`0.0`–`100.0` inclusive), or the package-only +`expect-no-coverable-lines` assertion described below. Future extensions can +add `min-functions` and `min-regions` symmetrically. The built-in default of `100.0` means **gating is on by default**: a new package with no metadata anywhere will only pass if every measured line is @@ -223,6 +224,56 @@ Rules: - A non-boolean value is a configuration error (exit `2`). An explicit `false` is identical to omitting the key. +#### Target-specific policy + +Packages whose implementation exists only on selected compilation targets use +Cargo-style target selectors nested under their package metadata: + +```toml +[package.metadata.coverage-gate] +min-lines-percent = 100 + +[package.metadata.coverage-gate.target.'cfg(not(windows))'] +enabled = false +``` + +Selectors use the same grammar as Cargo's target-specific dependency tables: +an exact target triple (`x86_64-pc-windows-msvc`) or a quoted `cfg(...)` +expression (`cfg(windows)`, `cfg(target_os = "linux")`, +`cfg(all(unix, target_arch = "x86_64"))`). Coverage-gate uses the +`cargo-platform` parser and matches `cfg(...)` expressions against +`rustc --print cfg --target `, so it does not maintain a second target +language. + +`enabled = false` disables coverage measurement and gating for that package on +the matching target. It does not disable tests: orchestrators run the package +through their non-instrumented test path. This is distinct from +`expect-no-coverable-lines = true`, which keeps a target-independent facade or +re-export package in the instrumented test set so its tests can contribute +coverage to other packages. + +Target tables may alternatively replace the base policy with +`min-lines-percent` or `expect-no-coverable-lines`. A target table describes +one complete policy; `enabled = false`, `min-lines-percent`, and +`expect-no-coverable-lines = true` are mutually exclusive in that table. +`enabled = true` explicitly inherits the base package/workspace policy and is +useful when an exact target needs to override a broader `cfg(...)` opt-out. + +Resolution follows Cargo's precedence: + +1. An exact target-triple table wins over every matching `cfg(...)` table. +2. Otherwise one matching `cfg(...)` table supplies the target policy. +3. Multiple matching `cfg(...)` tables are a configuration error rather than + depending on TOML declaration order. +4. With no matching target table, the ordinary package → workspace → built-in + policy applies. + +The CLI accepts `--target `. When omitted, it obtains the host triple +from `rustc -vV`. A dedicated `--print-test-only-packages` mode loads metadata +and prints packages whose effective policy is `min-lines-percent = 0` or +`enabled = false`, without reading lcov. Coverage orchestrators use this before +selecting packages for instrumentation. + ### 5.4 The verdict table The tool prints a table to stdout (and to the summary file when @@ -378,6 +429,10 @@ state and classifies as a pass (`EMPTY` / `➖`), not the no-data configuration error. Conversely, if such a package *does* have attributed coverable lines, it fails the gate (exit `1`) rather than passing. +A package disabled by its target-specific policy (§5.3) is removed from the gated set +before attribution. It therefore neither produces a table row nor triggers the +no-data error on an unsupported target. + ### 6.4 Cross-package test attribution Per-package aggregation groups measurements by **source-file ownership** diff --git a/crates/cargo-coverage-gate/src/attribute.rs b/crates/cargo-coverage-gate/src/attribute.rs index 37efc121..e27e9b1f 100644 --- a/crates/cargo-coverage-gate/src/attribute.rs +++ b/crates/cargo-coverage-gate/src/attribute.rs @@ -70,6 +70,7 @@ mod tests { manifest_dir: PathBuf::from(manifest_dir), min_lines_percent: None, expect_no_coverable_lines: false, + coverage_disabled: false, } } diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs index e0b5301f..887cd90d 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs @@ -45,6 +45,19 @@ pub(crate) struct CoverageGateArgs { #[arg(long = "package", short = 'p', value_name = "SPEC")] pub(crate) packages: Vec, + /// Rust target triple whose coverage policy should be evaluated. + /// + /// Defaults to the active rustc host target. + #[arg(long, value_name = "TRIPLE")] + pub(crate) target: Option, + + /// Print packages that should run tests without coverage and exit. + /// + /// Includes packages whose effective policy has + /// `min-lines-percent = 0` or `enabled = false`. + #[arg(long)] + pub(crate) print_test_only_packages: bool, + /// Write the Markdown verdict table to this file. /// /// When unset, the tool falls back to `$GITHUB_STEP_SUMMARY` and then diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs index b4a2c91a..3dd39978 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs @@ -15,6 +15,15 @@ use ohno::{AppError, IntoAppError}; use crate::cli::CoverageGateArgs; pub(crate) fn run(args: &CoverageGateArgs) -> Result { + if args.print_test_only_packages { + let packages = cargo_coverage_gate::test_only_packages(None, &args.packages, args.target.as_deref()) + .into_app_err("failed to resolve test-only packages")?; + for package in packages { + println!("{package}"); + } + return Ok(ExitCode::SUCCESS); + } + let lcov_paths: Vec = if args.lcov.is_empty() { vec![PathBuf::from("target/coverage/lcov.info")] } else { @@ -27,7 +36,8 @@ pub(crate) fn run(args: &CoverageGateArgs) -> Result { } let lcov_refs: Vec<&str> = lcov_texts.iter().map(String::as_str).collect(); - let report = cargo_coverage_gate::evaluate_many(&lcov_refs, None, &args.packages).into_app_err("failed to evaluate coverage")?; + let report = cargo_coverage_gate::evaluate_many_for_target(&lcov_refs, None, &args.packages, args.target.as_deref()) + .into_app_err("failed to evaluate coverage")?; write_text_output(&report, args.quiet).into_app_err("failed to write verdict to stdout")?; diff --git a/crates/cargo-coverage-gate/src/error.rs b/crates/cargo-coverage-gate/src/error.rs index 1dbac950..bdb6688c 100644 --- a/crates/cargo-coverage-gate/src/error.rs +++ b/crates/cargo-coverage-gate/src/error.rs @@ -33,7 +33,11 @@ use serde_json::Value; ThresholdOutOfRangeError, InvalidNoCoverableLinesValueError, ConflictingCoverageMetadataError, + InvalidTargetPolicyError, + InvalidTargetSelectorError, + AmbiguousTargetPolicyError, WorkspaceScopedNoCoverableLinesError, + ResolveTargetError, ParseLcovError, ReadLcovError, UnknownPackageSelectorError @@ -102,6 +106,44 @@ pub(crate) struct ConflictingCoverageMetadataError { )] pub(crate) struct WorkspaceScopedNoCoverableLinesError; +/// A target policy was not an object or did not select exactly one +/// effective behavior. +#[ohno::error] +#[display("{source}: invalid coverage-gate target policy: {detail}")] +pub(crate) struct InvalidTargetPolicyError { + pub source: String, + pub detail: String, +} + +/// A target-policy table key was neither a valid target triple nor a +/// valid Cargo `cfg(...)` selector. +#[ohno::error] +#[display("{source}: invalid coverage-gate target selector `{selector}`: {detail}")] +pub(crate) struct InvalidTargetSelectorError { + pub source: String, + pub selector: String, + pub detail: String, +} + +/// More than one `cfg(...)` target policy matched the active target. +#[ohno::error] +#[display( + "{source}: multiple coverage-gate target policies match `{target}`: {selectors}; \ + use disjoint cfg expressions or an exact target-triple override" +)] +pub(crate) struct AmbiguousTargetPolicyError { + pub source: String, + pub target: String, + pub selectors: String, +} + +/// The active Rust target or its cfg values could not be obtained. +#[ohno::error] +#[display("failed to resolve coverage target: {detail}")] +pub(crate) struct ResolveTargetError { + pub detail: String, +} + /// An lcov tracefile was syntactically malformed. #[ohno::error] #[display("lcov tracefile is not well-formed")] @@ -188,4 +230,17 @@ mod tests { assert!(rendered.contains("expect-no-coverable-lines")); assert!(rendered.contains("workspace.metadata.coverage-gate")); } + + #[test] + fn ambiguous_target_policy_names_target_and_selectors() { + let err = AmbiguousTargetPolicyError::new( + "alpha".to_owned(), + "x86_64-pc-windows-msvc".to_owned(), + "cfg(windows), cfg(target_os = \"windows\")".to_owned(), + ); + let rendered = err.to_string(); + assert!(rendered.contains("alpha")); + assert!(rendered.contains("x86_64-pc-windows-msvc")); + assert!(rendered.contains("cfg(windows)")); + } } diff --git a/crates/cargo-coverage-gate/src/lib.rs b/crates/cargo-coverage-gate/src/lib.rs index 3325e701..12f736c2 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -33,6 +33,20 @@ //! The two keys are mutually exclusive, and `expect-no-coverable-lines` //! is package-scoped only. //! +//! A package can replace that policy for a Cargo-style target selector: +//! +//! ```toml +//! [package.metadata.coverage-gate] +//! min-lines-percent = 100 +//! +//! [package.metadata.coverage-gate.target.'cfg(not(windows))'] +//! enabled = false +//! ``` +//! +//! `enabled = false` disables coverage measurement and gating on the +//! matching target, but does not disable test execution in automation +//! such as Cargo Anvil. +//! //! ## Why lcov, not the JSON? //! //! `cargo-llvm-cov` exports the same instrumentation run in several @@ -51,6 +65,7 @@ //! //! ```text //! cargo coverage-gate [--lcov ]... [-p|--package ]... +//! [--target ] //! [--summary-file ] [--quiet] //! ``` //! @@ -108,6 +123,7 @@ mod attribute; mod error; mod lcov_cov; mod render; +mod target; mod threshold; mod verdict; mod workspace; @@ -234,13 +250,59 @@ pub fn evaluate_many( lcov_texts: &[&str], manifest_path: Option<&Path>, gated_packages: &[String], +) -> Result { + evaluate_many_for_target(lcov_texts, manifest_path, gated_packages, None) +} + +/// Evaluate one or more lcov tracefiles for an explicit compilation target. +/// +/// `target` is a Rust target triple such as `x86_64-pc-windows-msvc`. +/// When omitted, the active `rustc` host target is used. Target-specific +/// package policy is resolved before the gated package set is evaluated. +/// +/// # Errors +/// +/// Returns a [`CoverageGateError`] under the same conditions as +/// [`evaluate_many`], or when the target/cfg query fails. +pub fn evaluate_many_for_target( + lcov_texts: &[&str], + manifest_path: Option<&Path>, + gated_packages: &[String], + target: Option<&str>, ) -> Result { let report = lcov_cov::CoverageReport::from_strs(lcov_texts)?; - let ws = workspace::Workspace::load(manifest_path)?; + let target = target::TargetContext::resolve(target)?; + let ws = workspace::Workspace::load(manifest_path, &target)?; let inner = verdict::evaluate(&report, &ws, gated_packages)?; Ok(EvaluatedReport { inner }) } +/// Return packages that should run tests without coverage for `target`. +/// +/// This metadata-only query is intended for coverage automation that +/// must remove packages with `min-lines-percent = 0` or +/// `enabled = false` from instrumentation while still running their tests +/// through a plain test runner. +/// +/// # Errors +/// +/// Returns a [`CoverageGateError`] when workspace metadata, target +/// discovery, target policy, or a package selector is invalid. +pub fn test_only_packages( + manifest_path: Option<&Path>, + packages: &[String], + target: Option<&str>, +) -> Result, CoverageGateError> { + let target = target::TargetContext::resolve(target)?; + let ws = workspace::Workspace::load(manifest_path, &target)?; + let selected = verdict::resolve_gated(&ws, packages)?; + Ok(selected + .into_iter() + .filter(|member| member.coverage_disabled || member.min_lines_percent == Some(0.0)) + .map(|member| member.name.clone()) + .collect()) +} + #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { diff --git a/crates/cargo-coverage-gate/src/target.rs b/crates/cargo-coverage-gate/src/target.rs new file mode 100644 index 00000000..1af74ba4 --- /dev/null +++ b/crates/cargo-coverage-gate/src/target.rs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active compilation-target discovery and Cargo-style selector matching. + +use std::env; +use std::ffi::OsString; +use std::process::Command; +use std::str::FromStr; + +use cargo_platform::{Cfg, Platform}; + +use crate::error::{CoverageGateError, ResolveTargetError}; + +/// The target triple and cfg values used to resolve target policy. +#[derive(Debug, Clone)] +pub(crate) struct TargetContext { + pub(crate) triple: String, + cfg: Vec, +} + +impl TargetContext { + /// Resolve an explicit target, or the active rustc host when omitted. + pub(crate) fn resolve(target: Option<&str>) -> Result { + let rustc = env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let triple = if let Some(target) = target { + target.to_owned() + } else { + let output = Command::new(&rustc) + .arg("-vV") + .output() + .map_err(|error| ResolveTargetError::new(format!("could not execute `{}`: {error}", rustc.to_string_lossy())))?; + if !output.status.success() { + return Err(ResolveTargetError::new(format!( + "`{} -vV` exited with {}: {}", + rustc.to_string_lossy(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + .into()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .map(str::to_owned) + .ok_or_else(|| ResolveTargetError::new(format!("`{} -vV` did not report a host triple", rustc.to_string_lossy())))? + }; + + let output = Command::new(&rustc) + .args(["--print", "cfg", "--target", &triple]) + .output() + .map_err(|error| ResolveTargetError::new(format!("could not execute `{}`: {error}", rustc.to_string_lossy())))?; + if !output.status.success() { + return Err(ResolveTargetError::new(format!( + "`{} --print cfg --target {triple}` exited with {}: {}", + rustc.to_string_lossy(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + .into()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let cfg = stdout + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + Cfg::from_str(line) + .map_err(|error| ResolveTargetError::new(format!("rustc reported invalid cfg `{line}` for `{triple}`: {error}")).into()) + }) + .collect::, CoverageGateError>>()?; + + Ok(Self { triple, cfg }) + } + + pub(crate) fn matches(&self, platform: &Platform) -> bool { + platform.matches(&self.triple, &self.cfg) + } + + #[cfg(test)] + pub(crate) fn from_parts(triple: &str, cfg: &[&str]) -> Self { + Self { + triple: triple.to_owned(), + cfg: cfg + .iter() + .map(|value| Cfg::from_str(value).expect("test cfg must use rustc --print cfg syntax")) + .collect(), + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn matches_exact_and_cfg_selectors() { + let target = TargetContext::from_parts( + "x86_64-pc-windows-msvc", + &["windows", "target_arch=\"x86_64\"", "target_os=\"windows\""], + ); + assert!(target.matches(&Platform::from_str("x86_64-pc-windows-msvc").expect("exact target"))); + assert!(target.matches(&Platform::from_str("cfg(windows)").expect("windows cfg"))); + assert!(target.matches(&Platform::from_str("cfg(target_os = \"windows\")").expect("target_os cfg"))); + assert!(!target.matches(&Platform::from_str("cfg(unix)").expect("unix cfg"))); + } +} diff --git a/crates/cargo-coverage-gate/src/threshold.rs b/crates/cargo-coverage-gate/src/threshold.rs index 70db4051..e09f793c 100644 --- a/crates/cargo-coverage-gate/src/threshold.rs +++ b/crates/cargo-coverage-gate/src/threshold.rs @@ -91,6 +91,7 @@ mod tests { manifest_dir: PathBuf::from(format!("/repo/crates/{name}")), min_lines_percent, expect_no_coverable_lines: false, + coverage_disabled: false, } } diff --git a/crates/cargo-coverage-gate/src/verdict.rs b/crates/cargo-coverage-gate/src/verdict.rs index ff23d9e5..9f2d94b0 100644 --- a/crates/cargo-coverage-gate/src/verdict.rs +++ b/crates/cargo-coverage-gate/src/verdict.rs @@ -104,7 +104,10 @@ impl Report { /// glob pattern (`*` and `?`). A literal that matches no workspace /// member, or a glob that matches none, produces a [`CoverageGateError`]. pub(crate) fn evaluate(report: &CoverageReport, workspace: &Workspace, gated_packages: &[String]) -> Result { - let gated = resolve_gated(workspace, gated_packages)?; + let gated: Vec<&Member> = resolve_gated(workspace, gated_packages)? + .into_iter() + .filter(|member| !member.coverage_disabled) + .collect(); let AttributionOutcome { by_member, unattributed } = attribute(&report.files, &workspace.members); @@ -154,7 +157,7 @@ pub(crate) fn evaluate(report: &CoverageReport, workspace: &Workspace, gated_pac /// Unix shell globs (mirroring `cargo build -p 'tokio-*'`). A selector /// that matches no member is a configuration error. Members matched by /// multiple selectors appear only once. -fn resolve_gated<'w>(workspace: &'w Workspace, packages: &[String]) -> Result, CoverageGateError> { +pub(crate) fn resolve_gated<'w>(workspace: &'w Workspace, packages: &[String]) -> Result, CoverageGateError> { if packages.is_empty() { return Ok(workspace.members.iter().collect()); } @@ -297,6 +300,7 @@ mod tests { manifest_dir: PathBuf::from(manifest_dir), min_lines_percent, expect_no_coverable_lines: false, + coverage_disabled: false, } } @@ -306,6 +310,7 @@ mod tests { manifest_dir: PathBuf::from(manifest_dir), min_lines_percent: None, expect_no_coverable_lines: true, + coverage_disabled: false, } } diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index a189c786..a53e35a3 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -11,14 +11,18 @@ //! and consumes the values surfaced here. use std::path::{Path, PathBuf}; +use std::str::FromStr; use cargo_metadata::MetadataCommand; +use cargo_platform::Platform; use serde_json::Value; use crate::error::{ - ConflictingCoverageMetadataError, CoverageGateError, InvalidNoCoverableLinesValueError, InvalidThresholdValueError, LoadMetadataError, - ThresholdOutOfRangeError, WorkspaceScopedNoCoverableLinesError, + AmbiguousTargetPolicyError, ConflictingCoverageMetadataError, CoverageGateError, InvalidNoCoverableLinesValueError, + InvalidTargetPolicyError, InvalidTargetSelectorError, InvalidThresholdValueError, LoadMetadataError, ThresholdOutOfRangeError, + WorkspaceScopedNoCoverableLinesError, }; +use crate::target::TargetContext; /// Lower bound on `min-lines-percent` values. const MIN_LINES_LOWER: f64 = 0.0; @@ -50,6 +54,8 @@ pub(crate) struct Member { /// and fails (as a regression) if coverable lines appear. Mutually /// exclusive with [`Member::min_lines_percent`]. pub(crate) expect_no_coverable_lines: bool, + /// A matching target policy disabled coverage measurement and gating. + pub(crate) coverage_disabled: bool, } impl Workspace { @@ -60,7 +66,7 @@ impl Workspace { /// Runs `cargo metadata --no-deps`, which does not fetch or build /// dependencies and is therefore fast and side-effect-free. #[ohno::enrich_err("failed to load cargo workspace metadata")] - pub(crate) fn load(manifest_path: Option<&Path>) -> Result { + pub(crate) fn load(manifest_path: Option<&Path>, target: &TargetContext) -> Result { let mut cmd = MetadataCommand::new(); cmd.no_deps(); if let Some(path) = manifest_path { @@ -84,12 +90,14 @@ impl Workspace { .expect("cargo-metadata always reports a manifest file path with a parent directory") .as_std_path() .to_path_buf(); - let gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?; + let mut gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?; + let coverage_disabled = apply_target_policy(&mut gate, target, &pkg.name)?; Ok::(Member { name: pkg.name.to_string(), manifest_dir, min_lines_percent: gate.min_lines_percent, expect_no_coverable_lines: gate.expect_no_coverable_lines, + coverage_disabled, }) }) .collect::, _>>()?; @@ -114,7 +122,7 @@ enum Scope { } /// The `coverage-gate` metadata extracted from a single scope. -#[derive(Debug, Default, Clone, Copy, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq)] struct CoverageGateMetadata { /// `min-lines-percent`, validated to `[0.0, 100.0]`, if present. min_lines_percent: Option, @@ -122,6 +130,23 @@ struct CoverageGateMetadata { /// indistinguishable. Always `false` in the workspace scope (a /// `true` value there is rejected). expect_no_coverable_lines: bool, + /// Parsed target-specific policy overrides. + target_policies: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +struct TargetPolicy { + selector_text: String, + selector: Platform, + policy: PolicyOverride, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum PolicyOverride { + Inherit, + Disabled, + Threshold(f64), + ExpectNoCoverableLines, } /// Pull the `coverage-gate` block out of a freeform metadata `Value` and @@ -139,6 +164,7 @@ fn extract_coverage_gate(metadata: &Value, source: &str, scope: Scope) -> Result let min_lines_percent = extract_min_lines_percent(gate, source)?; let expect_no_coverable_lines = extract_expect_no_coverable_lines(gate, source, scope)?; + let target_policies = extract_target_policies(gate, source, scope)?; if min_lines_percent.is_some() && expect_no_coverable_lines { return Err(ConflictingCoverageMetadataError::new(source.to_owned()).into()); @@ -147,9 +173,115 @@ fn extract_coverage_gate(metadata: &Value, source: &str, scope: Scope) -> Result Ok(CoverageGateMetadata { min_lines_percent, expect_no_coverable_lines, + target_policies, }) } +fn extract_target_policies(gate: &Value, source: &str, scope: Scope) -> Result, CoverageGateError> { + let Some(raw_target) = gate.get("target") else { + return Ok(Vec::new()); + }; + if scope == Scope::Workspace { + return Err(InvalidTargetPolicyError::new( + source.to_owned(), + "target policies are package-scoped and cannot be set in workspace metadata".to_owned(), + ) + .into()); + } + let table = raw_target.as_object().ok_or_else(|| { + InvalidTargetPolicyError::new( + source.to_owned(), + "`target` must be a table keyed by target triple or cfg expression".to_owned(), + ) + })?; + + table + .iter() + .map(|(selector_text, raw_policy)| { + let policy_source = format!("{source} target `{selector_text}`"); + let selector = Platform::from_str(selector_text) + .map_err(|error| InvalidTargetSelectorError::new(source.to_owned(), selector_text.clone(), error.to_string()))?; + let policy_table = raw_policy + .as_object() + .ok_or_else(|| InvalidTargetPolicyError::new(policy_source.clone(), "policy must be a table".to_owned()))?; + + let min_lines_percent = extract_min_lines_percent(raw_policy, &policy_source)?; + let expect_no_coverable_lines = extract_expect_no_coverable_lines(raw_policy, &policy_source, Scope::Package)?; + if min_lines_percent.is_some() && expect_no_coverable_lines { + return Err(ConflictingCoverageMetadataError::new(policy_source).into()); + } + + let enabled = match policy_table.get("enabled") { + Some(Value::Bool(value)) => Some(*value), + Some(value) => { + return Err(InvalidTargetPolicyError::new(policy_source, format!("`enabled` must be a boolean, got {value}")).into()); + } + None => None, + }; + if enabled.is_some() && (min_lines_percent.is_some() || expect_no_coverable_lines) { + return Err(InvalidTargetPolicyError::new( + policy_source, + "`enabled`, `min-lines-percent`, and `expect-no-coverable-lines = true` are mutually exclusive".to_owned(), + ) + .into()); + } + + let policy = match (enabled, min_lines_percent, expect_no_coverable_lines) { + (Some(false), None, false) => PolicyOverride::Disabled, + (Some(true) | None, None, false) => PolicyOverride::Inherit, + (None, Some(value), false) => PolicyOverride::Threshold(value), + (None, None, true) => PolicyOverride::ExpectNoCoverableLines, + _ => unreachable!("conflicting target policy combinations are rejected above"), + }; + Ok(TargetPolicy { + selector_text: selector_text.clone(), + selector, + policy, + }) + }) + .collect() +} + +fn apply_target_policy(metadata: &mut CoverageGateMetadata, target: &TargetContext, source: &str) -> Result { + let exact = metadata + .target_policies + .iter() + .find(|candidate| matches!(candidate.selector, Platform::Name(_)) && target.matches(&candidate.selector)); + let selected = if let Some(exact) = exact { + Some(exact) + } else { + let matching_cfg: Vec<&TargetPolicy> = metadata + .target_policies + .iter() + .filter(|candidate| matches!(candidate.selector, Platform::Cfg(_)) && target.matches(&candidate.selector)) + .collect(); + if matching_cfg.len() > 1 { + let selectors = matching_cfg + .iter() + .map(|candidate| candidate.selector_text.as_str()) + .collect::>() + .join(", "); + return Err(AmbiguousTargetPolicyError::new(source.to_owned(), target.triple.clone(), selectors).into()); + } + matching_cfg.first().copied() + }; + + match selected.map(|policy| policy.policy) { + None | Some(PolicyOverride::Inherit) => Ok(false), + Some(PolicyOverride::Disabled) => Ok(true), + Some(PolicyOverride::Threshold(value)) => { + metadata.min_lines_percent = Some(value); + metadata.expect_no_coverable_lines = false; + Ok(false) + } + Some(PolicyOverride::ExpectNoCoverableLines) => { + metadata.min_lines_percent = None; + metadata.expect_no_coverable_lines = true; + Ok(false) + } + } +} + /// Pull `min-lines-percent` out of a `coverage-gate` block and validate /// that it falls in `[0.0, 100.0]`. /// @@ -193,6 +325,17 @@ mod tests { use super::*; + fn test_target() -> TargetContext { + TargetContext::from_parts( + "x86_64-unknown-linux-gnu", + &["unix", "target_arch=\"x86_64\"", "target_os=\"linux\""], + ) + } + + fn load(manifest_path: &Path) -> Result { + Workspace::load(Some(manifest_path), &test_target()) + } + /// Write a minimal workspace with the given root `Cargo.toml` body /// and per-member specs. fn write_workspace(dir: &Path, root_body: &str, members: &[(&str, &str)]) { @@ -248,7 +391,7 @@ edition = "2021" ("gamma", &member("gamma", None)), ], ); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed"); + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); assert!(ws.default_min_lines_percent.is_none()); assert_eq!(ws.members.len(), 3); let names: Vec<&str> = ws.members.iter().map(|m| m.name.as_str()).collect(); @@ -268,7 +411,7 @@ edition = "2021" ROOT_WITH_DEFAULT, &[("alpha", &member("alpha", None)), ("beta", &member("beta", None))], ); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed"); + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); assert_eq!(ws.default_min_lines_percent, Some(80.0)); } @@ -281,7 +424,7 @@ edition = "2021" ROOT_WITH_DEFAULT, &[("alpha", &member("alpha", Some("90.5"))), ("beta", &member("beta", Some("0")))], ); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed"); + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); let alpha = ws.members.iter().find(|m| m.name == "alpha").expect("alpha"); let beta = ws.members.iter().find(|m| m.name == "beta").expect("beta"); assert_eq!(alpha.min_lines_percent, Some(90.5)); @@ -302,7 +445,7 @@ edition = "2021" ("gamma", &member("gamma", None)), ], ); - let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("out-of-range value must error"); + let err = load(&tmp.path().join("Cargo.toml")).expect_err("out-of-range value must error"); let rendered = err.to_string(); assert!(rendered.contains("alpha"), "rendered: {rendered}"); assert!(rendered.contains("120"), "rendered: {rendered}"); @@ -321,7 +464,7 @@ members = ["alpha"] min-lines-percent = -1 "#; write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); - let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("negative workspace value must error"); + let err = load(&tmp.path().join("Cargo.toml")).expect_err("negative workspace value must error"); let rendered = err.to_string(); assert!(rendered.contains("workspace"), "rendered: {rendered}"); assert!(rendered.contains("-1"), "rendered: {rendered}"); @@ -340,7 +483,7 @@ members = ["alpha"] min-lines-percent = "ninety" "#; write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); - let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("string threshold must error"); + let err = load(&tmp.path().join("Cargo.toml")).expect_err("string threshold must error"); assert!(err.to_string().contains("must be a number")); } @@ -373,7 +516,7 @@ edition = "2021" ("gamma", &member("gamma", None)), ], ); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed"); + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); let alpha = ws.members.iter().find(|m| m.name == "alpha").expect("alpha"); let beta = ws.members.iter().find(|m| m.name == "beta").expect("beta"); let gamma = ws.members.iter().find(|m| m.name == "gamma").expect("gamma"); @@ -400,7 +543,7 @@ edition = "2021" ("gamma", &member("gamma", None)), ], ); - let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("conflicting keys must error"); + let err = load(&tmp.path().join("Cargo.toml")).expect_err("conflicting keys must error"); let rendered = err.to_string(); assert!(rendered.contains("alpha"), "rendered: {rendered}"); assert!(rendered.contains("cannot set both"), "rendered: {rendered}"); @@ -419,7 +562,7 @@ members = ["alpha"] expect-no-coverable-lines = true "#; write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); - let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("workspace-scoped assertion must error"); + let err = load(&tmp.path().join("Cargo.toml")).expect_err("workspace-scoped assertion must error"); let rendered = err.to_string(); assert!(rendered.contains("package-level"), "rendered: {rendered}"); assert!(rendered.contains("expect-no-coverable-lines"), "rendered: {rendered}"); @@ -438,7 +581,7 @@ expect-no-coverable-lines = true ("gamma", &member("gamma", None)), ], ); - let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("non-boolean value must error"); + let err = load(&tmp.path().join("Cargo.toml")).expect_err("non-boolean value must error"); let rendered = err.to_string(); assert!(rendered.contains("must be a boolean"), "rendered: {rendered}"); } @@ -459,7 +602,104 @@ min-lines-percent = 80 expect-no-coverable-lines = false "#; write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed"); + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); assert_eq!(ws.default_min_lines_percent, Some(80.0)); } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn matching_cfg_policy_disables_package() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + let alpha = member_with_gate( + "alpha", + "min-lines-percent = 100\n\n[package.metadata.coverage-gate.target.'cfg(not(windows))']\nenabled = false", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); + let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); + assert!(alpha.coverage_disabled); + } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn exact_target_policy_wins_over_matching_cfg() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + let alpha = member_with_gate( + "alpha", + "min-lines-percent = 90\n\n\ + [package.metadata.coverage-gate.target.'cfg(windows)']\n\ + enabled = false\n\n\ + [package.metadata.coverage-gate.target.x86_64-pc-windows-msvc]\n\ + enabled = true", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + let target = TargetContext::from_parts( + "x86_64-pc-windows-msvc", + &["windows", "target_arch=\"x86_64\"", "target_os=\"windows\""], + ); + + let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml")), &target).expect("workspace load should succeed"); + let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); + assert!(!alpha.coverage_disabled); + assert_eq!(alpha.min_lines_percent, Some(90.0)); + } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn target_policy_replaces_base_threshold() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + let alpha = member_with_gate( + "alpha", + "min-lines-percent = 90\n\n[package.metadata.coverage-gate.target.'cfg(unix)']\nmin-lines-percent = 75", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + + let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); + let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); + assert_eq!(alpha.min_lines_percent, Some(75.0)); + assert!(!alpha.expect_no_coverable_lines); + } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn multiple_matching_cfg_policies_are_rejected() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + let alpha = member_with_gate( + "alpha", + "min-lines-percent = 90\n\n\ + [package.metadata.coverage-gate.target.'cfg(unix)']\n\ + enabled = false\n\n\ + [package.metadata.coverage-gate.target.'cfg(target_os = \"linux\")']\n\ + min-lines-percent = 75", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + + let error = load(&tmp.path().join("Cargo.toml")).expect_err("ambiguous cfg policies must fail"); + let rendered = error.to_string(); + assert!(rendered.contains("multiple coverage-gate target policies"), "rendered: {rendered}"); + assert!(rendered.contains("cfg(unix)"), "rendered: {rendered}"); + } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn target_policy_rejects_conflicting_enabled_and_threshold() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + let alpha = member_with_gate( + "alpha", + "min-lines-percent = 90\n\n\ + [package.metadata.coverage-gate.target.'cfg(unix)']\n\ + enabled = false\n\ + min-lines-percent = 0", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + + let error = load(&tmp.path().join("Cargo.toml")).expect_err("conflicting target policy must fail"); + assert!(error.to_string().contains("mutually exclusive")); + } } diff --git a/crates/cargo-coverage-gate/tests/cli.rs b/crates/cargo-coverage-gate/tests/cli.rs index 1800cb26..85bea947 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -284,6 +284,91 @@ fn conflicting_coverage_metadata_exits_2() { .stderr(predicate::str::contains("cannot set both")); } +#[test] +#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] +fn target_disabled_package_is_omitted_from_gate() { + let tmp = TempDir::new().expect("tempdir"); + make_workspace_with_gate( + tmp.path(), + &[ + ( + "alpha", + "min-lines-percent = 100\n\n\ + [package.metadata.coverage-gate.target.'cfg(not(windows))']\n\ + enabled = false", + ), + ("beta", "min-lines-percent = 80"), + ], + ); + let lcov_path = write_lcov(tmp.path(), &[("beta/src/lib.rs", 10, 9)]); + + coverage_gate(tmp.path()) + .args(["--target", "x86_64-unknown-linux-gnu", "--lcov", &lcov_path]) + .assert() + .success() + .stdout(predicate::str::contains("beta")) + .stdout(predicate::str::contains("alpha").not()); +} + +#[test] +#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] +fn print_test_only_packages_does_not_read_lcov() { + let tmp = TempDir::new().expect("tempdir"); + make_workspace_with_gate( + tmp.path(), + &[ + ( + "alpha", + "min-lines-percent = 100\n\n\ + [package.metadata.coverage-gate.target.'cfg(not(windows))']\n\ + enabled = false", + ), + ("beta", "expect-no-coverable-lines = true"), + ("gamma", "min-lines-percent = 0"), + ( + "delta", + "min-lines-percent = 100\n\n\ + [package.metadata.coverage-gate.target.'cfg(unix)']\n\ + min-lines-percent = 0", + ), + ], + ); + + coverage_gate(tmp.path()) + .args([ + "--target", + "x86_64-unknown-linux-gnu", + "--print-test-only-packages", + "--lcov", + "does-not-exist.info", + ]) + .assert() + .success() + .stdout("alpha\ndelta\ngamma\n"); +} + +#[test] +#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] +fn target_disabled_package_remains_gated_on_supported_target() { + let tmp = TempDir::new().expect("tempdir"); + make_workspace_with_gate( + tmp.path(), + &[( + "alpha", + "min-lines-percent = 100\n\n\ + [package.metadata.coverage-gate.target.'cfg(not(windows))']\n\ + enabled = false", + )], + ); + let empty_lcov = write_lcov(tmp.path(), &[]); + + coverage_gate(tmp.path()) + .args(["--target", "x86_64-pc-windows-msvc", "--lcov", &empty_lcov]) + .assert() + .code(2) + .stdout(predicate::str::contains("NO DATA")); +} + #[test] #[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] fn package_flag_restricts_scope() { From 6698fa74847159283a26a77052d2d2a2eafe1cb2 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Wed, 26 Aug 2026 10:42:37 +0200 Subject: [PATCH 02/12] chore: defer cargo-anvil adoption until release Keep the target-aware coverage-gate implementation publishable on its own. Cargo Anvil can consume version 0.4.0 after the crate has been released to crates.io. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-anvil/docs/design/checks.md | 2 +- .../justfiles/anvil/checks/llvm-cov.just | 46 +++++-------- .../templates/justfiles/anvil/versions.just | 2 +- crates/cargo-anvil/tests/recipe_contracts.rs | 64 +------------------ 4 files changed, 20 insertions(+), 94 deletions(-) diff --git a/crates/cargo-anvil/docs/design/checks.md b/crates/cargo-anvil/docs/design/checks.md index 0ca15310..69d2be0a 100644 --- a/crates/cargo-anvil/docs/design/checks.md +++ b/crates/cargo-anvil/docs/design/checks.md @@ -186,7 +186,7 @@ matrix overhead. | Check | Invocation | Source | |--------------|-----------------------------------------------------------------------------|--------| -| `llvm-cov` | Runs tests for every affected package under both feature configurations. Packages with an enabled positive coverage threshold run through self-contained `cargo + llvm-cov nextest --no-report` invocations and produce per-config lcov/cobertura reports. Packages declaring `min-lines-percent = 0`, plus packages disabled by a matching `[package.metadata.coverage-gate.target.'cfg(...)']` policy, still run through plain `cargo nextest`; the opt-out disables measurement and gating, never tests. Cargo Anvil asks cargo-coverage-gate to resolve target selectors so measurement and gating use one policy implementation. Per-config reports avoid Windows command-line overflow and are reconciled downstream by cargo-coverage-gate, Codecov, and ADO. Codecov is display-only; the local coverage gate is authoritative. | oxidizer, oxidizer-github; gate via [`cargo-coverage-gate`](../../../cargo-coverage-gate) | +| `llvm-cov` | Runs tests for every affected package under both feature configurations. Packages with a positive coverage threshold run through self-contained `cargo + llvm-cov nextest --no-report` invocations and produce per-config lcov/cobertura reports. Packages declaring `min-lines-percent = 0` still run through plain `cargo nextest`; the opt-out disables measurement and gating, never tests. Per-config reports avoid Windows command-line overflow and are reconciled downstream by cargo-coverage-gate, Codecov, and ADO. Codecov is display-only; the local coverage gate is authoritative. | oxidizer, oxidizer-github; gate via [`cargo-coverage-gate`](../../../cargo-coverage-gate) | | `doc-test` | Two cargo-test runs over the same affected set: `cargo test --doc --workspace --all-features --locked` and `cargo test --doc --workspace --locked` (default features). Running both catches doctests that only compile under one feature configuration (oxidizer-github runs both). nextest does not run doctests, so this stays a separate cargo-test invocation. | oxidizer, oxidizer-github | | `examples` | `cargo build --workspace --examples --all-features --locked` -- verifies that example targets compile. Running each example is intentionally not part of the check (examples are not test scaffolding; their runtime behavior isn't part of what we gate on). | oxidizer, oxidizer-github | diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just index 16d98bef..1e6098f0 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/llvm-cov.just @@ -30,37 +30,23 @@ anvil-llvm-cov: anvil-llvm-cov-validate-prereqs # and `llvm-cov export` aborts with "no coverage data found" whenever # such a package is the *only* object in the report (e.g. it is the # sole affected crate). Opt-out is declared via - # [package.metadata.coverage-gate] min-lines-percent = 0, or by a matching - # target policy with enabled = false. cargo-coverage-gate resolves target - # selectors so measurement and gating cannot disagree. Opted-out packages - # still run through plain nextest; opting out of measurement never opts a - # changed package out of tests. + # [package.metadata.coverage-gate] min-lines-percent = 0 -- the same + # marker cargo-coverage-gate already treats as NO DATA -> OK. Opted-out + # packages still run through plain nextest; opting out of measurement + # never opts a changed package out of tests. $testOnlyPkg = [System.Collections.Generic.List[string]]::new() - $metadataJson = & cargo metadata --no-deps --format-version 1 - if ($LASTEXITCODE -ne 0) { - Write-Error 'anvil-llvm-cov: cargo metadata failed' - exit $LASTEXITCODE - } - $metadata = $metadataJson | ConvertFrom-Json - $optedOut = @(& cargo coverage-gate --print-test-only-packages) - if ($LASTEXITCODE -ne 0) { - Write-Error 'anvil-llvm-cov: cargo coverage-gate target resolution failed' - exit $LASTEXITCODE - } - if ($optedOut.Count -gt 0) { - if ($pkg -contains '--workspace') { - $pkg = @( - $metadata.packages | - Where-Object { $optedOut -notcontains $_.name } | - ForEach-Object { '--package'; "$($_.name)@$($_.version)" } - ) - $metadata.packages | - Where-Object { $optedOut -contains $_.name } | - ForEach-Object { - $testOnlyPkg.Add('--package') - $testOnlyPkg.Add("$($_.name)@$($_.version)") - } - } elseif ($pkg -contains '--package') { + if ($pkg -contains '--package') { + $metadataJson = & cargo metadata --no-deps --format-version 1 + if ($LASTEXITCODE -ne 0) { + Write-Error 'anvil-llvm-cov: cargo metadata failed' + exit $LASTEXITCODE + } + $optedOut = @( + ($metadataJson | ConvertFrom-Json).packages | + Where-Object { $null -ne $_.metadata.'coverage-gate'.'min-lines-percent' -and [double]$_.metadata.'coverage-gate'.'min-lines-percent' -eq 0 } | + ForEach-Object { $_.name } + ) + if ($optedOut.Count -gt 0) { $filtered = [System.Collections.Generic.List[string]]::new() for ($i = 0; $i -lt $pkg.Count; $i++) { # Impact emits version-qualified specs (name@version); diff --git a/crates/cargo-anvil/templates/justfiles/anvil/versions.just b/crates/cargo-anvil/templates/justfiles/anvil/versions.just index 25cef735..564a3b90 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/versions.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/versions.just @@ -54,7 +54,7 @@ cargo_audit_version := "0.22.2" cargo_bolero_version := "0.13.4" cargo_careful_version := "0.4.10" cargo_check_external_types_version := "0.5.0" -cargo_coverage_gate_version := "0.4.0" +cargo_coverage_gate_version := "0.2.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.19.8" cargo_doc2readme_version := "0.7.2" diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 1d2a092c..52925110 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -50,13 +50,7 @@ if ($args -contains 'metadata') { manifest_path = $manifestPath targets = @([pscustomobject]@{ name = $libName; kind = @('lib') }) metadata = [pscustomobject]@{ - 'coverage-gate' = [pscustomobject]@{ - 'min-lines-percent' = if ($env:FAKE_MIN_LINES_PERCENT) { - [double]$env:FAKE_MIN_LINES_PERCENT - } else { - 0 - } - } + 'coverage-gate' = [pscustomobject]@{ 'min-lines-percent' = 0 } } } ) @@ -87,14 +81,6 @@ if ($args -contains 'semver-checks') { if ($env:FAKE_SEMVER_OUTPUT) { Write-Output $env:FAKE_SEMVER_OUTPUT } exit [int]$env:FAKE_SEMVER_EXIT } -if ($args -contains 'coverage-gate') { - if ($env:FAKE_DISABLED_PACKAGES) { - $env:FAKE_DISABLED_PACKAGES -split ',' | ForEach-Object { Write-Output $_ } - } elseif (-not $env:FAKE_MIN_LINES_PERCENT -or [double]$env:FAKE_MIN_LINES_PERCENT -eq 0) { - if ($env:FAKE_PACKAGE_NAME) { Write-Output $env:FAKE_PACKAGE_NAME } else { Write-Output 'fixture' } - } - exit [int]$env:FAKE_COVERAGE_GATE_EXIT -} if ($args -contains 'bolero' -and $args -contains 'list') { exit [int]$env:FAKE_BOLERO_LIST_EXIT } @@ -659,12 +645,7 @@ fn all_coverage_opted_out_packages_run_both_test_configurations() { assert!(calls.contains("--no-default-features"), "calls:\n{calls}"); assert_eq!(calls.matches("--no-tests=pass").count(), 2, "calls:\n{calls}"); assert!(!calls.contains("llvm-cov"), "coverage commands must not run:\n{calls}"); - assert_eq!( - calls.matches("coverage-gate").count(), - 1, - "only the metadata-only target-policy query may run:\n{calls}" - ); - assert!(calls.contains("coverage-gate --print-test-only-packages"), "calls:\n{calls}"); + assert!(!calls.contains("coverage-gate"), "the coverage gate must not run:\n{calls}"); let no_tests = run_just( tmp.path(), @@ -692,47 +673,6 @@ fn all_coverage_opted_out_packages_run_both_test_configurations() { assert_failed(&failed, "plain nextest failure for an opted-out package"); } -#[test] -fn target_disabled_packages_run_tests_without_coverage() { - if !tools_available() { - return; - } - let tmp = fixture( - &[("llvm-cov.just", LLVM_COV)], - &[ - "anvil-component-nightly-llvm-tools-validate-prereqs", - "anvil-tool-cargo-llvm-cov-validate-prereqs", - "anvil-tool-cargo-nextest-validate-prereqs", - "anvil-tool-cargo-coverage-gate-validate-prereqs", - "anvil-component-nightly-llvm-tools-install", - "anvil-tool-cargo-llvm-cov-install installer", - "anvil-tool-cargo-nextest-install installer", - "anvil-tool-cargo-coverage-gate-install installer", - ], - ); - let log = tmp.path().join("cargo.log"); - let output = run_just( - tmp.path(), - &["anvil-llvm-cov"], - &[ - ("ANVIL_INCLUDE_AFFECTED", OsStr::new("--package fixture@0.1.0")), - ("FAKE_MIN_LINES_PERCENT", OsStr::new("100")), - ("FAKE_DISABLED_PACKAGES", OsStr::new("fixture")), - ("FAKE_NEXTEST_EXIT", OsStr::new("0")), - ("FAKE_CARGO_LOG", log.as_os_str()), - ], - ); - assert!( - output.status.success(), - "target-disabled coverage path should succeed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - let calls = std::fs::read_to_string(&log).unwrap(); - assert_eq!(calls.matches("nextest run").count(), 2, "calls:\n{calls}"); - assert!(!calls.contains("llvm-cov nextest"), "coverage must not run:\n{calls}"); - assert!(calls.contains("coverage-gate --print-test-only-packages"), "calls:\n{calls}"); -} - #[cfg(windows)] #[test] fn windows_arm64_fallback_accepts_empty_nextest_sets_in_both_configurations() { From a20fc055be691cca7aefa59dc4907b8772a48aa8 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Wed, 26 Aug 2026 11:44:50 +0200 Subject: [PATCH 03/12] test(coverage-gate): cover target policy paths Exercise rustc target discovery failures and every target policy shape so coverage and mutation gates validate the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/src/target.rs | 97 ++++++++++++++++++++- crates/cargo-coverage-gate/src/workspace.rs | 83 ++++++++++++++++-- 2 files changed, 171 insertions(+), 9 deletions(-) diff --git a/crates/cargo-coverage-gate/src/target.rs b/crates/cargo-coverage-gate/src/target.rs index 1af74ba4..0ffde148 100644 --- a/crates/cargo-coverage-gate/src/target.rs +++ b/crates/cargo-coverage-gate/src/target.rs @@ -4,7 +4,7 @@ //! Active compilation-target discovery and Cargo-style selector matching. use std::env; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::process::Command; use std::str::FromStr; @@ -23,10 +23,14 @@ impl TargetContext { /// Resolve an explicit target, or the active rustc host when omitted. pub(crate) fn resolve(target: Option<&str>) -> Result { let rustc = env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + Self::resolve_with_rustc(target, &rustc) + } + + fn resolve_with_rustc(target: Option<&str>, rustc: &OsStr) -> Result { let triple = if let Some(target) = target { target.to_owned() } else { - let output = Command::new(&rustc) + let output = Command::new(rustc) .arg("-vV") .output() .map_err(|error| ResolveTargetError::new(format!("could not execute `{}`: {error}", rustc.to_string_lossy())))?; @@ -47,7 +51,7 @@ impl TargetContext { .ok_or_else(|| ResolveTargetError::new(format!("`{} -vV` did not report a host triple", rustc.to_string_lossy())))? }; - let output = Command::new(&rustc) + let output = Command::new(rustc) .args(["--print", "cfg", "--target", &triple]) .output() .map_err(|error| ResolveTargetError::new(format!("could not execute `{}`: {error}", rustc.to_string_lossy())))?; @@ -93,8 +97,54 @@ impl TargetContext { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { + use std::path::PathBuf; + use super::*; + fn fake_rustc(vv_stdout: &str, vv_exit: i32, cfg_stdout: &str, cfg_exit: i32) -> tempfile::TempDir { + let temp = tempfile::tempdir().expect("tempdir"); + let path = fake_rustc_path(&temp); + let vv_output = vv_stdout.lines().map(echo_line).collect::>().join("\n"); + let cfg_output = cfg_stdout.lines().map(echo_line).collect::>().join("\n"); + + #[cfg(windows)] + let script = format!("@echo off\nif \"%1\"==\"-vV\" (\n{vv_output}\nexit /b {vv_exit}\n)\n{cfg_output}\nexit /b {cfg_exit}\n"); + #[cfg(not(windows))] + let script = format!("#!/bin/sh\nif [ \"$1\" = \"-vV\" ]; then\n{vv_output}\nexit {vv_exit}\nfi\n{cfg_output}\nexit {cfg_exit}\n"); + std::fs::write(&path, script).expect("write fake rustc"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&path).expect("fake rustc metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("make fake rustc executable"); + } + + temp + } + + #[cfg(windows)] + fn echo_line(line: &str) -> String { + format!("echo {line}") + } + + #[cfg(not(windows))] + fn echo_line(line: &str) -> String { + format!("printf '%s\\n' '{line}'") + } + + fn fake_rustc_path(temp: &tempfile::TempDir) -> PathBuf { + #[cfg(windows)] + { + temp.path().join("rustc.cmd") + } + #[cfg(not(windows))] + { + temp.path().join("rustc") + } + } + #[test] fn matches_exact_and_cfg_selectors() { let target = TargetContext::from_parts( @@ -106,4 +156,45 @@ mod tests { assert!(target.matches(&Platform::from_str("cfg(target_os = \"windows\")").expect("target_os cfg"))); assert!(!target.matches(&Platform::from_str("cfg(unix)").expect("unix cfg"))); } + + #[test] + fn resolves_host_and_cfg_from_rustc() { + let temp = fake_rustc( + "rustc 1.97.0\nhost: x86_64-pc-windows-msvc", + 0, + "windows\ntarget_arch=\"x86_64\"\ntarget_os=\"windows\"", + 0, + ); + let target = TargetContext::resolve_with_rustc(None, fake_rustc_path(&temp).as_os_str()).expect("resolve fake host"); + + assert_eq!(target.triple, "x86_64-pc-windows-msvc"); + assert!(target.matches(&Platform::from_str("cfg(windows)").expect("windows cfg"))); + } + + #[test] + fn rejects_failed_or_malformed_rustc_output() { + let missing = fake_rustc_path(&tempfile::tempdir().expect("tempdir")); + let error = TargetContext::resolve_with_rustc(None, missing.as_os_str()).expect_err("missing rustc must fail"); + assert!(error.to_string().contains("failed to resolve")); + + let failed_host = fake_rustc("", 7, "", 0); + let error = + TargetContext::resolve_with_rustc(None, fake_rustc_path(&failed_host).as_os_str()).expect_err("failed host query must fail"); + assert!(error.to_string().contains("failed to resolve")); + + let missing_host = fake_rustc("rustc 1.97.0", 0, "", 0); + let error = + TargetContext::resolve_with_rustc(None, fake_rustc_path(&missing_host).as_os_str()).expect_err("missing host must fail"); + assert!(error.to_string().contains("failed to resolve")); + + let failed_cfg = fake_rustc("", 0, "", 8); + let error = TargetContext::resolve_with_rustc(Some("x86_64-unknown-linux-gnu"), fake_rustc_path(&failed_cfg).as_os_str()) + .expect_err("failed cfg query must fail"); + assert!(error.to_string().contains("failed to resolve")); + + let invalid_cfg = fake_rustc("", 0, "not a cfg", 0); + let error = TargetContext::resolve_with_rustc(Some("x86_64-unknown-linux-gnu"), fake_rustc_path(&invalid_cfg).as_os_str()) + .expect_err("invalid cfg must fail"); + assert!(error.to_string().contains("failed to resolve")); + } } diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index a53e35a3..eb8fde4c 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -226,12 +226,11 @@ fn extract_target_policies(gate: &Value, source: &str, scope: Scope) -> Result PolicyOverride::Disabled, - (Some(true) | None, None, false) => PolicyOverride::Inherit, - (None, Some(value), false) => PolicyOverride::Threshold(value), - (None, None, true) => PolicyOverride::ExpectNoCoverableLines, - _ => unreachable!("conflicting target policy combinations are rejected above"), + let policy = match enabled { + Some(false) => PolicyOverride::Disabled, + Some(true) => PolicyOverride::Inherit, + None if expect_no_coverable_lines => PolicyOverride::ExpectNoCoverableLines, + None => min_lines_percent.map_or(PolicyOverride::Inherit, PolicyOverride::Threshold), }; Ok(TargetPolicy { selector_text: selector_text.clone(), @@ -702,4 +701,76 @@ expect-no-coverable-lines = false let error = load(&tmp.path().join("Cargo.toml")).expect_err("conflicting target policy must fail"); assert!(error.to_string().contains("mutually exclusive")); } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn target_policy_can_expect_no_coverable_lines() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + let alpha = member_with_gate( + "alpha", + "min-lines-percent = 90\n\n\ + [package.metadata.coverage-gate.target.x86_64-pc-windows-msvc]\n\ + expect-no-coverable-lines = true", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + let target = TargetContext::from_parts("x86_64-pc-windows-msvc", &["windows"]); + + let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml")), &target).expect("workspace load should succeed"); + let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); + assert_eq!(alpha.min_lines_percent, None); + assert!(alpha.expect_no_coverable_lines); + assert!(!alpha.coverage_disabled); + } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn rejects_target_policy_at_workspace_scope() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = r#" +[workspace] +resolver = "2" +members = ["alpha"] + +[workspace.metadata.coverage-gate.target.'cfg(unix)'] +enabled = false +"#; + write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); + + let error = load(&tmp.path().join("Cargo.toml")).expect_err("workspace target policy must fail"); + assert!(error.to_string().contains("package-scoped")); + } + + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn rejects_malformed_target_policy_shapes() { + let cases = [ + ("target = false", "`target` must be a table"), + ( + "[package.metadata.coverage-gate.target]\n'cfg(unix)' = false", + "policy must be a table", + ), + ( + "[package.metadata.coverage-gate.target.'not a selector']\nenabled = false", + "invalid coverage-gate target selector", + ), + ( + "[package.metadata.coverage-gate.target.'cfg(unix)']\nenabled = \"no\"", + "`enabled` must be a boolean", + ), + ( + "[package.metadata.coverage-gate.target.'cfg(unix)']\nmin-lines-percent = 90\nexpect-no-coverable-lines = true", + "cannot set both", + ), + ]; + + for (index, (gate, expected)) in cases.into_iter().enumerate() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + write_workspace(tmp.path(), root, &[("alpha", &member_with_gate("alpha", gate))]); + + let error = load(&tmp.path().join("Cargo.toml")).expect_err("malformed target policy must fail"); + assert!(error.to_string().contains(expected), "case {index}: {error}"); + } + } } From 5fe6b19bc1bed6f2f9e2261ccf1ba8f7e2a9dbdc Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Wed, 26 Aug 2026 12:38:54 +0200 Subject: [PATCH 04/12] test(coverage-gate): skip process tests under Miri The rustc discovery tests require filesystem and process access, both forbidden by Miri isolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/src/target.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cargo-coverage-gate/src/target.rs b/crates/cargo-coverage-gate/src/target.rs index 0ffde148..35f41bc1 100644 --- a/crates/cargo-coverage-gate/src/target.rs +++ b/crates/cargo-coverage-gate/src/target.rs @@ -158,6 +158,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore = "uses filesystem and spawns a fake rustc process; miri isolation forbids both")] fn resolves_host_and_cfg_from_rustc() { let temp = fake_rustc( "rustc 1.97.0\nhost: x86_64-pc-windows-msvc", @@ -172,6 +173,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore = "uses filesystem and spawns fake rustc processes; miri isolation forbids both")] fn rejects_failed_or_malformed_rustc_output() { let missing = fake_rustc_path(&tempfile::tempdir().expect("tempdir")); let error = TargetContext::resolve_with_rustc(None, missing.as_os_str()).expect_err("missing rustc must fail"); From 007006213990dad627ae40b5435cd752bdbce77c Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Wed, 26 Aug 2026 20:09:59 +0200 Subject: [PATCH 05/12] refactor(coverage-gate): reuse thresholds for target opt-outs Remove the redundant target enabled flag. Target policies now use the existing min-lines-percent and expect-no-coverable-lines keys exclusively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/README.md | 10 +-- .../cargo-coverage-gate/docs/design/README.md | 31 ++++---- crates/cargo-coverage-gate/src/attribute.rs | 1 - .../src/bin/cargo-coverage-gate/cli.rs | 2 +- crates/cargo-coverage-gate/src/lib.rs | 16 ++--- crates/cargo-coverage-gate/src/threshold.rs | 1 - crates/cargo-coverage-gate/src/verdict.rs | 7 +- crates/cargo-coverage-gate/src/workspace.rs | 72 +++++++------------ crates/cargo-coverage-gate/tests/cli.rs | 14 ++-- 9 files changed, 64 insertions(+), 90 deletions(-) diff --git a/crates/cargo-coverage-gate/README.md b/crates/cargo-coverage-gate/README.md index bdcaf889..494eccc3 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -49,12 +49,12 @@ A package can replace that policy for a Cargo-style target selector: min-lines-percent = 100 [package.metadata.coverage-gate.target.'cfg(not(windows))'] -enabled = false +min-lines-percent = 0 ``` -`enabled = false` disables coverage measurement and gating on the -matching target, but does not disable test execution in automation -such as Cargo Anvil. +A zero target-specific threshold disables coverage measurement and +gating on the matching target, but does not disable test execution +in automation such as Cargo Anvil. ### Why lcov, not the JSON? @@ -120,7 +120,7 @@ plus the appropriate exit code. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbNxCFhIlcaZ4bsa602UxpyRQbgFr106dXJHcbhevnlfEoou9hZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbLvZZTPMnX1MbqhSMPfjasNcbUGTGpUxEN_4bq9cT9Euzu55hZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__link0]: https://github.com/taiki-e/cargo-llvm-cov [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/struct.EvaluatedReport.html diff --git a/crates/cargo-coverage-gate/docs/design/README.md b/crates/cargo-coverage-gate/docs/design/README.md index db3b5c7d..bfc9e7b9 100644 --- a/crates/cargo-coverage-gate/docs/design/README.md +++ b/crates/cargo-coverage-gate/docs/design/README.md @@ -234,7 +234,7 @@ Cargo-style target selectors nested under their package metadata: min-lines-percent = 100 [package.metadata.coverage-gate.target.'cfg(not(windows))'] -enabled = false +min-lines-percent = 0 ``` Selectors use the same grammar as Cargo's target-specific dependency tables: @@ -245,19 +245,17 @@ expression (`cfg(windows)`, `cfg(target_os = "linux")`, `rustc --print cfg --target `, so it does not maintain a second target language. -`enabled = false` disables coverage measurement and gating for that package on -the matching target. It does not disable tests: orchestrators run the package -through their non-instrumented test path. This is distinct from +`min-lines-percent = 0` disables coverage measurement and gating for that +package on the matching target. It does not disable tests: orchestrators run +the package through their non-instrumented test path. This is distinct from `expect-no-coverable-lines = true`, which keeps a target-independent facade or re-export package in the instrumented test set so its tests can contribute coverage to other packages. -Target tables may alternatively replace the base policy with -`min-lines-percent` or `expect-no-coverable-lines`. A target table describes -one complete policy; `enabled = false`, `min-lines-percent`, and -`expect-no-coverable-lines = true` are mutually exclusive in that table. -`enabled = true` explicitly inherits the base package/workspace policy and is -useful when an exact target needs to override a broader `cfg(...)` opt-out. +Target tables replace the base policy with either `min-lines-percent` or +`expect-no-coverable-lines = true`. The two are mutually exclusive. An exact +target that overrides a broader `cfg(...)` opt-out repeats its positive +threshold. Resolution follows Cargo's precedence: @@ -270,9 +268,9 @@ Resolution follows Cargo's precedence: The CLI accepts `--target `. When omitted, it obtains the host triple from `rustc -vV`. A dedicated `--print-test-only-packages` mode loads metadata -and prints packages whose effective policy is `min-lines-percent = 0` or -`enabled = false`, without reading lcov. Coverage orchestrators use this before -selecting packages for instrumentation. +and prints packages whose effective policy is `min-lines-percent = 0`, without +reading lcov. Coverage orchestrators use this before selecting packages for +instrumentation. ### 5.4 The verdict table @@ -429,9 +427,10 @@ state and classifies as a pass (`EMPTY` / `➖`), not the no-data configuration error. Conversely, if such a package *does* have attributed coverable lines, it fails the gate (exit `1`) rather than passing. -A package disabled by its target-specific policy (§5.3) is removed from the gated set -before attribution. It therefore neither produces a table row nor triggers the -no-data error on an unsupported target. +A package whose effective target policy sets `min-lines-percent = 0` passes, +including when it has no attributed data. Coverage orchestrators use +`--print-test-only-packages` to remove it from instrumentation while still +running its tests through a plain test runner. ### 6.4 Cross-package test attribution diff --git a/crates/cargo-coverage-gate/src/attribute.rs b/crates/cargo-coverage-gate/src/attribute.rs index e27e9b1f..37efc121 100644 --- a/crates/cargo-coverage-gate/src/attribute.rs +++ b/crates/cargo-coverage-gate/src/attribute.rs @@ -70,7 +70,6 @@ mod tests { manifest_dir: PathBuf::from(manifest_dir), min_lines_percent: None, expect_no_coverable_lines: false, - coverage_disabled: false, } } diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs index 887cd90d..eb0e9954 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs @@ -54,7 +54,7 @@ pub(crate) struct CoverageGateArgs { /// Print packages that should run tests without coverage and exit. /// /// Includes packages whose effective policy has - /// `min-lines-percent = 0` or `enabled = false`. + /// `min-lines-percent = 0`. #[arg(long)] pub(crate) print_test_only_packages: bool, diff --git a/crates/cargo-coverage-gate/src/lib.rs b/crates/cargo-coverage-gate/src/lib.rs index 12f736c2..73c00e8b 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -40,12 +40,12 @@ //! min-lines-percent = 100 //! //! [package.metadata.coverage-gate.target.'cfg(not(windows))'] -//! enabled = false +//! min-lines-percent = 0 //! ``` //! -//! `enabled = false` disables coverage measurement and gating on the -//! matching target, but does not disable test execution in automation -//! such as Cargo Anvil. +//! A zero target-specific threshold disables coverage measurement and +//! gating on the matching target, but does not disable test execution +//! in automation such as Cargo Anvil. //! //! ## Why lcov, not the JSON? //! @@ -280,9 +280,9 @@ pub fn evaluate_many_for_target( /// Return packages that should run tests without coverage for `target`. /// /// This metadata-only query is intended for coverage automation that -/// must remove packages with `min-lines-percent = 0` or -/// `enabled = false` from instrumentation while still running their tests -/// through a plain test runner. +/// must remove packages with an effective `min-lines-percent = 0` from +/// instrumentation while still running their tests through a plain test +/// runner. /// /// # Errors /// @@ -298,7 +298,7 @@ pub fn test_only_packages( let selected = verdict::resolve_gated(&ws, packages)?; Ok(selected .into_iter() - .filter(|member| member.coverage_disabled || member.min_lines_percent == Some(0.0)) + .filter(|member| member.min_lines_percent == Some(0.0)) .map(|member| member.name.clone()) .collect()) } diff --git a/crates/cargo-coverage-gate/src/threshold.rs b/crates/cargo-coverage-gate/src/threshold.rs index e09f793c..70db4051 100644 --- a/crates/cargo-coverage-gate/src/threshold.rs +++ b/crates/cargo-coverage-gate/src/threshold.rs @@ -91,7 +91,6 @@ mod tests { manifest_dir: PathBuf::from(format!("/repo/crates/{name}")), min_lines_percent, expect_no_coverable_lines: false, - coverage_disabled: false, } } diff --git a/crates/cargo-coverage-gate/src/verdict.rs b/crates/cargo-coverage-gate/src/verdict.rs index 9f2d94b0..833322cf 100644 --- a/crates/cargo-coverage-gate/src/verdict.rs +++ b/crates/cargo-coverage-gate/src/verdict.rs @@ -104,10 +104,7 @@ impl Report { /// glob pattern (`*` and `?`). A literal that matches no workspace /// member, or a glob that matches none, produces a [`CoverageGateError`]. pub(crate) fn evaluate(report: &CoverageReport, workspace: &Workspace, gated_packages: &[String]) -> Result { - let gated: Vec<&Member> = resolve_gated(workspace, gated_packages)? - .into_iter() - .filter(|member| !member.coverage_disabled) - .collect(); + let gated = resolve_gated(workspace, gated_packages)?; let AttributionOutcome { by_member, unattributed } = attribute(&report.files, &workspace.members); @@ -300,7 +297,6 @@ mod tests { manifest_dir: PathBuf::from(manifest_dir), min_lines_percent, expect_no_coverable_lines: false, - coverage_disabled: false, } } @@ -310,7 +306,6 @@ mod tests { manifest_dir: PathBuf::from(manifest_dir), min_lines_percent: None, expect_no_coverable_lines: true, - coverage_disabled: false, } } diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index eb8fde4c..78c8e0ec 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -54,8 +54,6 @@ pub(crate) struct Member { /// and fails (as a regression) if coverable lines appear. Mutually /// exclusive with [`Member::min_lines_percent`]. pub(crate) expect_no_coverable_lines: bool, - /// A matching target policy disabled coverage measurement and gating. - pub(crate) coverage_disabled: bool, } impl Workspace { @@ -91,13 +89,12 @@ impl Workspace { .as_std_path() .to_path_buf(); let mut gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?; - let coverage_disabled = apply_target_policy(&mut gate, target, &pkg.name)?; + apply_target_policy(&mut gate, target, &pkg.name)?; Ok::(Member { name: pkg.name.to_string(), manifest_dir, min_lines_percent: gate.min_lines_percent, expect_no_coverable_lines: gate.expect_no_coverable_lines, - coverage_disabled, }) }) .collect::, _>>()?; @@ -143,8 +140,6 @@ struct TargetPolicy { #[derive(Debug, Clone, Copy, PartialEq)] enum PolicyOverride { - Inherit, - Disabled, Threshold(f64), ExpectNoCoverableLines, } @@ -201,7 +196,7 @@ fn extract_target_policies(gate: &Value, source: &str, scope: Scope) -> Result Result Some(*value), - Some(value) => { - return Err(InvalidTargetPolicyError::new(policy_source, format!("`enabled` must be a boolean, got {value}")).into()); - } - None => None, - }; - if enabled.is_some() && (min_lines_percent.is_some() || expect_no_coverable_lines) { + let policy = if expect_no_coverable_lines { + PolicyOverride::ExpectNoCoverableLines + } else if let Some(value) = min_lines_percent { + PolicyOverride::Threshold(value) + } else { return Err(InvalidTargetPolicyError::new( policy_source, - "`enabled`, `min-lines-percent`, and `expect-no-coverable-lines = true` are mutually exclusive".to_owned(), + "policy must set `min-lines-percent` or `expect-no-coverable-lines = true`".to_owned(), ) .into()); - } - - let policy = match enabled { - Some(false) => PolicyOverride::Disabled, - Some(true) => PolicyOverride::Inherit, - None if expect_no_coverable_lines => PolicyOverride::ExpectNoCoverableLines, - None => min_lines_percent.map_or(PolicyOverride::Inherit, PolicyOverride::Threshold), }; Ok(TargetPolicy { selector_text: selector_text.clone(), @@ -241,7 +226,7 @@ fn extract_target_policies(gate: &Value, source: &str, scope: Scope) -> Result Result { +fn apply_target_policy(metadata: &mut CoverageGateMetadata, target: &TargetContext, source: &str) -> Result<(), CoverageGateError> { let exact = metadata .target_policies .iter() @@ -266,17 +251,16 @@ fn apply_target_policy(metadata: &mut CoverageGateMetadata, target: &TargetConte }; match selected.map(|policy| policy.policy) { - None | Some(PolicyOverride::Inherit) => Ok(false), - Some(PolicyOverride::Disabled) => Ok(true), + None => Ok(()), Some(PolicyOverride::Threshold(value)) => { metadata.min_lines_percent = Some(value); metadata.expect_no_coverable_lines = false; - Ok(false) + Ok(()) } Some(PolicyOverride::ExpectNoCoverableLines) => { metadata.min_lines_percent = None; metadata.expect_no_coverable_lines = true; - Ok(false) + Ok(()) } } } @@ -607,18 +591,18 @@ expect-no-coverable-lines = false #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - fn matching_cfg_policy_disables_package() { + fn matching_cfg_policy_opts_package_out_with_zero_threshold() { let tmp = tempfile::tempdir().expect("tempdir"); let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; let alpha = member_with_gate( "alpha", - "min-lines-percent = 100\n\n[package.metadata.coverage-gate.target.'cfg(not(windows))']\nenabled = false", + "min-lines-percent = 100\n\n[package.metadata.coverage-gate.target.'cfg(not(windows))']\nmin-lines-percent = 0", ); write_workspace(tmp.path(), root, &[("alpha", &alpha)]); let ws = load(&tmp.path().join("Cargo.toml")).expect("workspace load should succeed"); let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); - assert!(alpha.coverage_disabled); + assert_eq!(alpha.min_lines_percent, Some(0.0)); } #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] @@ -630,9 +614,9 @@ expect-no-coverable-lines = false "alpha", "min-lines-percent = 90\n\n\ [package.metadata.coverage-gate.target.'cfg(windows)']\n\ - enabled = false\n\n\ + min-lines-percent = 0\n\n\ [package.metadata.coverage-gate.target.x86_64-pc-windows-msvc]\n\ - enabled = true", + min-lines-percent = 90", ); write_workspace(tmp.path(), root, &[("alpha", &alpha)]); let target = TargetContext::from_parts( @@ -642,7 +626,6 @@ expect-no-coverable-lines = false let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml")), &target).expect("workspace load should succeed"); let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); - assert!(!alpha.coverage_disabled); assert_eq!(alpha.min_lines_percent, Some(90.0)); } @@ -672,7 +655,7 @@ expect-no-coverable-lines = false "alpha", "min-lines-percent = 90\n\n\ [package.metadata.coverage-gate.target.'cfg(unix)']\n\ - enabled = false\n\n\ + min-lines-percent = 0\n\n\ [package.metadata.coverage-gate.target.'cfg(target_os = \"linux\")']\n\ min-lines-percent = 75", ); @@ -686,20 +669,18 @@ expect-no-coverable-lines = false #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - fn target_policy_rejects_conflicting_enabled_and_threshold() { + fn target_policy_rejects_missing_policy_value() { let tmp = tempfile::tempdir().expect("tempdir"); let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; let alpha = member_with_gate( "alpha", "min-lines-percent = 90\n\n\ - [package.metadata.coverage-gate.target.'cfg(unix)']\n\ - enabled = false\n\ - min-lines-percent = 0", + [package.metadata.coverage-gate.target.'cfg(unix)']", ); write_workspace(tmp.path(), root, &[("alpha", &alpha)]); - let error = load(&tmp.path().join("Cargo.toml")).expect_err("conflicting target policy must fail"); - assert!(error.to_string().contains("mutually exclusive")); + let error = load(&tmp.path().join("Cargo.toml")).expect_err("empty target policy must fail"); + assert!(error.to_string().contains("policy must set")); } #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] @@ -720,7 +701,6 @@ expect-no-coverable-lines = false let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); assert_eq!(alpha.min_lines_percent, None); assert!(alpha.expect_no_coverable_lines); - assert!(!alpha.coverage_disabled); } #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] @@ -733,7 +713,7 @@ resolver = "2" members = ["alpha"] [workspace.metadata.coverage-gate.target.'cfg(unix)'] -enabled = false +min-lines-percent = 0 "#; write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); @@ -751,12 +731,12 @@ enabled = false "policy must be a table", ), ( - "[package.metadata.coverage-gate.target.'not a selector']\nenabled = false", + "[package.metadata.coverage-gate.target.'not a selector']\nmin-lines-percent = 0", "invalid coverage-gate target selector", ), ( - "[package.metadata.coverage-gate.target.'cfg(unix)']\nenabled = \"no\"", - "`enabled` must be a boolean", + "[package.metadata.coverage-gate.target.'cfg(unix)']\nunknown = false", + "policy must set", ), ( "[package.metadata.coverage-gate.target.'cfg(unix)']\nmin-lines-percent = 90\nexpect-no-coverable-lines = true", diff --git a/crates/cargo-coverage-gate/tests/cli.rs b/crates/cargo-coverage-gate/tests/cli.rs index 85bea947..0ace0e84 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -286,7 +286,7 @@ fn conflicting_coverage_metadata_exits_2() { #[test] #[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] -fn target_disabled_package_is_omitted_from_gate() { +fn target_zero_threshold_opts_package_out_of_gate() { let tmp = TempDir::new().expect("tempdir"); make_workspace_with_gate( tmp.path(), @@ -295,7 +295,7 @@ fn target_disabled_package_is_omitted_from_gate() { "alpha", "min-lines-percent = 100\n\n\ [package.metadata.coverage-gate.target.'cfg(not(windows))']\n\ - enabled = false", + min-lines-percent = 0", ), ("beta", "min-lines-percent = 80"), ], @@ -307,7 +307,9 @@ fn target_disabled_package_is_omitted_from_gate() { .assert() .success() .stdout(predicate::str::contains("beta")) - .stdout(predicate::str::contains("alpha").not()); + .stdout(predicate::str::contains("alpha")) + .stdout(predicate::str::contains("(no data)")) + .stdout(predicate::str::contains("0.0%")); } #[test] @@ -321,7 +323,7 @@ fn print_test_only_packages_does_not_read_lcov() { "alpha", "min-lines-percent = 100\n\n\ [package.metadata.coverage-gate.target.'cfg(not(windows))']\n\ - enabled = false", + min-lines-percent = 0", ), ("beta", "expect-no-coverable-lines = true"), ("gamma", "min-lines-percent = 0"), @@ -349,7 +351,7 @@ fn print_test_only_packages_does_not_read_lcov() { #[test] #[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] -fn target_disabled_package_remains_gated_on_supported_target() { +fn target_zero_threshold_package_remains_gated_on_supported_target() { let tmp = TempDir::new().expect("tempdir"); make_workspace_with_gate( tmp.path(), @@ -357,7 +359,7 @@ fn target_disabled_package_remains_gated_on_supported_target() { "alpha", "min-lines-percent = 100\n\n\ [package.metadata.coverage-gate.target.'cfg(not(windows))']\n\ - enabled = false", + min-lines-percent = 0", )], ); let empty_lcov = write_lcov(tmp.path(), &[]); From 4783e5a6b7aea7c4d1c2ac31104f87c9336ec0df Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Thu, 27 Aug 2026 09:44:06 +0200 Subject: [PATCH 06/12] docs(coverage-gate): document configuration capabilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/README.md | 104 +++++++++++++++++++------- crates/cargo-coverage-gate/src/lib.rs | 87 ++++++++++++++++----- 2 files changed, 148 insertions(+), 43 deletions(-) diff --git a/crates/cargo-coverage-gate/README.md b/crates/cargo-coverage-gate/README.md index 494eccc3..4af28cc7 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -22,7 +22,23 @@ coverage lcov tracefile, resolves each package’s threshold from a small three-layer lookup, and emits a verdict table to stdout (and, optionally, to a Markdown summary file for CI step summaries). -### Threshold resolution +### Configuration + +#### Numeric thresholds + +A workspace can define the default line-coverage threshold: + +```toml +[workspace.metadata.coverage-gate] +min-lines-percent = 80 +``` + +Individual packages can override it: + +```toml +[package.metadata.coverage-gate] +min-lines-percent = 95 +``` For each workspace member, the effective threshold is the first match among: @@ -34,13 +50,26 @@ among: 1. The built-in default of `100.0` — full coverage required. Setting `min-lines-percent = 0.0` explicitly opts a package out of -gating (it always passes, regardless of attributed data). A package -that legitimately contains no coverable lines (pure re-exports, type -definitions, a thin binary shim) instead declares -`expect-no-coverable-lines = true`: the gate passes only while that -holds and fails — as a regression — if coverable lines later appear. -The two keys are mutually exclusive, and `expect-no-coverable-lines` -is package-scoped only. +gating: it always passes, regardless of attributed data. Thresholds must +be in the inclusive range `0.0..=100.0`. + +#### Packages with no coverable lines + +A package that legitimately contains no coverable lines (pure re-exports, +type definitions, or a thin binary shim) can make that invariant explicit: + +```toml +[package.metadata.coverage-gate] +expect-no-coverable-lines = true +``` + +The gate passes only while the package has no attributed coverable lines +and fails as a regression if coverable code later appears. This differs +from `min-lines-percent = 0`, which keeps passing if the package grows +coverable code. The two keys are mutually exclusive, and +`expect-no-coverable-lines` is package-scoped only. + +#### Target-specific policies A package can replace that policy for a Cargo-style target selector: @@ -50,11 +79,29 @@ min-lines-percent = 100 [package.metadata.coverage-gate.target.'cfg(not(windows))'] min-lines-percent = 0 + +[package.metadata.coverage-gate.target.x86_64-pc-windows-msvc] +min-lines-percent = 100 +``` + +A target-specific no-coverable-lines assertion uses the same nesting: + +```toml +[package.metadata.coverage-gate.target.thumbv7em-none-eabihf] +expect-no-coverable-lines = true ``` -A zero target-specific threshold disables coverage measurement and -gating on the matching target, but does not disable test execution -in automation such as Cargo Anvil. +Target keys accept exact Rust target triples or quoted `cfg(...)` +expressions using Cargo’s target grammar. A target table sets either +`min-lines-percent` or `expect-no-coverable-lines = true`, replacing the +package’s base policy for that target. Exact triples take precedence over +matching `cfg(...)` expressions. Multiple matching cfg policies are a +configuration error rather than depending on declaration order. + +A zero target-specific threshold disables coverage measurement and gating +on the matching target, but does not disable test execution. Coverage +automation can call `cargo coverage-gate --print-test-only-packages --target ` or [`test_only_packages`][__link1] to +identify packages that should run through a non-instrumented test path. ### Why lcov, not the JSON? @@ -75,6 +122,7 @@ Codecov / ADO numbers confusing. ```text cargo coverage-gate [--lcov ]... [-p|--package ]... [--target ] + [--print-test-only-packages] [--summary-file ] [--quiet] ``` @@ -106,13 +154,15 @@ let code = report.verdict().as_exit_code(); ### Public API -The library exposes [`evaluate`][__link1], which returns an -[`EvaluatedReport`][__link2]. The report can be rendered as plain text via -[`EvaluatedReport::render_text`][__link3] or as GitHub-flavored Markdown -via [`EvaluatedReport::render_markdown`][__link4], and reduced to a single -[`Verdict`][__link5] via [`EvaluatedReport::verdict`][__link6]. The accompanying -binary loads the lcov tracefile from disk and orchestrates rendering -plus the appropriate exit code. +[`evaluate`][__link2] gates one lcov tracefile for the host target, while +[`evaluate_many`][__link3] merges multiple tracefiles at line level. +[`evaluate_many_for_target`][__link4] evaluates an explicit target triple, and +[`test_only_packages`][__link5] performs the metadata-only package query described +above. Evaluation returns an [`EvaluatedReport`][__link6], which renders as plain +text via [`EvaluatedReport::render_text`][__link7] or GitHub-flavored Markdown via +[`EvaluatedReport::render_markdown`][__link8] and reduces to a [`Verdict`][__link9] via +[`EvaluatedReport::verdict`][__link10]. The accompanying binary loads tracefiles from +disk and orchestrates rendering plus the appropriate exit code.
@@ -120,11 +170,15 @@ plus the appropriate exit code. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbLvZZTPMnX1MbqhSMPfjasNcbUGTGpUxEN_4bq9cT9Euzu55hZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbgT7C_MCtrmQbNek6QRgrLXcbevBPURuBRDAbnaR9T4OA_sZhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__link0]: https://github.com/taiki-e/cargo-llvm-cov - [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html - [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/struct.EvaluatedReport.html - [__link3]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_text - [__link4]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_markdown - [__link5]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/enum.Verdict.html - [__link6]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::verdict + [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.test_only_packages.html + [__link10]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::verdict + [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html + [__link3]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many.html + [__link4]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many_for_target.html + [__link5]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.test_only_packages.html + [__link6]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/struct.EvaluatedReport.html + [__link7]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_text + [__link8]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_markdown + [__link9]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/enum.Verdict.html diff --git a/crates/cargo-coverage-gate/src/lib.rs b/crates/cargo-coverage-gate/src/lib.rs index 73c00e8b..134db323 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -13,7 +13,23 @@ //! three-layer lookup, and emits a verdict table to stdout (and, //! optionally, to a Markdown summary file for CI step summaries). //! -//! ## Threshold resolution +//! ## Configuration +//! +//! ### Numeric thresholds +//! +//! A workspace can define the default line-coverage threshold: +//! +//! ```toml +//! [workspace.metadata.coverage-gate] +//! min-lines-percent = 80 +//! ``` +//! +//! Individual packages can override it: +//! +//! ```toml +//! [package.metadata.coverage-gate] +//! min-lines-percent = 95 +//! ``` //! //! For each workspace member, the effective threshold is the first match //! among: @@ -25,13 +41,26 @@ //! 3. The built-in default of `100.0` — full coverage required. //! //! Setting `min-lines-percent = 0.0` explicitly opts a package out of -//! gating (it always passes, regardless of attributed data). A package -//! that legitimately contains no coverable lines (pure re-exports, type -//! definitions, a thin binary shim) instead declares -//! `expect-no-coverable-lines = true`: the gate passes only while that -//! holds and fails — as a regression — if coverable lines later appear. -//! The two keys are mutually exclusive, and `expect-no-coverable-lines` -//! is package-scoped only. +//! gating: it always passes, regardless of attributed data. Thresholds must +//! be in the inclusive range `0.0..=100.0`. +//! +//! ### Packages with no coverable lines +//! +//! A package that legitimately contains no coverable lines (pure re-exports, +//! type definitions, or a thin binary shim) can make that invariant explicit: +//! +//! ```toml +//! [package.metadata.coverage-gate] +//! expect-no-coverable-lines = true +//! ``` +//! +//! The gate passes only while the package has no attributed coverable lines +//! and fails as a regression if coverable code later appears. This differs +//! from `min-lines-percent = 0`, which keeps passing if the package grows +//! coverable code. The two keys are mutually exclusive, and +//! `expect-no-coverable-lines` is package-scoped only. +//! +//! ### Target-specific policies //! //! A package can replace that policy for a Cargo-style target selector: //! @@ -41,11 +70,30 @@ //! //! [package.metadata.coverage-gate.target.'cfg(not(windows))'] //! min-lines-percent = 0 +//! +//! [package.metadata.coverage-gate.target.x86_64-pc-windows-msvc] +//! min-lines-percent = 100 +//! ``` +//! +//! A target-specific no-coverable-lines assertion uses the same nesting: +//! +//! ```toml +//! [package.metadata.coverage-gate.target.thumbv7em-none-eabihf] +//! expect-no-coverable-lines = true //! ``` //! -//! A zero target-specific threshold disables coverage measurement and -//! gating on the matching target, but does not disable test execution -//! in automation such as Cargo Anvil. +//! Target keys accept exact Rust target triples or quoted `cfg(...)` +//! expressions using Cargo's target grammar. A target table sets either +//! `min-lines-percent` or `expect-no-coverable-lines = true`, replacing the +//! package's base policy for that target. Exact triples take precedence over +//! matching `cfg(...)` expressions. Multiple matching cfg policies are a +//! configuration error rather than depending on declaration order. +//! +//! A zero target-specific threshold disables coverage measurement and gating +//! on the matching target, but does not disable test execution. Coverage +//! automation can call `cargo coverage-gate +//! --print-test-only-packages --target ` or [`test_only_packages`] to +//! identify packages that should run through a non-instrumented test path. //! //! ## Why lcov, not the JSON? //! @@ -66,6 +114,7 @@ //! ```text //! cargo coverage-gate [--lcov ]... [-p|--package ]... //! [--target ] +//! [--print-test-only-packages] //! [--summary-file ] [--quiet] //! ``` //! @@ -99,13 +148,15 @@ //! //! ## Public API //! -//! The library exposes [`evaluate`], which returns an -//! [`EvaluatedReport`]. The report can be rendered as plain text via -//! [`EvaluatedReport::render_text`] or as GitHub-flavored Markdown -//! via [`EvaluatedReport::render_markdown`], and reduced to a single -//! [`Verdict`] via [`EvaluatedReport::verdict`]. The accompanying -//! binary loads the lcov tracefile from disk and orchestrates rendering -//! plus the appropriate exit code. +//! [`evaluate`] gates one lcov tracefile for the host target, while +//! [`evaluate_many`] merges multiple tracefiles at line level. +//! [`evaluate_many_for_target`] evaluates an explicit target triple, and +//! [`test_only_packages`] performs the metadata-only package query described +//! above. Evaluation returns an [`EvaluatedReport`], which renders as plain +//! text via [`EvaluatedReport::render_text`] or GitHub-flavored Markdown via +//! [`EvaluatedReport::render_markdown`] and reduces to a [`Verdict`] via +//! [`EvaluatedReport::verdict`]. The accompanying binary loads tracefiles from +//! disk and orchestrates rendering plus the appropriate exit code. //! //! [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov From a5f7f2f6cb299d598deac1a2053d58cdfae6dc8a Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Thu, 27 Aug 2026 10:42:20 +0200 Subject: [PATCH 07/12] fix(coverage-gate): resolve effective target policies lazily Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/README.md | 8 ++- .../cargo-coverage-gate/docs/design/README.md | 24 ++++++-- .../src/bin/cargo-coverage-gate/cli.rs | 3 +- crates/cargo-coverage-gate/src/lib.rs | 14 ++--- crates/cargo-coverage-gate/src/workspace.rs | 56 +++++++++++++++---- crates/cargo-coverage-gate/tests/cli.rs | 31 +++++++++- 6 files changed, 108 insertions(+), 28 deletions(-) diff --git a/crates/cargo-coverage-gate/README.md b/crates/cargo-coverage-gate/README.md index 4af28cc7..1f554f9b 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -98,10 +98,12 @@ package’s base policy for that target. Exact triples take precedence over matching `cfg(...)` expressions. Multiple matching cfg policies are a configuration error rather than depending on declaration order. -A zero target-specific threshold disables coverage measurement and gating -on the matching target, but does not disable test execution. Coverage +A zero target-specific threshold disables gating on the matching target, +but does not itself control test execution or instrumentation. Coverage automation can call `cargo coverage-gate --print-test-only-packages --target ` or [`test_only_packages`][__link1] to identify packages that should run through a non-instrumented test path. +The command prints one bare package name per line (without `@version`) and +exits successfully without reading lcov. ### Why lcov, not the JSON? @@ -170,7 +172,7 @@ disk and orchestrates rendering plus the appropriate exit code. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbgT7C_MCtrmQbNek6QRgrLXcbevBPURuBRDAbnaR9T4OA_sZhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbdKLP7PcbAPsbeHSPlXhUImobKq_kwe1zsH4bcq-jhiXZ7SNhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__link0]: https://github.com/taiki-e/cargo-llvm-cov [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.test_only_packages.html [__link10]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::verdict diff --git a/crates/cargo-coverage-gate/docs/design/README.md b/crates/cargo-coverage-gate/docs/design/README.md index bfc9e7b9..c189dc88 100644 --- a/crates/cargo-coverage-gate/docs/design/README.md +++ b/crates/cargo-coverage-gate/docs/design/README.md @@ -97,6 +97,7 @@ want to reproduce the gate locally. ```text cargo coverage-gate [--lcov ]... [-p ]... [--package ]... + [--target ] [--print-test-only-packages] [--summary-file ] [--quiet] ``` @@ -126,6 +127,13 @@ Flags: test-impact step so that impact-scoped runs only gate the packages whose tests actually ran. A selector that matches no member is a configuration error (exit 2). +- `--target ` — evaluate target-specific package policies for the + supplied Rust target. Defaults to the active rustc host target when target + policies exist; workspaces without target policies do not invoke rustc. +- `--print-test-only-packages` — print packages whose effective policy has + `min-lines-percent = 0`, one bare package name per line without `@version`, + then exit `0` without reading lcov. Coverage automation uses this output to + choose packages for a non-instrumented test path. - `--summary-file ` — write a Markdown verdict table to this file. When unset, the tool honors the environment variables `GITHUB_STEP_SUMMARY` (GitHub Actions) and @@ -245,9 +253,10 @@ expression (`cfg(windows)`, `cfg(target_os = "linux")`, `rustc --print cfg --target `, so it does not maintain a second target language. -`min-lines-percent = 0` disables coverage measurement and gating for that -package on the matching target. It does not disable tests: orchestrators run -the package through their non-instrumented test path. This is distinct from +`min-lines-percent = 0` disables gating for that package on the matching +target. It does not itself disable tests or coverage instrumentation: +orchestrators can use `--print-test-only-packages` to route the package through +a non-instrumented test path. This is distinct from `expect-no-coverable-lines = true`, which keeps a target-independent facade or re-export package in the instrumented test set so its tests can contribute coverage to other packages. @@ -268,9 +277,12 @@ Resolution follows Cargo's precedence: The CLI accepts `--target `. When omitted, it obtains the host triple from `rustc -vV`. A dedicated `--print-test-only-packages` mode loads metadata -and prints packages whose effective policy is `min-lines-percent = 0`, without -reading lcov. Coverage orchestrators use this before selecting packages for -instrumentation. +and prints packages whose effective policy is `min-lines-percent = 0`, one bare +package name per line without `@version`, then exits `0` without reading lcov. +Coverage orchestrators can use this output before selecting packages for +instrumentation. Target discovery is lazy: if no package declares target +policies, evaluation does not invoke rustc and preserves the pre-target-policy +runtime and error surface. ### 5.4 The verdict table diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs index eb0e9954..dc4ef55a 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs @@ -54,7 +54,8 @@ pub(crate) struct CoverageGateArgs { /// Print packages that should run tests without coverage and exit. /// /// Includes packages whose effective policy has - /// `min-lines-percent = 0`. + /// `min-lines-percent = 0`. Prints one bare package name per line, + /// without `@version`, and exits successfully without reading lcov. #[arg(long)] pub(crate) print_test_only_packages: bool, diff --git a/crates/cargo-coverage-gate/src/lib.rs b/crates/cargo-coverage-gate/src/lib.rs index 134db323..39945043 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -89,11 +89,13 @@ //! matching `cfg(...)` expressions. Multiple matching cfg policies are a //! configuration error rather than depending on declaration order. //! -//! A zero target-specific threshold disables coverage measurement and gating -//! on the matching target, but does not disable test execution. Coverage +//! A zero target-specific threshold disables gating on the matching target, +//! but does not itself control test execution or instrumentation. Coverage //! automation can call `cargo coverage-gate //! --print-test-only-packages --target ` or [`test_only_packages`] to //! identify packages that should run through a non-instrumented test path. +//! The command prints one bare package name per line (without `@version`) and +//! exits successfully without reading lcov. //! //! ## Why lcov, not the JSON? //! @@ -322,8 +324,7 @@ pub fn evaluate_many_for_target( target: Option<&str>, ) -> Result { let report = lcov_cov::CoverageReport::from_strs(lcov_texts)?; - let target = target::TargetContext::resolve(target)?; - let ws = workspace::Workspace::load(manifest_path, &target)?; + let ws = workspace::Workspace::load(manifest_path, target)?; let inner = verdict::evaluate(&report, &ws, gated_packages)?; Ok(EvaluatedReport { inner }) } @@ -344,12 +345,11 @@ pub fn test_only_packages( packages: &[String], target: Option<&str>, ) -> Result, CoverageGateError> { - let target = target::TargetContext::resolve(target)?; - let ws = workspace::Workspace::load(manifest_path, &target)?; + let ws = workspace::Workspace::load(manifest_path, target)?; let selected = verdict::resolve_gated(&ws, packages)?; Ok(selected .into_iter() - .filter(|member| member.min_lines_percent == Some(0.0)) + .filter(|member| !member.expect_no_coverable_lines && threshold::Threshold::resolve(member, &ws).min_lines_percent == 0.0) .map(|member| member.name.clone()) .collect()) } diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index 78c8e0ec..d96ffb66 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -64,7 +64,14 @@ impl Workspace { /// Runs `cargo metadata --no-deps`, which does not fetch or build /// dependencies and is therefore fast and side-effect-free. #[ohno::enrich_err("failed to load cargo workspace metadata")] - pub(crate) fn load(manifest_path: Option<&Path>, target: &TargetContext) -> Result { + pub(crate) fn load(manifest_path: Option<&Path>, target: Option<&str>) -> Result { + Self::load_with_target_resolver(manifest_path, || TargetContext::resolve(target)) + } + + fn load_with_target_resolver( + manifest_path: Option<&Path>, + resolve_target: impl FnOnce() -> Result, + ) -> Result { let mut cmd = MetadataCommand::new(); cmd.no_deps(); if let Some(path) = manifest_path { @@ -78,7 +85,7 @@ impl Workspace { // omitting the key). let workspace_default = extract_coverage_gate(&metadata.workspace_metadata, "workspace", Scope::Workspace)?.min_lines_percent; - let mut members: Vec = metadata + let unresolved_members = metadata .workspace_packages() .iter() .map(|pkg| { @@ -88,16 +95,30 @@ impl Workspace { .expect("cargo-metadata always reports a manifest file path with a parent directory") .as_std_path() .to_path_buf(); - let mut gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?; - apply_target_policy(&mut gate, target, &pkg.name)?; - Ok::(Member { - name: pkg.name.to_string(), + let gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?; + Ok::<_, CoverageGateError>((pkg.name.to_string(), manifest_dir, gate)) + }) + .collect::, _>>()?; + + let target = unresolved_members + .iter() + .any(|(_, _, gate)| !gate.target_policies.is_empty()) + .then(resolve_target) + .transpose()?; + let mut members = unresolved_members + .into_iter() + .map(|(name, manifest_dir, mut gate)| { + if let Some(target) = &target { + apply_target_policy(&mut gate, target, &name)?; + } + Ok(Member { + name, manifest_dir, min_lines_percent: gate.min_lines_percent, expect_no_coverable_lines: gate.expect_no_coverable_lines, }) }) - .collect::, _>>()?; + .collect::, CoverageGateError>>()?; members.sort_by(|a, b| a.name.cmp(&b.name)); Ok(Self { @@ -316,7 +337,7 @@ mod tests { } fn load(manifest_path: &Path) -> Result { - Workspace::load(Some(manifest_path), &test_target()) + Workspace::load_with_target_resolver(Some(manifest_path), || Ok(test_target())) } /// Write a minimal workspace with the given root `Cargo.toml` body @@ -385,6 +406,19 @@ edition = "2021" } } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] + #[test] + fn does_not_resolve_target_without_target_policies() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = "[workspace]\nresolver = \"2\"\nmembers = [\"alpha\"]\n"; + write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]); + + let ws = Workspace::load_with_target_resolver(Some(&tmp.path().join("Cargo.toml")), || panic!("target resolution must stay lazy")) + .expect("workspace without target policies should load"); + + assert_eq!(ws.members.len(), 1); + } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] fn picks_up_workspace_level_default() { @@ -624,7 +658,8 @@ expect-no-coverable-lines = false &["windows", "target_arch=\"x86_64\"", "target_os=\"windows\""], ); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml")), &target).expect("workspace load should succeed"); + let ws = Workspace::load_with_target_resolver(Some(&tmp.path().join("Cargo.toml")), || Ok(target)) + .expect("workspace load should succeed"); let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); assert_eq!(alpha.min_lines_percent, Some(90.0)); } @@ -697,7 +732,8 @@ expect-no-coverable-lines = false write_workspace(tmp.path(), root, &[("alpha", &alpha)]); let target = TargetContext::from_parts("x86_64-pc-windows-msvc", &["windows"]); - let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml")), &target).expect("workspace load should succeed"); + let ws = Workspace::load_with_target_resolver(Some(&tmp.path().join("Cargo.toml")), || Ok(target)) + .expect("workspace load should succeed"); let alpha = ws.members.iter().find(|member| member.name == "alpha").expect("alpha"); assert_eq!(alpha.min_lines_percent, None); assert!(alpha.expect_no_coverable_lines); diff --git a/crates/cargo-coverage-gate/tests/cli.rs b/crates/cargo-coverage-gate/tests/cli.rs index 0ace0e84..616be4b8 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -202,10 +202,17 @@ fn gated_crate_with_no_data_exits_2() { /// `[package.metadata.coverage-gate]` body. Each entry is /// `(name, gate_body)`; an empty `gate_body` omits the block entirely. fn make_workspace_with_gate(dir: &Path, members: &[(&str, &str)]) { + make_workspace_with_gate_and_default(dir, members, None); +} + +fn make_workspace_with_gate_and_default(dir: &Path, members: &[(&str, &str)], workspace_min_lines_percent: Option<&str>) { let members_list = members.iter().map(|(n, _)| format!("\"{n}\"")).collect::>().join(", "); + let workspace_meta = workspace_min_lines_percent + .map(|m| format!("\n[workspace.metadata.coverage-gate]\nmin-lines-percent = {m}\n")) + .unwrap_or_default(); fs::write( dir.join("Cargo.toml"), - format!("[workspace]\nresolver = \"2\"\nmembers = [{members_list}]\n"), + format!("[workspace]\nresolver = \"2\"\nmembers = [{members_list}]\n{workspace_meta}"), ) .expect("write workspace root Cargo.toml"); @@ -349,6 +356,28 @@ fn print_test_only_packages_does_not_read_lcov() { .stdout("alpha\ndelta\ngamma\n"); } +#[test] +#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] +fn print_test_only_packages_includes_workspace_zero_threshold() { + let tmp = TempDir::new().expect("tempdir"); + make_workspace_with_gate_and_default( + tmp.path(), + &[ + ("alpha", ""), + ("beta", "expect-no-coverable-lines = true"), + ("gamma", "min-lines-percent = 80"), + ], + Some("0"), + ); + + coverage_gate(tmp.path()) + .args(["--print-test-only-packages", "--lcov", "does-not-exist.info"]) + .env("RUSTC", tmp.path().join("missing-rustc")) + .assert() + .success() + .stdout("alpha\n"); +} + #[test] #[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] fn target_zero_threshold_package_remains_gated_on_supported_target() { From ab5a27e3213e58269bae816edae1c51fca9bd20d Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 14:27:34 +0200 Subject: [PATCH 08/12] fix(coverage-gate): preserve cross-package coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/README.md | 47 ++++++++-------- .../cargo-coverage-gate/docs/design/README.md | 43 ++++++++------- .../src/bin/cargo-coverage-gate/cli.rs | 8 --- .../src/bin/cargo-coverage-gate/run.rs | 9 ---- crates/cargo-coverage-gate/src/lib.rs | 53 +++++-------------- crates/cargo-coverage-gate/tests/cli.rs | 53 +++---------------- 6 files changed, 63 insertions(+), 150 deletions(-) diff --git a/crates/cargo-coverage-gate/README.md b/crates/cargo-coverage-gate/README.md index 1f554f9b..477af76f 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -99,11 +99,12 @@ matching `cfg(...)` expressions. Multiple matching cfg policies are a configuration error rather than depending on declaration order. A zero target-specific threshold disables gating on the matching target, -but does not itself control test execution or instrumentation. Coverage -automation can call `cargo coverage-gate --print-test-only-packages --target ` or [`test_only_packages`][__link1] to -identify packages that should run through a non-instrumented test path. -The command prints one bare package name per line (without `@version`) and -exits successfully without reading lcov. +but does not disable test execution or instrumentation. Those test binaries +remain instrumented because they may contribute coverage to other packages. +If cargo-llvm-cov reports that an instrumented run produced no coverage +data, automation can supply an empty lcov tracefile: zero-threshold and +`expect-no-coverable-lines` packages pass, while positively gated packages +report `NO DATA`. ### Why lcov, not the JSON? @@ -124,7 +125,6 @@ Codecov / ADO numbers confusing. ```text cargo coverage-gate [--lcov ]... [-p|--package ]... [--target ] - [--print-test-only-packages] [--summary-file ] [--quiet] ``` @@ -156,14 +156,13 @@ let code = report.verdict().as_exit_code(); ### Public API -[`evaluate`][__link2] gates one lcov tracefile for the host target, while -[`evaluate_many`][__link3] merges multiple tracefiles at line level. -[`evaluate_many_for_target`][__link4] evaluates an explicit target triple, and -[`test_only_packages`][__link5] performs the metadata-only package query described -above. Evaluation returns an [`EvaluatedReport`][__link6], which renders as plain -text via [`EvaluatedReport::render_text`][__link7] or GitHub-flavored Markdown via -[`EvaluatedReport::render_markdown`][__link8] and reduces to a [`Verdict`][__link9] via -[`EvaluatedReport::verdict`][__link10]. The accompanying binary loads tracefiles from +[`evaluate`][__link1] gates one lcov tracefile for the host target, while +[`evaluate_many`][__link2] merges multiple tracefiles at line level. +[`evaluate_many_for_target`][__link3] evaluates an explicit target triple. +Evaluation returns an [`EvaluatedReport`][__link4], which renders as plain +text via [`EvaluatedReport::render_text`][__link5] or GitHub-flavored Markdown via +[`EvaluatedReport::render_markdown`][__link6] and reduces to a [`Verdict`][__link7] via +[`EvaluatedReport::verdict`][__link8]. The accompanying binary loads tracefiles from disk and orchestrates rendering plus the appropriate exit code. @@ -172,15 +171,13 @@ disk and orchestrates rendering plus the appropriate exit code. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbdKLP7PcbAPsbeHSPlXhUImobKq_kwe1zsH4bcq-jhiXZ7SNhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbcvaYALYk9GcbLDI6G2DLxr0bYVksFz6RuJcbUYA_Oup1xJdhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__link0]: https://github.com/taiki-e/cargo-llvm-cov - [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.test_only_packages.html - [__link10]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::verdict - [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html - [__link3]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many.html - [__link4]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many_for_target.html - [__link5]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.test_only_packages.html - [__link6]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/struct.EvaluatedReport.html - [__link7]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_text - [__link8]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_markdown - [__link9]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/enum.Verdict.html + [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html + [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many.html + [__link3]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many_for_target.html + [__link4]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/struct.EvaluatedReport.html + [__link5]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_text + [__link6]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::render_markdown + [__link7]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/enum.Verdict.html + [__link8]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/?search=EvaluatedReport::verdict diff --git a/crates/cargo-coverage-gate/docs/design/README.md b/crates/cargo-coverage-gate/docs/design/README.md index c189dc88..26357522 100644 --- a/crates/cargo-coverage-gate/docs/design/README.md +++ b/crates/cargo-coverage-gate/docs/design/README.md @@ -97,7 +97,7 @@ want to reproduce the gate locally. ```text cargo coverage-gate [--lcov ]... [-p ]... [--package ]... - [--target ] [--print-test-only-packages] + [--target ] [--summary-file ] [--quiet] ``` @@ -130,10 +130,6 @@ Flags: - `--target ` — evaluate target-specific package policies for the supplied Rust target. Defaults to the active rustc host target when target policies exist; workspaces without target policies do not invoke rustc. -- `--print-test-only-packages` — print packages whose effective policy has - `min-lines-percent = 0`, one bare package name per line without `@version`, - then exit `0` without reading lcov. Coverage automation uses this output to - choose packages for a non-instrumented test path. - `--summary-file ` — write a Markdown verdict table to this file. When unset, the tool honors the environment variables `GITHUB_STEP_SUMMARY` (GitHub Actions) and @@ -254,12 +250,11 @@ expression (`cfg(windows)`, `cfg(target_os = "linux")`, language. `min-lines-percent = 0` disables gating for that package on the matching -target. It does not itself disable tests or coverage instrumentation: -orchestrators can use `--print-test-only-packages` to route the package through -a non-instrumented test path. This is distinct from -`expect-no-coverable-lines = true`, which keeps a target-independent facade or -re-export package in the instrumented test set so its tests can contribute -coverage to other packages. +target. It does not disable tests or coverage instrumentation. Test binaries +from zero-threshold packages remain part of the instrumented run because they +may contribute coverage to other workspace packages. This is distinct from +`expect-no-coverable-lines = true`, which asserts that the selected package +itself owns no coverable lines. Target tables replace the base policy with either `min-lines-percent` or `expect-no-coverable-lines = true`. The two are mutually exclusive. An exact @@ -276,11 +271,7 @@ Resolution follows Cargo's precedence: policy applies. The CLI accepts `--target `. When omitted, it obtains the host triple -from `rustc -vV`. A dedicated `--print-test-only-packages` mode loads metadata -and prints packages whose effective policy is `min-lines-percent = 0`, one bare -package name per line without `@version`, then exits `0` without reading lcov. -Coverage orchestrators can use this output before selecting packages for -instrumentation. Target discovery is lazy: if no package declares target +from `rustc -vV`. Target discovery is lazy: if no package declares target policies, evaluation does not invoke rustc and preserves the pre-target-policy runtime and error surface. @@ -440,9 +431,17 @@ configuration error. Conversely, if such a package *does* have attributed coverable lines, it fails the gate (exit `1`) rather than passing. A package whose effective target policy sets `min-lines-percent = 0` passes, -including when it has no attributed data. Coverage orchestrators use -`--print-test-only-packages` to remove it from instrumentation while still -running its tests through a plain test runner. +including when it has no attributed data. Its tests remain instrumented so +they can contribute coverage to other packages. + +An empty lcov tracefile is valid input. Each in-scope package is classified +from its effective policy: zero-threshold and `expect-no-coverable-lines` +packages pass, while a package with a positive threshold reports `NO DATA` +and makes the result a configuration error. This lets an orchestrator recover +from cargo-llvm-cov's "no coverage data found" export outcome by supplying an +empty tracefile to the gate. It must not remove test binaries from +instrumentation preemptively; if those tests produce coverage for another +package, the normal export succeeds and that coverage remains visible. ### 6.4 Cross-package test attribution @@ -671,9 +670,9 @@ file) and `DA:` (line count) records; everything else is ignored, so new record types added by future cargo-llvm-cov releases will not break parsing. -If a tracefile contains no `SF:` sections — empty file, or a corrupted -upload — the tool exits with a configuration error (no files attributed -to any package). Structural parse errors (e.g., malformed `DA:` +If a tracefile contains no `SF:` sections, every in-scope package is evaluated +as having no attributed data. The effective package policies determine the +result as described in §6.3. Structural parse errors (e.g., malformed `DA:` records) are hard errors with exit code 2. #### Tooling requirements diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs index dc4ef55a..46dcb3c0 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs @@ -51,14 +51,6 @@ pub(crate) struct CoverageGateArgs { #[arg(long, value_name = "TRIPLE")] pub(crate) target: Option, - /// Print packages that should run tests without coverage and exit. - /// - /// Includes packages whose effective policy has - /// `min-lines-percent = 0`. Prints one bare package name per line, - /// without `@version`, and exits successfully without reading lcov. - #[arg(long)] - pub(crate) print_test_only_packages: bool, - /// Write the Markdown verdict table to this file. /// /// When unset, the tool falls back to `$GITHUB_STEP_SUMMARY` and then diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs index 3dd39978..0a1b350d 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/run.rs @@ -15,15 +15,6 @@ use ohno::{AppError, IntoAppError}; use crate::cli::CoverageGateArgs; pub(crate) fn run(args: &CoverageGateArgs) -> Result { - if args.print_test_only_packages { - let packages = cargo_coverage_gate::test_only_packages(None, &args.packages, args.target.as_deref()) - .into_app_err("failed to resolve test-only packages")?; - for package in packages { - println!("{package}"); - } - return Ok(ExitCode::SUCCESS); - } - let lcov_paths: Vec = if args.lcov.is_empty() { vec![PathBuf::from("target/coverage/lcov.info")] } else { diff --git a/crates/cargo-coverage-gate/src/lib.rs b/crates/cargo-coverage-gate/src/lib.rs index 39945043..8f8b704f 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -90,12 +90,12 @@ //! configuration error rather than depending on declaration order. //! //! A zero target-specific threshold disables gating on the matching target, -//! but does not itself control test execution or instrumentation. Coverage -//! automation can call `cargo coverage-gate -//! --print-test-only-packages --target ` or [`test_only_packages`] to -//! identify packages that should run through a non-instrumented test path. -//! The command prints one bare package name per line (without `@version`) and -//! exits successfully without reading lcov. +//! but does not disable test execution or instrumentation. Those test binaries +//! remain instrumented because they may contribute coverage to other packages. +//! If cargo-llvm-cov reports that an instrumented run produced no coverage +//! data, automation can supply an empty lcov tracefile: zero-threshold and +//! `expect-no-coverable-lines` packages pass, while positively gated packages +//! report `NO DATA`. //! //! ## Why lcov, not the JSON? //! @@ -116,7 +116,6 @@ //! ```text //! cargo coverage-gate [--lcov ]... [-p|--package ]... //! [--target ] -//! [--print-test-only-packages] //! [--summary-file ] [--quiet] //! ``` //! @@ -152,9 +151,8 @@ //! //! [`evaluate`] gates one lcov tracefile for the host target, while //! [`evaluate_many`] merges multiple tracefiles at line level. -//! [`evaluate_many_for_target`] evaluates an explicit target triple, and -//! [`test_only_packages`] performs the metadata-only package query described -//! above. Evaluation returns an [`EvaluatedReport`], which renders as plain +//! [`evaluate_many_for_target`] evaluates an explicit target triple. +//! Evaluation returns an [`EvaluatedReport`], which renders as plain //! text via [`EvaluatedReport::render_text`] or GitHub-flavored Markdown via //! [`EvaluatedReport::render_markdown`] and reduces to a [`Verdict`] via //! [`EvaluatedReport::verdict`]. The accompanying binary loads tracefiles from @@ -283,11 +281,11 @@ pub fn evaluate(lcov_text: &str, manifest_path: Option<&Path>, gated_packages: & /// counts summed, line sets combined), so passing the `--all-features` and /// `--no-default-features` exports yields the same per-package line /// coverage as a single merged report — without a platform-specific lcov -/// merger. An empty slice is treated as an empty report (every gated -/// package then reports NO DATA). NO DATA is not a passing outcome: -/// each such package classifies as `NoData`, which rolls the overall -/// result up to [`Verdict::ConfigError`] (process exit code 2), so an -/// empty slice never yields a successful verdict. +/// merger. An empty slice is treated as an empty report. Packages with +/// positive thresholds then report NO DATA, which rolls the overall result +/// up to [`Verdict::ConfigError`] (process exit code 2). Packages with zero +/// thresholds pass, while `expect-no-coverable-lines` packages report EMPTY +/// and pass. /// /// `gated_packages` restricts the operation to a named subset; when /// empty, every workspace member is in scope. @@ -329,31 +327,6 @@ pub fn evaluate_many_for_target( Ok(EvaluatedReport { inner }) } -/// Return packages that should run tests without coverage for `target`. -/// -/// This metadata-only query is intended for coverage automation that -/// must remove packages with an effective `min-lines-percent = 0` from -/// instrumentation while still running their tests through a plain test -/// runner. -/// -/// # Errors -/// -/// Returns a [`CoverageGateError`] when workspace metadata, target -/// discovery, target policy, or a package selector is invalid. -pub fn test_only_packages( - manifest_path: Option<&Path>, - packages: &[String], - target: Option<&str>, -) -> Result, CoverageGateError> { - let ws = workspace::Workspace::load(manifest_path, target)?; - let selected = verdict::resolve_gated(&ws, packages)?; - Ok(selected - .into_iter() - .filter(|member| !member.expect_no_coverable_lines && threshold::Threshold::resolve(member, &ws).min_lines_percent == 0.0) - .map(|member| member.name.clone()) - .collect()) -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { diff --git a/crates/cargo-coverage-gate/tests/cli.rs b/crates/cargo-coverage-gate/tests/cli.rs index 616be4b8..9d5eeb93 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -202,17 +202,10 @@ fn gated_crate_with_no_data_exits_2() { /// `[package.metadata.coverage-gate]` body. Each entry is /// `(name, gate_body)`; an empty `gate_body` omits the block entirely. fn make_workspace_with_gate(dir: &Path, members: &[(&str, &str)]) { - make_workspace_with_gate_and_default(dir, members, None); -} - -fn make_workspace_with_gate_and_default(dir: &Path, members: &[(&str, &str)], workspace_min_lines_percent: Option<&str>) { let members_list = members.iter().map(|(n, _)| format!("\"{n}\"")).collect::>().join(", "); - let workspace_meta = workspace_min_lines_percent - .map(|m| format!("\n[workspace.metadata.coverage-gate]\nmin-lines-percent = {m}\n")) - .unwrap_or_default(); fs::write( dir.join("Cargo.toml"), - format!("[workspace]\nresolver = \"2\"\nmembers = [{members_list}]\n{workspace_meta}"), + format!("[workspace]\nresolver = \"2\"\nmembers = [{members_list}]\n"), ) .expect("write workspace root Cargo.toml"); @@ -321,7 +314,7 @@ fn target_zero_threshold_opts_package_out_of_gate() { #[test] #[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] -fn print_test_only_packages_does_not_read_lcov() { +fn empty_lcov_passes_when_all_effective_policies_allow_no_data() { let tmp = TempDir::new().expect("tempdir"); make_workspace_with_gate( tmp.path(), @@ -333,49 +326,17 @@ fn print_test_only_packages_does_not_read_lcov() { min-lines-percent = 0", ), ("beta", "expect-no-coverable-lines = true"), - ("gamma", "min-lines-percent = 0"), - ( - "delta", - "min-lines-percent = 100\n\n\ - [package.metadata.coverage-gate.target.'cfg(unix)']\n\ - min-lines-percent = 0", - ), ], ); + let empty_lcov = write_lcov(tmp.path(), &[]); coverage_gate(tmp.path()) - .args([ - "--target", - "x86_64-unknown-linux-gnu", - "--print-test-only-packages", - "--lcov", - "does-not-exist.info", - ]) - .assert() - .success() - .stdout("alpha\ndelta\ngamma\n"); -} - -#[test] -#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] -fn print_test_only_packages_includes_workspace_zero_threshold() { - let tmp = TempDir::new().expect("tempdir"); - make_workspace_with_gate_and_default( - tmp.path(), - &[ - ("alpha", ""), - ("beta", "expect-no-coverable-lines = true"), - ("gamma", "min-lines-percent = 80"), - ], - Some("0"), - ); - - coverage_gate(tmp.path()) - .args(["--print-test-only-packages", "--lcov", "does-not-exist.info"]) - .env("RUSTC", tmp.path().join("missing-rustc")) + .args(["--target", "x86_64-unknown-linux-gnu", "--lcov", &empty_lcov]) .assert() .success() - .stdout("alpha\n"); + .stdout(predicate::str::contains("alpha")) + .stdout(predicate::str::contains("beta")) + .stdout(predicate::str::contains("all packages meet their threshold")); } #[test] From 313319a49c9a887427f16bf7781cc3b45bf617f2 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 14:44:20 +0200 Subject: [PATCH 09/12] refactor(coverage-gate): tighten target policy contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 5 +- crates/cargo-coverage-gate/README.md | 10 +- .../cargo-coverage-gate/docs/design/README.md | 31 +++-- .../src/bin/cargo-coverage-gate/cli.rs | 2 +- crates/cargo-coverage-gate/src/error.rs | 112 ++++++++++++++---- crates/cargo-coverage-gate/src/lib.rs | 26 ++-- crates/cargo-coverage-gate/src/target.rs | 78 ++++++------ crates/cargo-coverage-gate/src/workspace.rs | 101 ++++++++++++---- crates/cargo-coverage-gate/tests/cli.rs | 5 +- 9 files changed, 258 insertions(+), 112 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5ee476ec..127f15c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,6 @@ anyhow = { version = "1.0.100", default-features = false } assert_cmd = { version = "2.2.0", default-features = false } bytes = { version = "1.11.1", default-features = false } camino = { version = "1.2.5", default-features = false } -# local dependencies -cargo-aprz-lib = { path = "crates/cargo-aprz-lib", default-features = false, version = "1.1.0" } cargo-platform = { version = "0.3.3", default-features = false } cargo_metadata = { version = "0.23.1", default-features = false } cel-interpreter = { version = "0.10.0", default-features = false } @@ -96,6 +94,9 @@ windows = { version = "0.62.2", default-features = false } wiremock = { version = "0.6.5", default-features = false } zstd = { version = "0.13.3", default-features = false } +# local dependencies +cargo-aprz-lib = { path = "crates/cargo-aprz-lib", default-features = false, version = "1.1.0" } + # >>> anvil-managed: anvil-workspace-lints [workspace.lints] # Catalog of opinionated lints, in dotted-key form so users can extend the diff --git a/crates/cargo-coverage-gate/README.md b/crates/cargo-coverage-gate/README.md index 477af76f..f93997d1 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -29,6 +29,7 @@ optionally, to a Markdown summary file for CI step summaries). A workspace can define the default line-coverage threshold: ```toml +# Illustrative workspace policy. [workspace.metadata.coverage-gate] min-lines-percent = 80 ``` @@ -36,6 +37,7 @@ min-lines-percent = 80 Individual packages can override it: ```toml +# Illustrative package policy, intentionally stricter than the workspace. [package.metadata.coverage-gate] min-lines-percent = 95 ``` @@ -80,7 +82,7 @@ min-lines-percent = 100 [package.metadata.coverage-gate.target.'cfg(not(windows))'] min-lines-percent = 0 -[package.metadata.coverage-gate.target.x86_64-pc-windows-msvc] +[package.metadata.coverage-gate.target.x86_64-unknown-linux-gnu] min-lines-percent = 100 ``` @@ -156,9 +158,9 @@ let code = report.verdict().as_exit_code(); ### Public API -[`evaluate`][__link1] gates one lcov tracefile for the host target, while +[`evaluate`][__link1] gates one lcov tracefile for the rustc host target, while [`evaluate_many`][__link2] merges multiple tracefiles at line level. -[`evaluate_many_for_target`][__link3] evaluates an explicit target triple. +[`evaluate_many_for_target`][__link3] evaluates an explicit Rust target triple. Evaluation returns an [`EvaluatedReport`][__link4], which renders as plain text via [`EvaluatedReport::render_text`][__link5] or GitHub-flavored Markdown via [`EvaluatedReport::render_markdown`][__link6] and reduces to a [`Verdict`][__link7] via @@ -171,7 +173,7 @@ disk and orchestrates rendering plus the appropriate exit code. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbcvaYALYk9GcbLDI6G2DLxr0bYVksFz6RuJcbUYA_Oup1xJdhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbF3P3j2nLNgEbcLNXR38OFU8bmdHWagvGfrMbqUEVVagSr0lhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__link0]: https://github.com/taiki-e/cargo-llvm-cov [__link1]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate.html [__link2]: https://docs.rs/cargo-coverage-gate/0.4.0/cargo_coverage_gate/fn.evaluate_many.html diff --git a/crates/cargo-coverage-gate/docs/design/README.md b/crates/cargo-coverage-gate/docs/design/README.md index 26357522..a13e2cf8 100644 --- a/crates/cargo-coverage-gate/docs/design/README.md +++ b/crates/cargo-coverage-gate/docs/design/README.md @@ -128,7 +128,7 @@ Flags: whose tests actually ran. A selector that matches no member is a configuration error (exit 2). - `--target ` — evaluate target-specific package policies for the - supplied Rust target. Defaults to the active rustc host target when target + supplied Rust target. Defaults to the rustc host target when target policies exist; workspaces without target policies do not invoke rustc. - `--summary-file ` — write a Markdown verdict table to this file. When unset, the tool honors the environment variables @@ -247,7 +247,10 @@ expression (`cfg(windows)`, `cfg(target_os = "linux")`, `cfg(all(unix, target_arch = "x86_64"))`). Coverage-gate uses the `cargo-platform` parser and matches `cfg(...)` expressions against `rustc --print cfg --target `, so it does not maintain a second target -language. +language. As in Cargo target-specific dependency selection, selectors must +describe target properties. Build-context predicates such as `cfg(feature = +"...")`, `cfg(test)`, `cfg(debug_assertions)`, and `cfg(proc_macro)` are +configuration errors because a standalone target query cannot evaluate them. `min-lines-percent = 0` disables gating for that package on the matching target. It does not disable tests or coverage instrumentation. Test binaries @@ -270,10 +273,9 @@ Resolution follows Cargo's precedence: 4. With no matching target table, the ordinary package → workspace → built-in policy applies. -The CLI accepts `--target `. When omitted, it obtains the host triple -from `rustc -vV`. Target discovery is lazy: if no package declares target -policies, evaluation does not invoke rustc and preserves the pre-target-policy -runtime and error surface. +The CLI accepts `--target `. When omitted, it obtains the rustc host +target from `rustc -vV`. Rust target discovery is lazy: if no package declares +target policies, evaluation does not invoke rustc. ### 5.4 The verdict table @@ -647,12 +649,17 @@ comparison rounds to the same precision before comparing — see ### 10.2 Security -The tool reads `Cargo.toml` files and a coverage lcov tracefile. It never -writes; the only output channels are stdout and the optional summary -file. No network access, no privileged operations. The only subprocess -invocation is the read-only `cargo metadata` call performed by -`cargo_metadata::MetadataCommand::exec()` during workspace discovery -(used to enumerate workspace members and resolve the workspace root). +The tool reads `Cargo.toml` files and coverage lcov tracefiles. It never writes; +the only output channels are stdout and the optional summary file. It performs +no network access or privileged operations. + +Workspace discovery invokes the read-only `cargo metadata` command through +`cargo_metadata::MetadataCommand::exec()` to enumerate workspace members and +resolve the workspace root. When any package declares target-specific policy, +the tool also invokes the executable selected by `RUSTC` (or `rustc` when +unset). It runs `rustc -vV` only when it must discover the host target, then +runs `rustc --print cfg --target ` for both explicit and discovered +targets. Workspaces without target-specific policy do not invoke rustc. ### 10.3 Monorepo / multi-workspace diff --git a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs index 46dcb3c0..701d42d1 100644 --- a/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs +++ b/crates/cargo-coverage-gate/src/bin/cargo-coverage-gate/cli.rs @@ -47,7 +47,7 @@ pub(crate) struct CoverageGateArgs { /// Rust target triple whose coverage policy should be evaluated. /// - /// Defaults to the active rustc host target. + /// Defaults to the rustc host target. #[arg(long, value_name = "TRIPLE")] pub(crate) target: Option, diff --git a/crates/cargo-coverage-gate/src/error.rs b/crates/cargo-coverage-gate/src/error.rs index bdb6688c..dc092392 100644 --- a/crates/cargo-coverage-gate/src/error.rs +++ b/crates/cargo-coverage-gate/src/error.rs @@ -33,8 +33,12 @@ use serde_json::Value; ThresholdOutOfRangeError, InvalidNoCoverableLinesValueError, ConflictingCoverageMetadataError, - InvalidTargetPolicyError, + WorkspaceTargetPolicyError, + InvalidTargetTableError, + InvalidTargetPolicyShapeError, + MissingTargetPolicyBehaviorError, InvalidTargetSelectorError, + UnsupportedTargetSelectorError, AmbiguousTargetPolicyError, WorkspaceScopedNoCoverableLinesError, ResolveTargetError, @@ -106,42 +110,99 @@ pub(crate) struct ConflictingCoverageMetadataError { )] pub(crate) struct WorkspaceScopedNoCoverableLinesError; -/// A target policy was not an object or did not select exactly one -/// effective behavior. +/// Target policies were declared in workspace metadata. #[ohno::error] -#[display("{source}: invalid coverage-gate target policy: {detail}")] -pub(crate) struct InvalidTargetPolicyError { - pub source: String, - pub detail: String, +#[display("coverage-gate target policies are package-scoped and cannot be set in workspace metadata")] +pub(crate) struct WorkspaceTargetPolicyError; + +/// A target-policy container was not a table. +#[ohno::error] +#[display("{source}: coverage-gate `target` must be a table keyed by target triple or cfg expression")] +pub(crate) struct InvalidTargetTableError { + pub(crate) source: String, +} + +/// A selected target policy was not a table. +#[ohno::error] +#[display("{source}: coverage-gate target policy must be a table")] +pub(crate) struct InvalidTargetPolicyShapeError { + pub(crate) source: String, } -/// A target-policy table key was neither a valid target triple nor a -/// valid Cargo `cfg(...)` selector. +/// A target policy did not select an effective behavior. #[ohno::error] -#[display("{source}: invalid coverage-gate target selector `{selector}`: {detail}")] +#[display("{source}: target policy must set `min-lines-percent` or `expect-no-coverable-lines = true`")] +pub(crate) struct MissingTargetPolicyBehaviorError { + pub(crate) source: String, +} + +/// A target-policy selector was syntactically invalid. +#[ohno::error] +#[display("{source}: invalid coverage-gate target selector `{selector}`")] +#[from(cargo_platform::ParseError)] pub(crate) struct InvalidTargetSelectorError { - pub source: String, - pub selector: String, - pub detail: String, + pub(crate) source: String, + pub(crate) selector: String, } -/// More than one `cfg(...)` target policy matched the active target. +/// A target selector depends on Cargo build-unit context. +#[ohno::error] +#[display("{source}: coverage-gate target selector `{selector}` uses unsupported build-context cfg attributes: {attributes}")] +pub(crate) struct UnsupportedTargetSelectorError { + pub(crate) source: String, + pub(crate) selector: String, + pub(crate) attributes: String, +} + +/// More than one `cfg(...)` target policy matched the selected Rust target. #[ohno::error] #[display( "{source}: multiple coverage-gate target policies match `{target}`: {selectors}; \ - use disjoint cfg expressions or an exact target-triple override" + use disjoint cfg expressions or an exact Rust target-triple override" )] pub(crate) struct AmbiguousTargetPolicyError { - pub source: String, - pub target: String, - pub selectors: String, + pub(crate) source: String, + pub(crate) target: String, + pub(crate) selectors: String, } -/// The active Rust target or its cfg values could not be obtained. +/// The Rust target or its cfg values could not be obtained. #[ohno::error] -#[display("failed to resolve coverage target: {detail}")] -pub(crate) struct ResolveTargetError { - pub detail: String, +#[display("failed to resolve Rust target")] +#[from(ExecuteRustcError, RustcCommandFailedError, MissingRustcHostTargetError, InvalidRustcCfgError)] +pub(crate) struct ResolveTargetError; + +/// A rustc target-information command could not be launched. +#[ohno::error] +#[display("could not execute `{command}`")] +#[from(std::io::Error)] +pub(crate) struct ExecuteRustcError { + pub(crate) command: String, +} + +/// A rustc target-information command exited unsuccessfully. +#[ohno::error] +#[display("`{command}` exited with {status}: {stderr}")] +pub(crate) struct RustcCommandFailedError { + pub(crate) command: String, + pub(crate) status: String, + pub(crate) stderr: String, +} + +/// The rustc version output did not identify its host target. +#[ohno::error] +#[display("`{command}` did not report a host target")] +pub(crate) struct MissingRustcHostTargetError { + pub(crate) command: String, +} + +/// A cfg value emitted by rustc could not be parsed. +#[ohno::error] +#[display("rustc reported invalid cfg `{value}` for Rust target `{target}`")] +#[from(cargo_platform::ParseError)] +pub(crate) struct InvalidRustcCfgError { + pub(crate) value: String, + pub(crate) target: String, } /// An lcov tracefile was syntactically malformed. @@ -187,6 +248,13 @@ mod tests { assert!(rendered.contains("lcov tracefile")); } + #[test] + fn execute_rustc_error_preserves_io_source() { + let error = ExecuteRustcError::caused_by("rustc -vV".to_owned(), std::io::Error::other("launch failed")); + let source = std::error::Error::source(&error).expect("execute error must retain its IO source"); + assert_eq!(source.to_string(), "launch failed"); + } + #[test] fn unknown_package_selector_carries_pattern() { let err = UnknownPackageSelectorError::new("nope-*".to_owned()); diff --git a/crates/cargo-coverage-gate/src/lib.rs b/crates/cargo-coverage-gate/src/lib.rs index 8f8b704f..17bd2e8b 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -20,6 +20,7 @@ //! A workspace can define the default line-coverage threshold: //! //! ```toml +//! # Illustrative workspace policy. //! [workspace.metadata.coverage-gate] //! min-lines-percent = 80 //! ``` @@ -27,6 +28,7 @@ //! Individual packages can override it: //! //! ```toml +//! # Illustrative package policy, intentionally stricter than the workspace. //! [package.metadata.coverage-gate] //! min-lines-percent = 95 //! ``` @@ -71,7 +73,7 @@ //! [package.metadata.coverage-gate.target.'cfg(not(windows))'] //! min-lines-percent = 0 //! -//! [package.metadata.coverage-gate.target.x86_64-pc-windows-msvc] +//! [package.metadata.coverage-gate.target.x86_64-unknown-linux-gnu] //! min-lines-percent = 100 //! ``` //! @@ -149,9 +151,9 @@ //! //! ## Public API //! -//! [`evaluate`] gates one lcov tracefile for the host target, while +//! [`evaluate`] gates one lcov tracefile for the rustc host target, while //! [`evaluate_many`] merges multiple tracefiles at line level. -//! [`evaluate_many_for_target`] evaluates an explicit target triple. +//! [`evaluate_many_for_target`] evaluates an explicit Rust target triple. //! Evaluation returns an [`EvaluatedReport`], which renders as plain //! text via [`EvaluatedReport::render_text`] or GitHub-flavored Markdown via //! [`EvaluatedReport::render_markdown`] and reduces to a [`Verdict`] via @@ -263,10 +265,11 @@ impl EvaluatedReport { /// # Errors /// /// Returns a [`CoverageGateError`] when the tracefile does not parse, -/// workspace discovery fails, an unknown package appears in -/// `gated_packages`, or a configured `min-lines-percent` value is outside -/// `[0.0, 100.0]`. The error message identifies which case occurred; -/// callers usually just propagate it. +/// workspace discovery fails, a package selector is unknown, coverage +/// metadata or target policy is invalid or ambiguous, a configured threshold +/// is out of range, or required Rust target discovery or cfg queries fail. +/// The error message identifies which case occurred; callers usually just +/// propagate it. /// /// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov pub fn evaluate(lcov_text: &str, manifest_path: Option<&Path>, gated_packages: &[String]) -> Result { @@ -297,6 +300,7 @@ pub fn evaluate(lcov_text: &str, manifest_path: Option<&Path>, gated_packages: & /// the merge. /// /// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov +#[inline] pub fn evaluate_many( lcov_texts: &[&str], manifest_path: Option<&Path>, @@ -305,16 +309,18 @@ pub fn evaluate_many( evaluate_many_for_target(lcov_texts, manifest_path, gated_packages, None) } -/// Evaluate one or more lcov tracefiles for an explicit compilation target. +/// Evaluate one or more lcov tracefiles for an explicit Rust target. /// /// `target` is a Rust target triple such as `x86_64-pc-windows-msvc`. -/// When omitted, the active `rustc` host target is used. Target-specific +/// When omitted, the rustc host target is used. Target-specific /// package policy is resolved before the gated package set is evaluated. /// /// # Errors /// /// Returns a [`CoverageGateError`] under the same conditions as -/// [`evaluate_many`], or when the target/cfg query fails. +/// [`evaluate_many`]. When target policy requires Rust target resolution, +/// rustc launch, exit-status, host-output, and cfg-output failures are also +/// reported. pub fn evaluate_many_for_target( lcov_texts: &[&str], manifest_path: Option<&Path>, diff --git a/crates/cargo-coverage-gate/src/target.rs b/crates/cargo-coverage-gate/src/target.rs index 35f41bc1..f050f915 100644 --- a/crates/cargo-coverage-gate/src/target.rs +++ b/crates/cargo-coverage-gate/src/target.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Active compilation-target discovery and Cargo-style selector matching. +//! Rust target discovery and Cargo-style selector matching. use std::env; use std::ffi::{OsStr, OsString}; @@ -10,9 +10,10 @@ use std::str::FromStr; use cargo_platform::{Cfg, Platform}; -use crate::error::{CoverageGateError, ResolveTargetError}; +use crate::CoverageGateError; +use crate::error::{ExecuteRustcError, InvalidRustcCfgError, MissingRustcHostTargetError, ResolveTargetError, RustcCommandFailedError}; -/// The target triple and cfg values used to resolve target policy. +/// The Rust target triple and cfg values used to resolve target policy. #[derive(Debug, Clone)] pub(crate) struct TargetContext { pub(crate) triple: String, @@ -20,27 +21,28 @@ pub(crate) struct TargetContext { } impl TargetContext { - /// Resolve an explicit target, or the active rustc host when omitted. + /// Resolve an explicit Rust target, or the rustc host target when omitted. pub(crate) fn resolve(target: Option<&str>) -> Result { let rustc = env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); - Self::resolve_with_rustc(target, &rustc) + Self::resolve_with_rustc(target, &rustc).map_err(Into::into) } - fn resolve_with_rustc(target: Option<&str>, rustc: &OsStr) -> Result { + fn resolve_with_rustc(target: Option<&str>, rustc: &OsStr) -> Result { + let rustc_display = rustc.to_string_lossy(); let triple = if let Some(target) = target { target.to_owned() } else { + let command = format!("{rustc_display} -vV"); let output = Command::new(rustc) .arg("-vV") .output() - .map_err(|error| ResolveTargetError::new(format!("could not execute `{}`: {error}", rustc.to_string_lossy())))?; + .map_err(|error| ExecuteRustcError::caused_by(command.clone(), error))?; if !output.status.success() { - return Err(ResolveTargetError::new(format!( - "`{} -vV` exited with {}: {}", - rustc.to_string_lossy(), - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )) + return Err(RustcCommandFailedError::new( + command, + output.status.to_string(), + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + ) .into()); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -48,20 +50,20 @@ impl TargetContext { .lines() .find_map(|line| line.strip_prefix("host: ")) .map(str::to_owned) - .ok_or_else(|| ResolveTargetError::new(format!("`{} -vV` did not report a host triple", rustc.to_string_lossy())))? + .ok_or_else(|| MissingRustcHostTargetError::new(command))? }; + let command = format!("{rustc_display} --print cfg --target {triple}"); let output = Command::new(rustc) .args(["--print", "cfg", "--target", &triple]) .output() - .map_err(|error| ResolveTargetError::new(format!("could not execute `{}`: {error}", rustc.to_string_lossy())))?; + .map_err(|error| ExecuteRustcError::caused_by(command.clone(), error))?; if !output.status.success() { - return Err(ResolveTargetError::new(format!( - "`{} --print cfg --target {triple}` exited with {}: {}", - rustc.to_string_lossy(), - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )) + return Err(RustcCommandFailedError::new( + command, + output.status.to_string(), + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + ) .into()); } @@ -69,11 +71,8 @@ impl TargetContext { let cfg = stdout .lines() .filter(|line| !line.is_empty()) - .map(|line| { - Cfg::from_str(line) - .map_err(|error| ResolveTargetError::new(format!("rustc reported invalid cfg `{line}` for `{triple}`: {error}")).into()) - }) - .collect::, CoverageGateError>>()?; + .map(|line| Cfg::from_str(line).map_err(|error| InvalidRustcCfgError::caused_by(line.to_owned(), triple.clone(), error).into())) + .collect::, ResolveTargetError>>()?; Ok(Self { triple, cfg }) } @@ -97,12 +96,20 @@ impl TargetContext { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { + use std::error::Error as _; + use std::fs::write; + #[cfg(unix)] + use std::fs::{metadata, set_permissions}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; + use tempfile::{TempDir, tempdir}; + use super::*; - fn fake_rustc(vv_stdout: &str, vv_exit: i32, cfg_stdout: &str, cfg_exit: i32) -> tempfile::TempDir { - let temp = tempfile::tempdir().expect("tempdir"); + fn fake_rustc(vv_stdout: &str, vv_exit: i32, cfg_stdout: &str, cfg_exit: i32) -> TempDir { + let temp = tempdir().expect("tempdir"); let path = fake_rustc_path(&temp); let vv_output = vv_stdout.lines().map(echo_line).collect::>().join("\n"); let cfg_output = cfg_stdout.lines().map(echo_line).collect::>().join("\n"); @@ -111,14 +118,13 @@ mod tests { let script = format!("@echo off\nif \"%1\"==\"-vV\" (\n{vv_output}\nexit /b {vv_exit}\n)\n{cfg_output}\nexit /b {cfg_exit}\n"); #[cfg(not(windows))] let script = format!("#!/bin/sh\nif [ \"$1\" = \"-vV\" ]; then\n{vv_output}\nexit {vv_exit}\nfi\n{cfg_output}\nexit {cfg_exit}\n"); - std::fs::write(&path, script).expect("write fake rustc"); + write(&path, script).expect("write fake rustc"); #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(&path).expect("fake rustc metadata").permissions(); + let mut permissions = metadata(&path).expect("fake rustc metadata").permissions(); permissions.set_mode(0o755); - std::fs::set_permissions(&path, permissions).expect("make fake rustc executable"); + set_permissions(&path, permissions).expect("make fake rustc executable"); } temp @@ -134,7 +140,7 @@ mod tests { format!("printf '%s\\n' '{line}'") } - fn fake_rustc_path(temp: &tempfile::TempDir) -> PathBuf { + fn fake_rustc_path(temp: &TempDir) -> PathBuf { #[cfg(windows)] { temp.path().join("rustc.cmd") @@ -175,9 +181,11 @@ mod tests { #[test] #[cfg_attr(miri, ignore = "uses filesystem and spawns fake rustc processes; miri isolation forbids both")] fn rejects_failed_or_malformed_rustc_output() { - let missing = fake_rustc_path(&tempfile::tempdir().expect("tempdir")); + let missing = fake_rustc_path(&tempdir().expect("tempdir")); let error = TargetContext::resolve_with_rustc(None, missing.as_os_str()).expect_err("missing rustc must fail"); - assert!(error.to_string().contains("failed to resolve")); + let rendered = error.to_string(); + assert!(rendered.contains("failed to resolve")); + assert!(error.source().is_some(), "resolve error must preserve its typed cause"); let failed_host = fake_rustc("", 7, "", 0); let error = diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index d96ffb66..1f49a22c 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -14,13 +14,14 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use cargo_metadata::MetadataCommand; -use cargo_platform::Platform; +use cargo_platform::{Cfg, CfgExpr, Platform}; use serde_json::Value; +use crate::CoverageGateError; use crate::error::{ - AmbiguousTargetPolicyError, ConflictingCoverageMetadataError, CoverageGateError, InvalidNoCoverableLinesValueError, - InvalidTargetPolicyError, InvalidTargetSelectorError, InvalidThresholdValueError, LoadMetadataError, ThresholdOutOfRangeError, - WorkspaceScopedNoCoverableLinesError, + AmbiguousTargetPolicyError, ConflictingCoverageMetadataError, InvalidNoCoverableLinesValueError, InvalidTargetPolicyShapeError, + InvalidTargetSelectorError, InvalidTargetTableError, InvalidThresholdValueError, LoadMetadataError, MissingTargetPolicyBehaviorError, + ThresholdOutOfRangeError, UnsupportedTargetSelectorError, WorkspaceScopedNoCoverableLinesError, WorkspaceTargetPolicyError, }; use crate::target::TargetContext; @@ -198,28 +199,30 @@ fn extract_target_policies(gate: &Value, source: &str, scope: Scope) -> Result Result Result Vec { + fn visit(expression: &CfgExpr, attributes: &mut Vec) { + match expression { + CfgExpr::Not(expression) => visit(expression, attributes), + CfgExpr::All(expressions) | CfgExpr::Any(expressions) => { + for expression in expressions { + visit(expression, attributes); + } + } + CfgExpr::Value(Cfg::Name(name)) if matches!(name.as_str(), "test" | "debug_assertions" | "proc_macro") => { + attributes.push(name.as_str().to_owned()); + } + CfgExpr::Value(Cfg::KeyPair(name, _)) if name.as_str() == "feature" => { + attributes.push(name.as_str().to_owned()); + } + CfgExpr::Value(_) | CfgExpr::True | CfgExpr::False => {} + } + } + + let mut attributes = Vec::new(); + if let Platform::Cfg(expression) = platform { + visit(expression, &mut attributes); + } + attributes.sort(); + attributes.dedup(); + attributes +} + fn apply_target_policy(metadata: &mut CoverageGateMetadata, target: &TargetContext, source: &str) -> Result<(), CoverageGateError> { let exact = metadata .target_policies @@ -789,4 +816,34 @@ min-lines-percent = 0 assert!(error.to_string().contains(expected), "case {index}: {error}"); } } + + #[test] + fn rejects_build_context_target_selectors() { + for selector in ["cfg(feature = \"simd\")", "cfg(test)", "cfg(debug_assertions)", "cfg(proc_macro)"] { + let gate = serde_json::json!({ + "target": { + (selector): { "min-lines-percent": 0 } + } + }); + let error = extract_target_policies(&gate, "alpha", Scope::Package).expect_err("build-context selector must be rejected"); + let rendered = error.to_string(); + assert!( + rendered.contains("unsupported build-context cfg attributes"), + "{selector}: {rendered}" + ); + } + } + + #[test] + fn accepts_target_derived_cfg_selectors() { + for selector in ["cfg(target_os = \"linux\")", "cfg(target_arch = \"x86_64\")"] { + let gate = serde_json::json!({ + "target": { + (selector): { "min-lines-percent": 0 } + } + }); + let policies = extract_target_policies(&gate, "alpha", Scope::Package).expect("target-derived selector must be accepted"); + assert_eq!(policies.len(), 1); + } + } } diff --git a/crates/cargo-coverage-gate/tests/cli.rs b/crates/cargo-coverage-gate/tests/cli.rs index 9d5eeb93..a3854ad5 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -285,7 +285,6 @@ fn conflicting_coverage_metadata_exits_2() { } #[test] -#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] fn target_zero_threshold_opts_package_out_of_gate() { let tmp = TempDir::new().expect("tempdir"); make_workspace_with_gate( @@ -313,7 +312,6 @@ fn target_zero_threshold_opts_package_out_of_gate() { } #[test] -#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] fn empty_lcov_passes_when_all_effective_policies_allow_no_data() { let tmp = TempDir::new().expect("tempdir"); make_workspace_with_gate( @@ -340,8 +338,7 @@ fn empty_lcov_passes_when_all_effective_policies_allow_no_data() { } #[test] -#[cfg_attr(miri, ignore = "spawns the binary as a subprocess")] -fn target_zero_threshold_package_remains_gated_on_supported_target() { +fn target_zero_threshold_package_remains_gated_when_override_does_not_match() { let tmp = TempDir::new().expect("tempdir"); make_workspace_with_gate( tmp.path(), From 2e788fd33ea25860a4bc8b471b513c61bdb529a9 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 15:02:49 +0200 Subject: [PATCH 10/12] test(coverage-gate): cover nested target selectors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/src/workspace.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index 1f49a22c..5f863d4e 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -819,7 +819,13 @@ min-lines-percent = 0 #[test] fn rejects_build_context_target_selectors() { - for selector in ["cfg(feature = \"simd\")", "cfg(test)", "cfg(debug_assertions)", "cfg(proc_macro)"] { + for selector in [ + "cfg(feature = \"simd\")", + "cfg(test)", + "cfg(debug_assertions)", + "cfg(proc_macro)", + "cfg(all(unix, any(target_os = \"linux\", feature = \"simd\")))", + ] { let gate = serde_json::json!({ "target": { (selector): { "min-lines-percent": 0 } From dea28a709ff980e3c42d96b9c6b3a39b52ce9666 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 19:25:43 +0200 Subject: [PATCH 11/12] fix: preserve manifest dependency ordering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 359e5c89..0b8e8ca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ blake3 = { version = "1.8.5", default-features = false } bolero = { version = "0.13.4", default-features = false } bytes = { version = "1.11.1", default-features = false } camino = { version = "1.2.5", default-features = false } -cargo-platform = { version = "0.3.3", default-features = false } # local dependencies cargo-aprz-lib = { path = "crates/cargo-aprz-lib", default-features = false, version = "1.1.0" } cargo-gamma-attrs-impl = { path = "crates/cargo-gamma-attrs-impl", default-features = false, version = "0.1.0" } @@ -40,6 +39,7 @@ cargo-gamma-process = { path = "crates/cargo-gamma-process", default-features = cargo-gamma-rt = { path = "crates/cargo-gamma-rt", default-features = false, version = "0.1.0" } cargo-gamma-unsafe = { path = "crates/cargo-gamma-unsafe", default-features = false, version = "0.1.0" } cargo-heather = { path = "crates/cargo-heather", default-features = false, version = "0.2.1" } +cargo-platform = { version = "0.3.3", default-features = false } cargo_metadata = { version = "0.23.1", default-features = false } cel-interpreter = { version = "0.10.0", default-features = false } chrono = { version = "0.4.40", default-features = false } From eaf6995d3a8a30c015959d09857d035523bbf599 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 28 Aug 2026 19:28:05 +0200 Subject: [PATCH 12/12] chore(coverage-gate): generate 0.4.0 changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-coverage-gate/CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/cargo-coverage-gate/CHANGELOG.md b/crates/cargo-coverage-gate/CHANGELOG.md index 825c32f0..9e862e49 100644 --- a/crates/cargo-coverage-gate/CHANGELOG.md +++ b/crates/cargo-coverage-gate/CHANGELOG.md @@ -1 +1,24 @@ # Changelog +## [0.4.0] - 2026-08-28 + +- ✨ Features + + - add target-specific policies + - add cargo-aprz and cargo-ensure-no-default-features ([#76](https://github.com/microsoft/ox-tools/pull/76)) + - run a command per workspace member with cargo-style selection ([#61](https://github.com/microsoft/ox-tools/pull/61)) + - add expect-no-coverable-lines assertion ([#51](https://github.com/microsoft/ox-tools/pull/51)) + +- 🐛 Bug Fixes + + - preserve cross-package coverage + - resolve effective target policies lazily + +- 📚 Documentation + + - document configuration capabilities + +- ♻️ Code Refactoring + + - tighten target policy contracts + - reuse thresholds for target opt-outs +