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 gating.
28//!
29//! ## Why lcov, not the JSON?
30//!
31//! `cargo-llvm-cov` exports the same instrumentation run in several
32//! formats (JSON, lcov, cobertura, codecov-custom-JSON). The gate
33//! consumes lcov because that is what every other coverage report fed by
34//! the same data sees: Codecov ingests lcov uploads directly, ADO
35//! consumes cobertura that cargo-llvm-cov derives from lcov, and the
36//! lcov line semantics ("a line is covered if any region on it was
37//! hit") match the human reading of "did we hit this line". The JSON
38//! export uses a stricter "every region on the line must be hit"
39//! interpretation that systematically reports a couple of
40//! percentage-points lower, which makes calibrating thresholds against
41//! Codecov / ADO numbers confusing.
42//!
43//! ## Binary usage
44//!
45//! ```text
46//! cargo coverage-gate  [--lcov <path>] [-p|--package <spec>]...
47//!                      [--summary-file <path>] [--quiet]
48//! ```
49//!
50//! Exit codes: `0` if every gated package meets its threshold, `1` if any
51//! gated package falls below its threshold, and `2` for configuration
52//! errors (unparseable lcov, missing data for a gated package, a `--package`
53//! selector that matches no member, an out-of-range `min-lines-percent`
54//! value, …).
55//!
56//! When `--summary-file` is unset, the binary falls back to
57//! `$GITHUB_STEP_SUMMARY` and then `$COVERAGE_GATE_SUMMARY` to decide
58//! where to write the Markdown verdict table.
59//!
60//! ## Library usage
61//!
62//! ```no_run
63//! use std::io;
64//!
65//! let lcov = std::fs::read_to_string("target/coverage/lcov.info")?;
66//! let report = cargo_coverage_gate::evaluate(&lcov, None, &[])?;
67//! report.render_text(&mut io::stdout())?;
68//! let code = report.verdict().as_exit_code();
69//! # let _ = code;
70//! # Ok::<(), Box<dyn std::error::Error>>(())
71//! ```
72//!
73//! ## Public API
74//!
75//! The library exposes [`evaluate`], which returns an
76//! [`EvaluatedReport`]. The report can be rendered as plain text via
77//! [`EvaluatedReport::render_text`] or as GitHub-flavored Markdown
78//! via [`EvaluatedReport::render_markdown`], and reduced to a single
79//! [`Verdict`] via [`EvaluatedReport::verdict`]. The accompanying
80//! binary loads the lcov tracefile from disk and orchestrates rendering
81//! plus the appropriate exit code.
82//!
83//! [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
84
85#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-coverage-gate/logo.png")]
86#![doc(
87    html_favicon_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-coverage-gate/favicon.ico"
88)]
89#![deny(unsafe_code)]
90
91use std::io;
92use std::path::Path;
93
94mod aggregate;
95mod attribute;
96mod error;
97mod lcov_cov;
98mod render;
99mod threshold;
100mod verdict;
101mod workspace;
102
103pub use error::CoverageGateError;
104
105/// Outcome of a coverage-gate evaluation.
106///
107/// Maps onto the process exit code: [`Verdict::Pass`] is `0`,
108/// [`Verdict::Fail`] is `1`, and [`Verdict::ConfigError`] is `2`.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Verdict {
111    /// Every gated package met its threshold.
112    Pass,
113    /// At least one gated package fell below its threshold.
114    Fail,
115    /// A configuration error prevented evaluation (for example, a gated
116    /// package had no coverage data, or the lcov tracefile failed to parse).
117    ConfigError,
118}
119
120impl Verdict {
121    /// The process exit code associated with this verdict.
122    #[must_use]
123    pub fn as_exit_code(self) -> i32 {
124        match self {
125            Self::Pass => 0,
126            Self::Fail => 1,
127            Self::ConfigError => 2,
128        }
129    }
130}
131
132/// An evaluated coverage report.
133///
134/// Produced by [`evaluate`]; can be rendered as either a fixed-width plain
135/// text table or a GitHub-flavored Markdown table, and reducible to a
136/// single [`Verdict`].
137#[derive(Debug)]
138pub struct EvaluatedReport {
139    inner: verdict::Report,
140}
141
142impl EvaluatedReport {
143    /// The overall verdict for this evaluation.
144    #[must_use]
145    pub fn verdict(&self) -> Verdict {
146        self.inner.verdict()
147    }
148
149    /// Number of source files in the lcov tracefile whose path did not
150    /// match any workspace member. Such files are dropped from the
151    /// per-package aggregation; this count surfaces them as a single
152    /// aggregated warning rather than per-file noise.
153    #[must_use]
154    pub fn unattributed_count(&self) -> usize {
155        self.inner.unattributed
156    }
157
158    /// Render the verdict table as plain text to `out`.
159    ///
160    /// # Errors
161    ///
162    /// Returns whatever IO error `out` produces.
163    pub fn render_text(&self, out: &mut dyn io::Write) -> io::Result<()> {
164        render::text::render(out, &self.inner)
165    }
166
167    /// Render the verdict table as GitHub-flavored Markdown to `out`.
168    ///
169    /// # Errors
170    ///
171    /// Returns whatever IO error `out` produces.
172    pub fn render_markdown(&self, out: &mut dyn io::Write) -> io::Result<()> {
173        render::markdown::render(out, &self.inner)
174    }
175}
176
177/// Evaluate `lcov_text` (a [`cargo-llvm-cov`] lcov tracefile) against
178/// the workspace anchored at `manifest_path` and return the resolved
179/// [`EvaluatedReport`].
180///
181/// `gated_packages` restricts the operation to a named subset; when
182/// empty, every workspace member is in scope.
183///
184/// # Errors
185///
186/// Returns a [`CoverageGateError`] when the tracefile does not parse,
187/// workspace discovery fails, an unknown package appears in
188/// `gated_packages`, or a configured `min-lines-percent` value is outside
189/// `[0.0, 100.0]`. The error message identifies which case occurred;
190/// callers usually just propagate it.
191///
192/// [`cargo-llvm-cov`]: https://github.com/taiki-e/cargo-llvm-cov
193pub fn evaluate(lcov_text: &str, manifest_path: Option<&Path>, gated_packages: &[String]) -> Result<EvaluatedReport, CoverageGateError> {
194    let report = lcov_cov::CoverageReport::from_str(lcov_text)?;
195    let ws = workspace::Workspace::load(manifest_path)?;
196    let inner = verdict::evaluate(&report, &ws, gated_packages)?;
197    Ok(EvaluatedReport { inner })
198}
199
200#[cfg(test)]
201#[cfg_attr(coverage_nightly, coverage(off))]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn exit_codes() {
207        assert_eq!(Verdict::Pass.as_exit_code(), 0);
208        assert_eq!(Verdict::Fail.as_exit_code(), 1);
209        assert_eq!(Verdict::ConfigError.as_exit_code(), 2);
210    }
211
212    #[test]
213    fn evaluate_rejects_malformed_lcov() {
214        let err = evaluate("not lcov", None, &[]).expect_err("malformed lcov must error");
215        assert!(err.to_string().contains("lcov tracefile"));
216    }
217
218    #[test]
219    fn evaluated_report_unattributed_count_round_trips() {
220        // Construct an EvaluatedReport whose inner Report has a known
221        // unattributed count, then verify the public accessor returns it.
222        let inner = verdict::Report {
223            outcomes: Vec::new(),
224            unattributed: 3,
225        };
226        let report = EvaluatedReport { inner };
227        assert_eq!(report.unattributed_count(), 3);
228    }
229
230    #[test]
231    fn evaluated_report_unattributed_count_zero_for_empty() {
232        let inner = verdict::Report {
233            outcomes: Vec::new(),
234            unattributed: 0,
235        };
236        let report = EvaluatedReport { inner };
237        assert_eq!(report.unattributed_count(), 0);
238    }
239}