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