diff --git a/Cargo.lock b/Cargo.lock index d88661cc..9b33248b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,9 +496,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 67ee14a9..0b8e8ca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,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 } 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 + 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..f93997d1 100644 --- a/crates/cargo-coverage-gate/README.md +++ b/crates/cargo-coverage-gate/README.md @@ -22,7 +22,25 @@ 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 +# Illustrative workspace policy. +[workspace.metadata.coverage-gate] +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 +``` For each workspace member, the effective threshold is the first match among: @@ -34,13 +52,61 @@ 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: + +```toml +[package.metadata.coverage-gate] +min-lines-percent = 100 + +[package.metadata.coverage-gate.target.'cfg(not(windows))'] +min-lines-percent = 0 + +[package.metadata.coverage-gate.target.x86_64-unknown-linux-gnu] +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 +``` + +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 gating on the matching target, +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? @@ -60,6 +126,7 @@ Codecov / ADO numbers confusing. ```text cargo coverage-gate [--lcov ]... [-p|--package ]... + [--target ] [--summary-file ] [--quiet] ``` @@ -91,13 +158,14 @@ 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`][__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 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 +[`EvaluatedReport::verdict`][__link8]. The accompanying binary loads tracefiles from +disk and orchestrates rendering plus the appropriate exit code.
@@ -105,11 +173,13 @@ 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]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbF3P3j2nLNgEbcLNXR38OFU8bmdHWagvGfrMbqUEVVagSr0lhZIGDc2NhcmdvLWNvdmVyYWdlLWdhdGVlMC40LjBzY2FyZ29fY292ZXJhZ2VfZ2F0ZQ [__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/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 7d6b1d14..a13e2cf8 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 ] [--summary-file ] [--quiet] ``` @@ -126,6 +127,9 @@ 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 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 `GITHUB_STEP_SUMMARY` (GitHub Actions) and @@ -161,9 +165,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 +228,55 @@ 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))'] +min-lines-percent = 0 +``` + +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. 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 +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 +target that overrides a broader `cfg(...)` opt-out repeats its positive +threshold. + +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 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 The tool prints a table to stdout (and to the summary file when @@ -378,6 +432,19 @@ 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 whose effective target policy sets `min-lines-percent = 0` passes, +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 Per-package aggregation groups measurements by **source-file ownership** @@ -582,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 @@ -605,9 +677,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 e0b5301f..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 @@ -45,6 +45,12 @@ 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 rustc host target. + #[arg(long, value_name = "TRIPLE")] + pub(crate) target: Option, + /// 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..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 @@ -27,7 +27,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..dc092392 100644 --- a/crates/cargo-coverage-gate/src/error.rs +++ b/crates/cargo-coverage-gate/src/error.rs @@ -33,7 +33,15 @@ use serde_json::Value; ThresholdOutOfRangeError, InvalidNoCoverableLinesValueError, ConflictingCoverageMetadataError, + WorkspaceTargetPolicyError, + InvalidTargetTableError, + InvalidTargetPolicyShapeError, + MissingTargetPolicyBehaviorError, + InvalidTargetSelectorError, + UnsupportedTargetSelectorError, + AmbiguousTargetPolicyError, WorkspaceScopedNoCoverableLinesError, + ResolveTargetError, ParseLcovError, ReadLcovError, UnknownPackageSelectorError @@ -102,6 +110,101 @@ pub(crate) struct ConflictingCoverageMetadataError { )] pub(crate) struct WorkspaceScopedNoCoverableLinesError; +/// Target policies were declared in workspace metadata. +#[ohno::error] +#[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 did not select an effective behavior. +#[ohno::error] +#[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(crate) source: String, + pub(crate) selector: String, +} + +/// 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 Rust target-triple override" +)] +pub(crate) struct AmbiguousTargetPolicyError { + pub(crate) source: String, + pub(crate) target: String, + pub(crate) selectors: String, +} + +/// The Rust target or its cfg values could not be obtained. +#[ohno::error] +#[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. #[ohno::error] #[display("lcov tracefile is not well-formed")] @@ -145,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()); @@ -188,4 +298,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..17bd2e8b 100644 --- a/crates/cargo-coverage-gate/src/lib.rs +++ b/crates/cargo-coverage-gate/src/lib.rs @@ -13,7 +13,25 @@ //! 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 +//! # Illustrative workspace policy. +//! [workspace.metadata.coverage-gate] +//! 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 +//! ``` //! //! For each workspace member, the effective threshold is the first match //! among: @@ -25,13 +43,61 @@ //! 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: +//! +//! ```toml +//! [package.metadata.coverage-gate] +//! min-lines-percent = 100 +//! +//! [package.metadata.coverage-gate.target.'cfg(not(windows))'] +//! min-lines-percent = 0 +//! +//! [package.metadata.coverage-gate.target.x86_64-unknown-linux-gnu] +//! 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 +//! ``` +//! +//! 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 gating on the matching target, +//! 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? //! @@ -51,6 +117,7 @@ //! //! ```text //! cargo coverage-gate [--lcov ]... [-p|--package ]... +//! [--target ] //! [--summary-file ] [--quiet] //! ``` //! @@ -84,13 +151,14 @@ //! //! ## 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 rustc host target, while +//! [`evaluate_many`] merges multiple tracefiles at line level. +//! [`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 +//! [`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 @@ -108,6 +176,7 @@ mod attribute; mod error; mod lcov_cov; mod render; +mod target; mod threshold; mod verdict; mod workspace; @@ -196,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 { @@ -214,11 +284,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. @@ -230,13 +300,35 @@ 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>, gated_packages: &[String], +) -> Result { + evaluate_many_for_target(lcov_texts, manifest_path, gated_packages, None) +} + +/// 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 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`]. 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>, + gated_packages: &[String], + target: Option<&str>, ) -> Result { let report = lcov_cov::CoverageReport::from_strs(lcov_texts)?; - let ws = workspace::Workspace::load(manifest_path)?; + let ws = workspace::Workspace::load(manifest_path, target)?; let inner = verdict::evaluate(&report, &ws, gated_packages)?; Ok(EvaluatedReport { inner }) } diff --git a/crates/cargo-coverage-gate/src/target.rs b/crates/cargo-coverage-gate/src/target.rs new file mode 100644 index 00000000..f050f915 --- /dev/null +++ b/crates/cargo-coverage-gate/src/target.rs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rust target discovery and Cargo-style selector matching. + +use std::env; +use std::ffi::{OsStr, OsString}; +use std::process::Command; +use std::str::FromStr; + +use cargo_platform::{Cfg, Platform}; + +use crate::CoverageGateError; +use crate::error::{ExecuteRustcError, InvalidRustcCfgError, MissingRustcHostTargetError, ResolveTargetError, RustcCommandFailedError}; + +/// The Rust 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 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).map_err(Into::into) + } + + 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| ExecuteRustcError::caused_by(command.clone(), error))?; + if !output.status.success() { + 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); + stdout + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .map(str::to_owned) + .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| ExecuteRustcError::caused_by(command.clone(), error))?; + if !output.status.success() { + 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); + let cfg = stdout + .lines() + .filter(|line| !line.is_empty()) + .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 }) + } + + 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 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) -> 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"); + + #[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"); + write(&path, script).expect("write fake rustc"); + + #[cfg(unix)] + { + let mut permissions = metadata(&path).expect("fake rustc metadata").permissions(); + permissions.set_mode(0o755); + 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: &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( + "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"))); + } + + #[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", + 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] + #[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(&tempdir().expect("tempdir")); + let error = TargetContext::resolve_with_rustc(None, missing.as_os_str()).expect_err("missing rustc must fail"); + 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 = + 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/verdict.rs b/crates/cargo-coverage-gate/src/verdict.rs index ff23d9e5..833322cf 100644 --- a/crates/cargo-coverage-gate/src/verdict.rs +++ b/crates/cargo-coverage-gate/src/verdict.rs @@ -154,7 +154,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()); } diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index a189c786..5f863d4e 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -11,14 +11,19 @@ //! and consumes the values surfaced here. use std::path::{Path, PathBuf}; +use std::str::FromStr; use cargo_metadata::MetadataCommand; +use cargo_platform::{Cfg, CfgExpr, Platform}; use serde_json::Value; +use crate::CoverageGateError; use crate::error::{ - ConflictingCoverageMetadataError, CoverageGateError, InvalidNoCoverableLinesValueError, InvalidThresholdValueError, LoadMetadataError, - ThresholdOutOfRangeError, WorkspaceScopedNoCoverableLinesError, + AmbiguousTargetPolicyError, ConflictingCoverageMetadataError, InvalidNoCoverableLinesValueError, InvalidTargetPolicyShapeError, + InvalidTargetSelectorError, InvalidTargetTableError, InvalidThresholdValueError, LoadMetadataError, MissingTargetPolicyBehaviorError, + ThresholdOutOfRangeError, UnsupportedTargetSelectorError, WorkspaceScopedNoCoverableLinesError, WorkspaceTargetPolicyError, }; +use crate::target::TargetContext; /// Lower bound on `min-lines-percent` values. const MIN_LINES_LOWER: f64 = 0.0; @@ -60,7 +65,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>) -> 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 { @@ -74,7 +86,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| { @@ -85,14 +97,29 @@ impl Workspace { .as_std_path() .to_path_buf(); let gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?; - Ok::(Member { - name: pkg.name.to_string(), + 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 { @@ -114,7 +141,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 +149,21 @@ 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 { + Threshold(f64), + ExpectNoCoverableLines, } /// Pull the `coverage-gate` block out of a freeform metadata `Value` and @@ -139,6 +181,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 +190,129 @@ 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(WorkspaceTargetPolicyError::new().into()); + } + let table = raw_target + .as_object() + .ok_or_else(|| InvalidTargetTableError::new(source.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::caused_by(source.to_owned(), selector_text.clone(), error))?; + let unsupported_attributes = unsupported_cfg_attributes(&selector); + if !unsupported_attributes.is_empty() { + return Err(UnsupportedTargetSelectorError::new( + source.to_owned(), + selector_text.clone(), + unsupported_attributes.join(", "), + ) + .into()); + } + raw_policy + .as_object() + .ok_or_else(|| InvalidTargetPolicyShapeError::new(policy_source.clone()))?; + + 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 policy = if expect_no_coverable_lines { + PolicyOverride::ExpectNoCoverableLines + } else if let Some(value) = min_lines_percent { + PolicyOverride::Threshold(value) + } else { + return Err(MissingTargetPolicyBehaviorError::new(policy_source).into()); + }; + Ok(TargetPolicy { + selector_text: selector_text.clone(), + selector, + policy, + }) + }) + .collect() +} + +fn unsupported_cfg_attributes(platform: &Platform) -> 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 + .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 => Ok(()), + Some(PolicyOverride::Threshold(value)) => { + metadata.min_lines_percent = Some(value); + metadata.expect_no_coverable_lines = false; + Ok(()) + } + Some(PolicyOverride::ExpectNoCoverableLines) => { + metadata.min_lines_percent = None; + metadata.expect_no_coverable_lines = true; + Ok(()) + } + } +} + /// Pull `min-lines-percent` out of a `coverage-gate` block and validate /// that it falls in `[0.0, 100.0]`. /// @@ -193,6 +356,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_with_target_resolver(Some(manifest_path), || Ok(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 +422,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(); @@ -259,6 +433,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() { @@ -268,7 +455,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 +468,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 +489,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 +508,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 +527,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 +560,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 +587,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 +606,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 +625,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 +646,210 @@ 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_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))']\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_eq!(alpha.min_lines_percent, Some(0.0)); + } + + #[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\ + min-lines-percent = 0\n\n\ + [package.metadata.coverage-gate.target.x86_64-pc-windows-msvc]\n\ + min-lines-percent = 90", + ); + 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_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)); + } + + #[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\ + min-lines-percent = 0\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_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)']", + ); + write_workspace(tmp.path(), root, &[("alpha", &alpha)]); + + 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")] + #[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_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); + } + + #[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)'] +min-lines-percent = 0 +"#; + 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']\nmin-lines-percent = 0", + "invalid coverage-gate target selector", + ), + ( + "[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", + "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}"); + } + } + + #[test] + fn rejects_build_context_target_selectors() { + 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 } + } + }); + 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 1800cb26..a3854ad5 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -284,6 +284,80 @@ fn conflicting_coverage_metadata_exits_2() { .stderr(predicate::str::contains("cannot set both")); } +#[test] +fn target_zero_threshold_opts_package_out_of_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\ + min-lines-percent = 0", + ), + ("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")) + .stdout(predicate::str::contains("(no data)")) + .stdout(predicate::str::contains("0.0%")); +} + +#[test] +fn empty_lcov_passes_when_all_effective_policies_allow_no_data() { + 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\ + min-lines-percent = 0", + ), + ("beta", "expect-no-coverable-lines = true"), + ], + ); + let empty_lcov = write_lcov(tmp.path(), &[]); + + coverage_gate(tmp.path()) + .args(["--target", "x86_64-unknown-linux-gnu", "--lcov", &empty_lcov]) + .assert() + .success() + .stdout(predicate::str::contains("alpha")) + .stdout(predicate::str::contains("beta")) + .stdout(predicate::str::contains("all packages meet their threshold")); +} + +#[test] +fn target_zero_threshold_package_remains_gated_when_override_does_not_match() { + 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\ + min-lines-percent = 0", + )], + ); + 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() {