Skip to main content

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 threshold from a small
13//! three-layer lookup, and emits a verdict table to stdout (and,
14//! optionally, to a Markdown summary file for CI step summaries).
15//!
16//! ## Threshold resolution
17//!
18//! For each workspace member, the effective threshold is the first match
19//! among:
20//!
21//! 1. `[package.metadata.coverage-gate] min-lines-percent = N` in the package's
22//!    `Cargo.toml`,
23//! 2. `[workspace.metadata.coverage-gate] min-lines-percent = N` in the workspace
24//!    root `Cargo.toml`, or
25//! 3. The built-in default of `100.0` — full coverage required.
26//!
27//! Setting `min-lines-percent = 0.0` explicitly opts a package out of
28//! gating (it always passes, regardless of attributed data). A package
29//! that legitimately contains no coverable lines (pure re-exports, type
30//! definitions, a thin binary shim) instead declares
31//! `expect-no-coverable-lines = true`: the gate passes only while that
32//! holds and fails — as a regression — if coverable lines later appear.
33//! The two keys are mutually exclusive, and `expect-no-coverable-lines`
34//! is package-scoped only.
35//!
36//! ## Why lcov, not the JSON?
37//!
38//! `cargo-llvm-cov` exports the same instrumentation run in several
39//! formats (JSON, lcov, cobertura, codecov-custom-JSON). The gate
40//! consumes lcov because that is what every other coverage report fed by
41//! the same data sees: Codecov ingests lcov uploads directly, ADO
42//! consumes cobertura that cargo-llvm-cov derives from lcov, and the
43//! lcov line semantics ("a line is covered if any region on it was
44//! hit") match the human reading of "did we hit this line". The JSON
45//! export uses a stricter "every region on the line must be hit"
46//! interpretation that systematically reports a couple of
47//! percentage-points lower, which makes calibrating thresholds against
48//! Codecov / ADO numbers confusing.
49//!
50//! ## Binary usage
51//!
52//! ```text
53//! cargo coverage-gate  [--lcov <path>]... [-p|--package <spec>]...
54//!                      [--summary-file <path>] [--quiet]
55//! ```
56//!
57//! `--lcov` may be repeated; the tracefiles are merged at the line level
58//! (per-line counts summed) so multiple feature-config exports
59//! (`--all-features`, `--no-default-features`) can be gated together
60//! without a separate, platform-specific merge step.
61//!
62//! Exit codes: `0` if every gated package meets its threshold, `1` if any
63//! gated package falls below its threshold, and `2` for configuration
64//! errors (unparseable lcov, missing data for a gated package, a `--package`
65//! selector that matches no member, an out-of-range `min-lines-percent`
66//! value, …).
67//!
68//! When `--summary-file` is unset, the binary falls back to
69//! `$GITHUB_STEP_SUMMARY` and then `$COVERAGE_GATE_SUMMARY` to decide
70//! where to write the Markdown verdict table.
71//!
72//! ## Library usage
73//!
74//! ```no_run
75//! use std::io;
76//!
77//! let lcov = std::fs::read_to_string("target/coverage/lcov.info")?;
78//! let report = cargo_coverage_gate::evaluate(&lcov, None, &[])?;
79//! report.render_text(&mut io::stdout())?;
80//! let code = report.verdict().as_exit_code();
81//! # let _ = code;
82//! # Ok::<(), Box<dyn std::error::Error>>(())
83//! ```
84//!
85//! ## Public API
86//!
87//! The library exposes [`evaluate`], which returns an
88//! [`EvaluatedReport`]. The report can be rendered as plain text via
89//! [`EvaluatedReport::render_text`] or as GitHub-flavored Markdown
90//! via [`EvaluatedReport::render_markdown`], and reduced to a single
91//! [`Verdict`] via [`EvaluatedReport::verdict`]. The accompanying
92//! binary loads the lcov tracefile from disk and orchestrates rendering
93//! plus the appropriate exit code.
94//!
95//! [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
96
97#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-coverage-gate/logo.png")]
98#![doc(
99    html_favicon_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-coverage-gate/favicon.ico"
100)]
101#![deny(unsafe_code)]
102
103use std::io;
104use std::path::Path;
105
106mod aggregate;
107mod attribute;
108mod error;
109mod lcov_cov;
110mod render;
111mod threshold;
112mod verdict;
113mod workspace;
114
115pub use error::CoverageGateError;
116
117/// Outcome of a coverage-gate evaluation.
118///
119/// Maps onto the process exit code: [`Verdict::Pass`] is `0`,
120/// [`Verdict::Fail`] is `1`, and [`Verdict::ConfigError`] is `2`.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Verdict {
123    /// Every gated package met its threshold.
124    Pass,
125    /// At least one gated package fell below its threshold.
126    Fail,
127    /// A configuration error prevented evaluation (for example, a gated
128    /// package had no coverage data, or the lcov tracefile failed to parse).
129    ConfigError,
130}
131
132impl Verdict {
133    /// The process exit code associated with this verdict.
134    #[must_use]
135    pub fn as_exit_code(self) -> i32 {
136        match self {
137            Self::Pass => 0,
138            Self::Fail => 1,
139            Self::ConfigError => 2,
140        }
141    }
142}
143
144/// An evaluated coverage report.
145///
146/// Produced by [`evaluate`]; can be rendered as either a fixed-width plain
147/// text table or a GitHub-flavored Markdown table, and reducible to a
148/// single [`Verdict`].
149#[derive(Debug)]
150pub struct EvaluatedReport {
151    inner: verdict::Report,
152}
153
154impl EvaluatedReport {
155    /// The overall verdict for this evaluation.
156    #[must_use]
157    pub fn verdict(&self) -> Verdict {
158        self.inner.verdict()
159    }
160
161    /// Number of source files in the lcov tracefile whose path did not
162    /// match any workspace member. Such files are dropped from the
163    /// per-package aggregation; this count surfaces them as a single
164    /// aggregated warning rather than per-file noise.
165    #[must_use]
166    pub fn unattributed_count(&self) -> usize {
167        self.inner.unattributed
168    }
169
170    /// Render the verdict table as plain text to `out`.
171    ///
172    /// # Errors
173    ///
174    /// Returns whatever IO error `out` produces.
175    pub fn render_text(&self, out: &mut dyn io::Write) -> io::Result<()> {
176        render::text::render(out, &self.inner)
177    }
178
179    /// Render the verdict table as GitHub-flavored Markdown to `out`.
180    ///
181    /// # Errors
182    ///
183    /// Returns whatever IO error `out` produces.
184    pub fn render_markdown(&self, out: &mut dyn io::Write) -> io::Result<()> {
185        render::markdown::render(out, &self.inner)
186    }
187}
188
189/// Evaluate `lcov_text` (a [`cargo-llvm-cov`] lcov tracefile) against
190/// the workspace anchored at `manifest_path` and return the resolved
191/// [`EvaluatedReport`].
192///
193/// `gated_packages` restricts the operation to a named subset; when
194/// empty, every workspace member is in scope.
195///
196/// # Errors
197///
198/// Returns a [`CoverageGateError`] when the tracefile does not parse,
199/// workspace discovery fails, an unknown package appears in
200/// `gated_packages`, or a configured `min-lines-percent` value is outside
201/// `[0.0, 100.0]`. The error message identifies which case occurred;
202/// callers usually just propagate it.
203///
204/// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
205pub fn evaluate(lcov_text: &str, manifest_path: Option<&Path>, gated_packages: &[String]) -> Result<EvaluatedReport, CoverageGateError> {
206    evaluate_many(std::slice::from_ref(&lcov_text), manifest_path, gated_packages)
207}
208
209/// Evaluate one or more [`cargo-llvm-cov`] lcov tracefiles against the
210/// workspace anchored at `manifest_path` and return the resolved
211/// [`EvaluatedReport`].
212///
213/// The tracefiles are merged at the line level before evaluation (per-line
214/// counts summed, line sets combined), so passing the `--all-features` and
215/// `--no-default-features` exports yields the same per-package line
216/// coverage as a single merged report — without a platform-specific lcov
217/// merger. An empty slice is treated as an empty report (every gated
218/// package then reports NO DATA). NO DATA is not a passing outcome:
219/// each such package classifies as `NoData`, which rolls the overall
220/// result up to [`Verdict::ConfigError`] (process exit code 2), so an
221/// empty slice never yields a successful verdict.
222///
223/// `gated_packages` restricts the operation to a named subset; when
224/// empty, every workspace member is in scope.
225///
226/// # Errors
227///
228/// Returns a [`CoverageGateError`] under the same conditions as
229/// [`evaluate`]; additionally, any tracefile that fails to parse aborts
230/// the merge.
231///
232/// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
233pub fn evaluate_many(
234    lcov_texts: &[&str],
235    manifest_path: Option<&Path>,
236    gated_packages: &[String],
237) -> Result<EvaluatedReport, CoverageGateError> {
238    let report = lcov_cov::CoverageReport::from_strs(lcov_texts)?;
239    let ws = workspace::Workspace::load(manifest_path)?;
240    let inner = verdict::evaluate(&report, &ws, gated_packages)?;
241    Ok(EvaluatedReport { inner })
242}
243
244#[cfg(test)]
245#[cfg_attr(coverage_nightly, coverage(off))]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn exit_codes() {
251        assert_eq!(Verdict::Pass.as_exit_code(), 0);
252        assert_eq!(Verdict::Fail.as_exit_code(), 1);
253        assert_eq!(Verdict::ConfigError.as_exit_code(), 2);
254    }
255
256    #[test]
257    fn evaluate_rejects_malformed_lcov() {
258        let err = evaluate("not lcov", None, &[]).expect_err("malformed lcov must error");
259        assert!(err.to_string().contains("lcov tracefile"));
260    }
261
262    #[test]
263    fn evaluated_report_unattributed_count_round_trips() {
264        // Construct an EvaluatedReport whose inner Report has a known
265        // unattributed count, then verify the public accessor returns it.
266        let inner = verdict::Report {
267            outcomes: Vec::new(),
268            unattributed: 3,
269        };
270        let report = EvaluatedReport { inner };
271        assert_eq!(report.unattributed_count(), 3);
272    }
273
274    #[test]
275    fn evaluated_report_unattributed_count_zero_for_empty() {
276        let inner = verdict::Report {
277            outcomes: Vec::new(),
278            unattributed: 0,
279        };
280        let report = EvaluatedReport { inner };
281        assert_eq!(report.unattributed_count(), 0);
282    }
283}