cargo_coverage_gate/lib.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
5#![cfg_attr(docsrs, feature(doc_cfg))]
6
7//! # cargo-coverage-gate
8//!
9//! A pull-request-time gate that compares per-package line coverage produced
10//! by [`cargo-llvm-cov`] against per-package thresholds carried in
11//! `Cargo.toml`. The accompanying `cargo-coverage-gate` binary reads the
12//! coverage lcov tracefile, resolves each package's base policy from a small
13//! three-layer lookup, applies any matching package target policy, and emits a
14//! verdict table to stdout (and,
15//! optionally, to a Markdown summary file for CI step summaries). A failing
16//! verdict includes actionable details without relying on a later
17//! coverage-service upload. A coverable line is a distinct LCOV `DA:` record.
18//! Numeric failures show exact covered/coverable counts and uncovered ranges;
19//! `expect-no-coverable-lines` failures show the unexpected coverable ranges;
20//! and `NO DATA` explains that no records were attributed. Location lists are
21//! bounded, with an exact count of omitted locations.
22//!
23//! ## Configuration
24//!
25//! ### Numeric thresholds
26//!
27//! A workspace can define the default line-coverage threshold:
28//!
29//! ```toml
30//! # Illustrative workspace policy.
31//! [workspace.metadata.coverage-gate]
32//! min-lines-percent = 80
33//! ```
34//!
35//! Individual packages can override it:
36//!
37//! ```toml
38//! # Illustrative package policy, intentionally stricter than the workspace.
39//! [package.metadata.coverage-gate]
40//! min-lines-percent = 95
41//! ```
42//!
43//! For each workspace member, the base threshold is the first match
44//! among:
45//!
46//! 1. `[package.metadata.coverage-gate] min-lines-percent = N` in the package's
47//! `Cargo.toml`,
48//! 2. `[workspace.metadata.coverage-gate] min-lines-percent = N` in the workspace
49//! root `Cargo.toml`, or
50//! 3. The built-in default of `100.0` — full coverage required.
51//!
52//! Setting `min-lines-percent = 0.0` explicitly opts a package out of
53//! gating: it always passes, regardless of attributed data. Thresholds must
54//! be in the inclusive range `0.0..=100.0`.
55//!
56//! ### Packages with no coverable lines
57//!
58//! A package that legitimately contains no coverable lines (pure re-exports,
59//! type definitions, or a thin binary shim) can make that invariant explicit:
60//!
61//! ```toml
62//! [package.metadata.coverage-gate]
63//! expect-no-coverable-lines = true
64//! ```
65//!
66//! The gate passes only while the package has no attributed coverable lines
67//! and fails as a regression if coverable code later appears. This differs
68//! from `min-lines-percent = 0`, which keeps passing if the package grows
69//! coverable code. The two keys are mutually exclusive, and
70//! `expect-no-coverable-lines` is package-scoped only.
71//!
72//! ### Target-specific policies
73//!
74//! A package can replace its base policy for a Cargo-style target selector:
75//!
76//! ```toml
77//! [package.metadata.coverage-gate]
78//! min-lines-percent = 100
79//!
80//! [package.metadata.coverage-gate.target.'cfg(not(windows))']
81//! expect-no-coverable-lines = true
82//!
83//! [package.metadata.coverage-gate.target.x86_64-unknown-linux-gnu]
84//! min-lines-percent = 100
85//! ```
86//!
87//! A target-specific no-coverable-lines assertion uses the same nesting:
88//!
89//! ```toml
90//! [package.metadata.coverage-gate.target.thumbv7em-none-eabihf]
91//! expect-no-coverable-lines = true
92//! ```
93//!
94//! Target tables are package-scoped; they are invalid in workspace metadata.
95//! Their keys accept exact Rust target triples or quoted `cfg(...)` expressions
96//! using the target-derived subset of Cargo's target grammar. Target
97//! configuration options such as `windows`, `unix`, `target_os`, and
98//! `target_arch` are supported. Build-context options such as `feature`, `test`,
99//! `debug_assertions`, and `proc_macro` are rejected because a standalone target
100//! query cannot evaluate them. A selected target table sets either
101//! `min-lines-percent` or `expect-no-coverable-lines = true`, completely
102//! replacing the package's base policy to produce its effective policy. Exact
103//! triples take precedence over matching `cfg(...)` expressions. Multiple
104//! matching cfg policies are a configuration error rather than depending on
105//! declaration order.
106//!
107//! A zero target-specific threshold disables gating on the matching target,
108//! but does not disable test execution or instrumentation. Those test binaries
109//! remain instrumented because they may contribute coverage to other packages.
110//! If cargo-llvm-cov reports that an instrumented run produced no coverage
111//! data, automation can supply an empty lcov tracefile: zero-threshold and
112//! `expect-no-coverable-lines` packages pass, while positively gated packages
113//! report `NO DATA`.
114//!
115//! ## Why lcov, not the JSON?
116//!
117//! `cargo-llvm-cov` exports the same instrumentation run in several
118//! formats (JSON, lcov, cobertura, codecov-custom-JSON). The gate
119//! consumes lcov because that is what every other coverage report fed by
120//! the same data sees: Codecov ingests lcov uploads directly, ADO
121//! consumes cobertura that cargo-llvm-cov derives from lcov, and the
122//! lcov line semantics ("a line is covered if any region on it was
123//! hit") match the human reading of "did we hit this line". The JSON
124//! export uses a stricter "every region on the line must be hit"
125//! interpretation that systematically reports a couple of
126//! percentage-points lower, which makes calibrating thresholds against
127//! Codecov / ADO numbers confusing.
128//!
129//! ## Binary usage
130//!
131//! ```text
132//! cargo coverage-gate [--lcov <path>]... [-p|--package <spec>]...
133//! [--target <triple>]
134//! [--summary-file <path>] [--quiet]
135//! ```
136//!
137//! `--lcov` may be repeated; the tracefiles are merged at the line level
138//! (per-line counts summed) so multiple feature-config exports
139//! (`--all-features`, `--no-default-features`) can be gated together
140//! without a separate, platform-specific merge step.
141//!
142//! Exit codes: `0` if every gated package meets its threshold, `1` if any
143//! gated package falls below its threshold, and `2` for configuration
144//! errors (unparseable lcov, missing data for a gated package, a `--package`
145//! selector that matches no member, an out-of-range `min-lines-percent`
146//! value, …).
147//!
148//! When `--summary-file` is unset, the binary falls back to
149//! `$GITHUB_STEP_SUMMARY` and then `$COVERAGE_GATE_SUMMARY` to decide
150//! where to write the Markdown verdict table.
151//!
152//! ## Library usage
153//!
154//! ```no_run
155//! use std::io;
156//!
157//! let lcov = std::fs::read_to_string("target/coverage/lcov.info")?;
158//! let report = cargo_coverage_gate::evaluate(&lcov, None, &[])?;
159//! report.render_text(&mut io::stdout())?;
160//! let code = report.verdict().as_exit_code();
161//! # let _ = code;
162//! # Ok::<(), Box<dyn std::error::Error>>(())
163//! ```
164//!
165//! ## Public API
166//!
167//! [`evaluate`] gates one lcov tracefile for the rustc host target, while
168//! [`evaluate_many`] merges multiple tracefiles at line level.
169//! [`evaluate_many_for_target`] evaluates a selected Rust target, which may be
170//! supplied explicitly or omitted to select the rustc host target.
171//! Evaluation returns an [`EvaluatedReport`], which renders as plain
172//! text via [`EvaluatedReport::render_text`] or GitHub-flavored Markdown via
173//! [`EvaluatedReport::render_markdown`] and reduces to a [`Verdict`] via
174//! [`EvaluatedReport::verdict`]. The accompanying binary loads tracefiles from
175//! disk and orchestrates rendering plus the appropriate exit code.
176//!
177//! [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
178
179#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-coverage-gate/logo.png")]
180#![doc(
181 html_favicon_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-coverage-gate/favicon.ico"
182)]
183#![deny(unsafe_code)]
184
185use std::io;
186use std::path::Path;
187
188mod aggregate;
189mod attribute;
190mod error;
191mod lcov_cov;
192mod render;
193mod target;
194mod threshold;
195mod verdict;
196mod workspace;
197
198pub use error::CoverageGateError;
199
200/// Outcome of a coverage-gate evaluation.
201///
202/// Maps onto the process exit code: [`Verdict::Pass`] is `0`,
203/// [`Verdict::Fail`] is `1`, and [`Verdict::ConfigError`] is `2`.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum Verdict {
206 /// Every gated package met its threshold.
207 Pass,
208 /// At least one gated package fell below its threshold.
209 Fail,
210 /// A configuration error prevented evaluation (for example, a gated
211 /// package had no coverage data, or the lcov tracefile failed to parse).
212 ConfigError,
213}
214
215impl Verdict {
216 /// The process exit code associated with this verdict.
217 #[must_use]
218 pub fn as_exit_code(self) -> i32 {
219 match self {
220 Self::Pass => 0,
221 Self::Fail => 1,
222 Self::ConfigError => 2,
223 }
224 }
225}
226
227/// An evaluated coverage report.
228///
229/// Produced by [`evaluate`]; can be rendered as either a fixed-width plain
230/// text table or a GitHub-flavored Markdown table, and reducible to a
231/// single [`Verdict`].
232#[derive(Debug)]
233pub struct EvaluatedReport {
234 inner: verdict::Report,
235}
236
237impl EvaluatedReport {
238 /// The overall verdict for this evaluation.
239 #[must_use]
240 pub fn verdict(&self) -> Verdict {
241 self.inner.verdict()
242 }
243
244 /// Number of source files in the lcov tracefile whose path did not
245 /// match any workspace member. Such files are dropped from the
246 /// per-package aggregation; this count surfaces them as a single
247 /// aggregated warning rather than per-file noise.
248 #[must_use]
249 pub fn unattributed_count(&self) -> usize {
250 self.inner.unattributed
251 }
252
253 /// Render the verdict table as plain text to `out`.
254 ///
255 /// # Errors
256 ///
257 /// Returns whatever IO error `out` produces.
258 pub fn render_text(&self, out: &mut dyn io::Write) -> io::Result<()> {
259 render::text::render(out, &self.inner)
260 }
261
262 /// Render the verdict table as GitHub-flavored Markdown to `out`.
263 ///
264 /// # Errors
265 ///
266 /// Returns whatever IO error `out` produces.
267 pub fn render_markdown(&self, out: &mut dyn io::Write) -> io::Result<()> {
268 render::markdown::render(out, &self.inner)
269 }
270}
271
272/// Evaluate `lcov_text` (a [`cargo-llvm-cov`] lcov tracefile) against
273/// the workspace anchored at `manifest_path` and return the resolved
274/// [`EvaluatedReport`].
275///
276/// `gated_packages` restricts the operation to a named subset; when
277/// empty, every workspace member is in scope.
278///
279/// # Errors
280///
281/// Returns a [`CoverageGateError`] when the tracefile does not parse,
282/// workspace discovery fails, a package selector is unknown, coverage
283/// metadata or target policy is invalid or ambiguous, a configured threshold
284/// is out of range, or required Rust target discovery or cfg queries fail.
285/// The error message identifies which case occurred; callers usually just
286/// propagate it.
287///
288/// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
289pub fn evaluate(lcov_text: &str, manifest_path: Option<&Path>, gated_packages: &[String]) -> Result<EvaluatedReport, CoverageGateError> {
290 evaluate_many(std::slice::from_ref(&lcov_text), manifest_path, gated_packages)
291}
292
293/// Evaluate one or more [`cargo-llvm-cov`] lcov tracefiles against the
294/// workspace anchored at `manifest_path` and return the resolved
295/// [`EvaluatedReport`].
296///
297/// The tracefiles are merged at the line level before evaluation (per-line
298/// counts summed, line sets combined), so passing the `--all-features` and
299/// `--no-default-features` exports yields the same per-package line
300/// coverage as a single merged report — without a platform-specific lcov
301/// merger. An empty slice is treated as an empty report. Packages with
302/// positive thresholds then report NO DATA, which rolls the overall result
303/// up to [`Verdict::ConfigError`] (process exit code 2). Packages with zero
304/// thresholds pass, while `expect-no-coverable-lines` packages report EMPTY
305/// and pass.
306///
307/// `gated_packages` restricts the operation to a named subset; when
308/// empty, every workspace member is in scope.
309///
310/// # Errors
311///
312/// Returns a [`CoverageGateError`] under the same conditions as
313/// [`evaluate`]; additionally, any tracefile that fails to parse aborts
314/// the merge.
315///
316/// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
317#[inline]
318pub fn evaluate_many(
319 lcov_texts: &[&str],
320 manifest_path: Option<&Path>,
321 gated_packages: &[String],
322) -> Result<EvaluatedReport, CoverageGateError> {
323 evaluate_many_for_target(lcov_texts, manifest_path, gated_packages, None)
324}
325
326/// Evaluate one or more lcov tracefiles for a selected Rust target.
327///
328/// `target` is a Rust target triple such as `x86_64-pc-windows-msvc`.
329/// When omitted, the rustc host target is used. Target-specific
330/// package policy is resolved before the gated package set is evaluated.
331///
332/// # Errors
333///
334/// Returns a [`CoverageGateError`] under the same conditions as
335/// [`evaluate_many`]. Target-resolution failures are also reported when target
336/// policy requires a Rust target.
337pub fn evaluate_many_for_target(
338 lcov_texts: &[&str],
339 manifest_path: Option<&Path>,
340 gated_packages: &[String],
341 target: Option<&str>,
342) -> Result<EvaluatedReport, CoverageGateError> {
343 let report = lcov_cov::CoverageReport::from_strs(lcov_texts)?;
344 let ws = workspace::Workspace::load(manifest_path, target)?;
345 let inner = verdict::evaluate(&report, &ws, gated_packages)?;
346 Ok(EvaluatedReport { inner })
347}
348
349#[cfg(test)]
350#[cfg_attr(coverage_nightly, coverage(off))]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn exit_codes() {
356 assert_eq!(Verdict::Pass.as_exit_code(), 0);
357 assert_eq!(Verdict::Fail.as_exit_code(), 1);
358 assert_eq!(Verdict::ConfigError.as_exit_code(), 2);
359 }
360
361 #[test]
362 fn evaluate_rejects_malformed_lcov() {
363 let err = evaluate("not lcov", None, &[]).expect_err("malformed lcov must error");
364 assert!(err.to_string().contains("lcov tracefile"));
365 }
366
367 #[test]
368 fn evaluated_report_unattributed_count_round_trips() {
369 // Construct an EvaluatedReport whose inner Report has a known
370 // unattributed count, then verify the public accessor returns it.
371 let inner = verdict::Report {
372 outcomes: Vec::new(),
373 unattributed: 3,
374 };
375 let report = EvaluatedReport { inner };
376 assert_eq!(report.unattributed_count(), 3);
377 }
378
379 #[test]
380 fn evaluated_report_unattributed_count_zero_for_empty() {
381 let inner = verdict::Report {
382 outcomes: Vec::new(),
383 unattributed: 0,
384 };
385 let report = EvaluatedReport { inner };
386 assert_eq!(report.unattributed_count(), 0);
387 }
388}