Skip to main content

dev_coverage/
lib.rs

1//! # dev-coverage
2//!
3//! Test coverage measurement and regression detection for Rust. Part
4//! of the `dev-*` verification suite.
5//!
6//! Wraps `cargo-llvm-cov` (the modern Rust coverage standard) and
7//! emits results as `dev-report::Report`. Detects coverage regressions
8//! against a stored baseline so AI agents and CI gates can decide
9//! whether a PR drops coverage too far.
10//!
11//! ## Quick example
12//!
13//! ```no_run
14//! use dev_coverage::{CoverageRun, CoverageThreshold};
15//!
16//! let run = CoverageRun::new("my-crate", "0.1.0");
17//! let result = run.execute().unwrap();
18//!
19//! let threshold = CoverageThreshold::min_line_pct(80.0);
20//! let check = result.into_check_result(threshold);
21//! ```
22//!
23//! ## Status
24//!
25//! Pre-1.0. The `0.9.0` release defines the shape of the API; the
26//! actual `cargo-llvm-cov` integration lands in `0.9.1`.
27
28#![cfg_attr(docsrs, feature(doc_cfg))]
29#![warn(missing_docs)]
30#![warn(rust_2018_idioms)]
31
32use dev_report::{CheckResult, Severity};
33
34/// Configuration for a coverage run.
35#[derive(Debug, Clone)]
36pub struct CoverageRun {
37    name: String,
38    version: String,
39}
40
41impl CoverageRun {
42    /// Begin a coverage run for the given crate name and version.
43    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
44        Self {
45            name: name.into(),
46            version: version.into(),
47        }
48    }
49
50    /// Execute the coverage run.
51    ///
52    /// In `0.9.0` this is a stub; the actual `cargo llvm-cov`
53    /// invocation lands in `0.9.1`.
54    pub fn execute(&self) -> Result<CoverageResult, CoverageError> {
55        // Stub: returns a zero-coverage result for now.
56        Ok(CoverageResult {
57            name: self.name.clone(),
58            version: self.version.clone(),
59            line_pct: 0.0,
60            function_pct: 0.0,
61            region_pct: 0.0,
62            total_lines: 0,
63            covered_lines: 0,
64        })
65    }
66}
67
68/// Result of a coverage run.
69#[derive(Debug, Clone)]
70pub struct CoverageResult {
71    /// Crate name.
72    pub name: String,
73    /// Crate version.
74    pub version: String,
75    /// Percentage of executable lines that were exercised by tests.
76    pub line_pct: f64,
77    /// Percentage of functions that were called by tests.
78    pub function_pct: f64,
79    /// Percentage of regions (branch points) that were exercised.
80    pub region_pct: f64,
81    /// Total executable lines in the crate.
82    pub total_lines: u64,
83    /// Lines that were exercised at least once.
84    pub covered_lines: u64,
85}
86
87/// Threshold defining the minimum acceptable coverage.
88#[derive(Debug, Clone, Copy)]
89pub enum CoverageThreshold {
90    /// Fail if `line_pct < pct`.
91    MinLinePct(f64),
92    /// Fail if `function_pct < pct`.
93    MinFunctionPct(f64),
94    /// Fail if `region_pct < pct`.
95    MinRegionPct(f64),
96}
97
98impl CoverageThreshold {
99    /// Build a line-coverage threshold.
100    pub fn min_line_pct(pct: f64) -> Self {
101        Self::MinLinePct(pct)
102    }
103
104    /// Build a function-coverage threshold.
105    pub fn min_function_pct(pct: f64) -> Self {
106        Self::MinFunctionPct(pct)
107    }
108
109    /// Build a region-coverage threshold.
110    pub fn min_region_pct(pct: f64) -> Self {
111        Self::MinRegionPct(pct)
112    }
113}
114
115impl CoverageResult {
116    /// Convert this result into a `CheckResult` against the given threshold.
117    pub fn into_check_result(self, threshold: CoverageThreshold) -> CheckResult {
118        let name = format!("coverage::{}", self.name);
119        let (actual, target, label) = match threshold {
120            CoverageThreshold::MinLinePct(p) => (self.line_pct, p, "line"),
121            CoverageThreshold::MinFunctionPct(p) => (self.function_pct, p, "function"),
122            CoverageThreshold::MinRegionPct(p) => (self.region_pct, p, "region"),
123        };
124        let detail = format!("{label} coverage {actual:.2}% (threshold {target:.2}%)");
125        if actual < target {
126            CheckResult::fail(name, Severity::Warning).with_detail(detail)
127        } else {
128            CheckResult::pass(name).with_detail(detail)
129        }
130    }
131}
132
133/// Errors that can arise during a coverage run.
134#[derive(Debug)]
135pub enum CoverageError {
136    /// The `cargo-llvm-cov` tool is not installed.
137    ToolNotInstalled,
138    /// The coverage subprocess failed.
139    SubprocessFailed(String),
140    /// The coverage output could not be parsed.
141    ParseError(String),
142}
143
144impl std::fmt::Display for CoverageError {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            Self::ToolNotInstalled => write!(f, "cargo-llvm-cov is not installed"),
148            Self::SubprocessFailed(s) => write!(f, "subprocess failed: {s}"),
149            Self::ParseError(s) => write!(f, "parse error: {s}"),
150        }
151    }
152}
153
154impl std::error::Error for CoverageError {}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn run_returns_a_result() {
162        let run = CoverageRun::new("x", "0.1.0");
163        let r = run.execute().unwrap();
164        assert_eq!(r.name, "x");
165    }
166
167    #[test]
168    fn threshold_pass() {
169        let r = CoverageResult {
170            name: "x".into(),
171            version: "0.1.0".into(),
172            line_pct: 90.0,
173            function_pct: 85.0,
174            region_pct: 80.0,
175            total_lines: 100,
176            covered_lines: 90,
177        };
178        let c = r.into_check_result(CoverageThreshold::min_line_pct(80.0));
179        assert!(matches!(c.verdict, dev_report::Verdict::Pass));
180    }
181
182    #[test]
183    fn threshold_fail() {
184        let r = CoverageResult {
185            name: "x".into(),
186            version: "0.1.0".into(),
187            line_pct: 50.0,
188            function_pct: 60.0,
189            region_pct: 40.0,
190            total_lines: 100,
191            covered_lines: 50,
192        };
193        let c = r.into_check_result(CoverageThreshold::min_line_pct(80.0));
194        assert!(matches!(c.verdict, dev_report::Verdict::Fail));
195    }
196}