dev_coverage/producer.rs
1//! [`Producer`] integration: wrap a [`CoverageRun`] + [`CoverageThreshold`]
2//! and emit a [`Report`] every time the producer runs.
3//!
4//! [`Producer`]: dev_report::Producer
5//! [`Report`]: dev_report::Report
6
7use dev_report::{CheckResult, Producer, Report, Severity};
8
9use crate::{Baseline, CoverageRun, CoverageThreshold};
10
11/// `Producer` adapter that runs a [`CoverageRun`], compares the result
12/// against the configured [`CoverageThreshold`], and (optionally) flags
13/// regressions against a stored [`Baseline`].
14///
15/// Subprocess failures map to a failing `CheckResult` named
16/// `coverage::<subject>` with `Severity::Critical`. No panics.
17///
18/// # Example
19///
20/// ```no_run
21/// use dev_coverage::{CoverageProducer, CoverageRun, CoverageThreshold};
22/// use dev_report::Producer;
23///
24/// let producer = CoverageProducer::new(
25/// CoverageRun::new("my-crate", "0.1.0"),
26/// CoverageThreshold::min_line_pct(80.0),
27/// );
28/// let report = producer.produce();
29/// println!("{}", report.to_json().unwrap());
30/// ```
31pub struct CoverageProducer {
32 run: CoverageRun,
33 threshold: CoverageThreshold,
34 baseline: Option<Baseline>,
35 regression_tolerance_pct: f64,
36}
37
38impl CoverageProducer {
39 /// Build a producer with a threshold only.
40 pub fn new(run: CoverageRun, threshold: CoverageThreshold) -> Self {
41 Self {
42 run,
43 threshold,
44 baseline: None,
45 regression_tolerance_pct: 0.0,
46 }
47 }
48
49 /// Compare each run against the given baseline. When the current
50 /// result regresses by more than `tolerance_pct`, a separate
51 /// `coverage::regression` check is pushed alongside the threshold check.
52 pub fn with_baseline(mut self, baseline: Baseline, tolerance_pct: f64) -> Self {
53 self.baseline = Some(baseline);
54 self.regression_tolerance_pct = tolerance_pct;
55 self
56 }
57}
58
59impl Producer for CoverageProducer {
60 fn produce(&self) -> Report {
61 let subject = self.run.subject().to_string();
62 let version = self.run.subject_version().to_string();
63 let mut report = Report::new(&subject, &version).with_producer("dev-coverage");
64 match self.run.execute() {
65 Ok(result) => {
66 if let Some(baseline) = &self.baseline {
67 let diff = result.diff(baseline, self.regression_tolerance_pct);
68 let detail = format!(
69 "line {:+.2}pp, function {:+.2}pp, region {:+.2}pp (tolerance {:.2}pp)",
70 diff.line_pct_delta,
71 diff.function_pct_delta,
72 diff.region_pct_delta,
73 self.regression_tolerance_pct
74 );
75 let regression_check = if diff.regressed {
76 CheckResult::fail(
77 format!("coverage::regression::{}", subject),
78 Severity::Error,
79 )
80 .with_detail(detail)
81 .with_tag("coverage")
82 .with_tag("regression")
83 } else {
84 CheckResult::pass(format!("coverage::regression::{}", subject))
85 .with_detail(detail)
86 .with_tag("coverage")
87 };
88 report.push(regression_check);
89 }
90 report.push(result.into_check_result(self.threshold));
91 }
92 Err(e) => {
93 let detail = e.to_string();
94 let check = CheckResult::fail(format!("coverage::{}", subject), Severity::Critical)
95 .with_detail(detail)
96 .with_tag("coverage")
97 .with_tag("subprocess");
98 report.push(check);
99 }
100 }
101 report.finish();
102 report
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn produce_emits_subprocess_fail_when_tool_missing() {
112 // We can't easily mock `cargo llvm-cov` here, but if the tool
113 // happens to be installed the call succeeds and we just check
114 // that a non-empty report comes back. If absent, we get the
115 // failing critical check. Either path is acceptable for the
116 // contract: no panic, returns a Report.
117 let producer = CoverageProducer::new(
118 CoverageRun::new("self", "0.0.0"),
119 CoverageThreshold::min_line_pct(80.0),
120 );
121 let report = producer.produce();
122 assert_eq!(report.subject, "self");
123 assert!(!report.checks.is_empty());
124 }
125}