crates/nornir-coverage/src/diff.rs
//! # Coverage diff — the regression gate
//!
//! [`CoverageReport`](super::CoverageReport) answers *"what fraction of the
//! source ran?"* for ONE test run. A CI gate needs the orthogonal *"did that
//! fraction DROP versus the baseline?"* — a coverage **regression** verdict.
//!
//! This module is the pure fold that turns two reports (an `old` baseline and a
//! `new` run) into a [`CoverageDiff`]: an overall before→after [`CoverageDelta`],
//! one delta per crate present in BOTH reports, and the sets of crates that were
//! added / removed. From that a consumer asks the gate questions —
//! [`CoverageDiff::is_regression`] (the boolean CI gate), [`CoverageDiff::regressions`]
//! (what dropped), [`CoverageDiff::worst`] (the biggest drop) — and renders a
//! compact PR-comment table with [`CoverageDiff::to_markdown`].
//!
//! It is `std` + `serde` only, has no I/O, and would suit any third consumer that
//! keeps a coverage baseline (a CI job, a docs bencher, a release gate) — so it
//! lives in the leaf, not the conductor (L5a). The *storage* of the baseline
//! (warehouse / a checked-in JSON) stays with whoever owns it.
use serde::{Deserialize, Serialize};
use super::CoverageReport;
/// One label's `old → new` coverage delta (the whole repo `"overall"`, or a
/// single crate). Percentages are 0.0..100.0, matching [`CoverageReport`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CoverageDelta {
/// `"overall"` for the whole-repo roll-up, else the crate name.
pub label: String,
/// Line coverage % in the OLD (baseline) report.
pub line_pct_before: f64,
/// Line coverage % in the NEW report.
pub line_pct_after: f64,
/// Region coverage % in the OLD (baseline) report.
pub region_pct_before: f64,
/// Region coverage % in the NEW report.
pub region_pct_after: f64,
}
impl CoverageDelta {
/// Signed change in line coverage (percentage points): `after − before`.
/// Negative ⇒ coverage dropped.
pub fn line_pct_delta(&self) -> f64 {
self.line_pct_after - self.line_pct_before
}
/// Signed change in region coverage (percentage points): `after − before`.
pub fn region_pct_delta(&self) -> f64 {
self.region_pct_after - self.region_pct_before
}
/// Regressed ⇒ **line** coverage dropped by MORE than `tolerance` points.
/// `tolerance = 0.0` makes any drop a regression; a small tolerance (e.g.
/// `0.5`) absorbs measurement noise. This is the headline maven number, so
/// the default gate verdict keys on lines; see [`Self::region_regressed`].
pub fn regressed(&self, tolerance: f64) -> bool {
self.line_pct_delta() < -tolerance
}
/// Regressed ⇒ **region** coverage dropped by more than `tolerance` points.
pub fn region_regressed(&self, tolerance: f64) -> bool {
self.region_pct_delta() < -tolerance
}
/// Improved ⇒ line coverage rose by more than `epsilon` points.
pub fn improved(&self, epsilon: f64) -> bool {
self.line_pct_delta() > epsilon
}
}
/// The full `old → new` diff of two coverage reports: an overall delta, per-crate
/// deltas (crates in BOTH reports), and the added / removed crate sets.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CoverageDiff {
/// The repo the diff covers (taken from the NEW report).
pub repo: String,
/// Whole-repo line/region % before → after.
pub overall: CoverageDelta,
/// One delta per crate present in BOTH reports, sorted by label. A crate only
/// in one side can't be compared, so it is listed in
/// [`Self::added_crates`] / [`Self::removed_crates`] instead.
pub crates: Vec<CoverageDelta>,
/// Crate names in the NEW report but not the OLD (new code — never a
/// regression, nothing to compare against).
pub added_crates: Vec<String>,
/// Crate names in the OLD report but not the NEW (code that went away).
pub removed_crates: Vec<String>,
}
impl CoverageDiff {
/// Fold `old` (baseline) and `new` into a diff. The overall roll-up is always
/// compared; per-crate deltas are matched by crate name.
pub fn between(old: &CoverageReport, new: &CoverageReport) -> Self {
use std::collections::BTreeMap;
let old_by_crate: BTreeMap<&str, &super::CrateCoverage> =
old.crates.iter().map(|c| (c.krate.as_str(), c)).collect();
let new_by_crate: BTreeMap<&str, &super::CrateCoverage> =
new.crates.iter().map(|c| (c.krate.as_str(), c)).collect();
let mut crates = Vec::new();
for (name, nc) in &new_by_crate {
if let Some(oc) = old_by_crate.get(name) {
crates.push(CoverageDelta {
label: (*name).to_string(),
line_pct_before: oc.line_pct(),
line_pct_after: nc.line_pct(),
region_pct_before: oc.region_pct(),
region_pct_after: nc.region_pct(),
});
}
}
// BTreeMap iteration is already sorted by label; keep it explicit.
crates.sort_by(|a, b| a.label.cmp(&b.label));
let added_crates: Vec<String> = new_by_crate
.keys()
.filter(|k| !old_by_crate.contains_key(*k))
.map(|k| (*k).to_string())
.collect();
let removed_crates: Vec<String> = old_by_crate
.keys()
.filter(|k| !new_by_crate.contains_key(*k))
.map(|k| (*k).to_string())
.collect();
let overall = CoverageDelta {
label: "overall".to_string(),
line_pct_before: old.line_pct(),
line_pct_after: new.line_pct(),
region_pct_before: old.region_pct(),
region_pct_after: new.region_pct(),
};
CoverageDiff { repo: new.repo.clone(), overall, crates, added_crates, removed_crates }
}
/// Every delta — overall first, then crates — that regressed by more than
/// `tolerance` line-% points. Empty ⇒ no regression.
pub fn regressions(&self, tolerance: f64) -> Vec<&CoverageDelta> {
let mut out = Vec::new();
if self.overall.regressed(tolerance) {
out.push(&self.overall);
}
out.extend(self.crates.iter().filter(|d| d.regressed(tolerance)));
out
}
/// The CI gate verdict: did the overall roll-up OR any crate regress beyond
/// `tolerance`? `true` ⇒ fail the gate.
pub fn is_regression(&self, tolerance: f64) -> bool {
self.overall.regressed(tolerance) || self.crates.iter().any(|d| d.regressed(tolerance))
}
/// The single worst line-coverage drop across overall + crates (most negative
/// delta), if anything dropped at all. `None` when nothing regressed.
pub fn worst(&self) -> Option<&CoverageDelta> {
std::iter::once(&self.overall)
.chain(self.crates.iter())
.filter(|d| d.line_pct_delta() < 0.0)
.min_by(|a, b| a.line_pct_delta().total_cmp(&b.line_pct_delta()))
}
/// A compact markdown table (overall + per-crate) for a PR comment or the
/// terminal. Plain numbers, one decimal, signed deltas; a `⚠` marks a crate
/// that regressed beyond `tolerance`. Added / removed crates are footnoted.
pub fn to_markdown(&self, tolerance: f64) -> String {
let mut s = String::new();
s.push_str(&format!("### coverage diff — {}\n\n", self.repo));
s.push_str("| scope | line % (old → new) | Δ line | region % (old → new) | Δ region |\n");
s.push_str("|---|---|---|---|---|\n");
let row = |d: &CoverageDelta| {
let flag = if d.regressed(tolerance) { " ⚠" } else { "" };
format!(
"| {}{} | {:.1} → {:.1} | {:+.1} | {:.1} → {:.1} | {:+.1} |\n",
d.label,
flag,
d.line_pct_before,
d.line_pct_after,
d.line_pct_delta(),
d.region_pct_before,
d.region_pct_after,
d.region_pct_delta(),
)
};
s.push_str(&row(&self.overall));
for d in &self.crates {
s.push_str(&row(d));
}
if !self.added_crates.is_empty() {
s.push_str(&format!("\n_added crates (no baseline): {}_\n", self.added_crates.join(", ")));
}
if !self.removed_crates.is_empty() {
s.push_str(&format!("\n_removed crates: {}_\n", self.removed_crates.join(", ")));
}
s
}
}
impl CoverageReport {
/// Diff this report (the NEW run) against an `old` baseline. Convenience for
/// [`CoverageDiff::between`] read the natural way: `new.diff(&old)`.
pub fn diff(&self, old: &CoverageReport) -> CoverageDiff {
CoverageDiff::between(old, self)
}
}
#[cfg(test)]
mod tests {
use super::super::{summarise, CoverageReport, FileCoverage};
use super::*;
/// Build a one-file report for crate `krate` at a chosen line & region cover.
fn report(repo: &str, krate: &str, lines: u64, lines_cov: u64, regions: u64, regions_cov: u64) -> CoverageReport {
let file = FileCoverage {
file: format!("{krate}/src/lib.rs"),
krate: krate.to_string(),
lines,
lines_covered: lines_cov,
regions,
regions_covered: regions_cov,
functions: vec![],
};
summarise(repo, vec![file]).unwrap()
}
#[test]
fn delta_signs_and_regression_threshold() {
// 80% → 60% line coverage: a 20-point drop.
let d = CoverageDelta {
label: "k".into(),
line_pct_before: 80.0,
line_pct_after: 60.0,
region_pct_before: 50.0,
region_pct_after: 55.0,
};
assert!((d.line_pct_delta() - -20.0).abs() < 1e-9);
assert!((d.region_pct_delta() - 5.0).abs() < 1e-9);
// RED-when-broken: a 20pt drop MUST be a regression at any tolerance < 20.
assert!(d.regressed(0.0), "any drop regresses at tol 0");
assert!(d.regressed(19.9));
assert!(!d.regressed(20.0), "exactly-at tolerance is not a regression");
assert!(!d.regressed(25.0), "within tolerance is not a regression");
// Region rose, so it never regresses; and the line drop is not an
// improvement.
assert!(!d.region_regressed(0.0));
assert!(!d.improved(0.0));
}
#[test]
fn between_matches_crates_and_flags_add_remove() {
// old: crate `a` at 50% lines, crate `gone` at 100%.
let mut old = report("r", "a", 10, 5, 4, 2);
old.crates.push(super::super::CrateCoverage {
krate: "gone".into(),
lines: 4,
lines_covered: 4,
regions: 4,
regions_covered: 4,
files: 1,
});
old.lines += 4;
old.lines_covered += 4;
old.regions += 4;
old.regions_covered += 4;
// new: crate `a` DROPPED to 20% lines, plus a brand-new crate `fresh`.
let mut new = report("r", "a", 10, 2, 4, 1);
new.crates.push(super::super::CrateCoverage {
krate: "fresh".into(),
lines: 2,
lines_covered: 2,
regions: 2,
regions_covered: 2,
files: 1,
});
new.lines += 2;
new.lines_covered += 2;
new.regions += 2;
new.regions_covered += 2;
let diff = new.diff(&old);
assert_eq!(diff.repo, "r");
// Only `a` is in both reports → exactly one per-crate delta.
assert_eq!(diff.crates.len(), 1);
assert_eq!(diff.crates[0].label, "a");
assert!((diff.crates[0].line_pct_before - 50.0).abs() < 1e-9);
assert!((diff.crates[0].line_pct_after - 20.0).abs() < 1e-9);
// `fresh` was added, `gone` was removed.
assert_eq!(diff.added_crates, vec!["fresh"]);
assert_eq!(diff.removed_crates, vec!["gone"]);
}
#[test]
fn regression_gate_verdict_and_worst() {
// Overall drops (old 9/13 ≈ 69% → new 6/12 = 50%), crate `a` drops hard.
let old = report("r", "a", 10, 8, 4, 3);
let new = report("r", "a", 10, 5, 4, 2);
let diff = new.diff(&old);
// RED-when-broken: this IS a regression at a 1-point tolerance.
assert!(diff.is_regression(1.0), "overall+crate coverage dropped");
let regs = diff.regressions(1.0);
assert!(!regs.is_empty());
assert!(regs.iter().any(|d| d.label == "overall"));
assert!(regs.iter().any(|d| d.label == "a"));
// The worst drop is reported and is negative.
let worst = diff.worst().expect("something dropped");
assert!(worst.line_pct_delta() < 0.0);
// A no-change diff is NOT a regression and has no worst.
let same = new.diff(&new);
assert!(!same.is_regression(0.0), "identical reports never regress");
assert!(same.regressions(0.0).is_empty());
assert!(same.worst().is_none());
}
#[test]
fn markdown_table_renders_overall_and_flags_regression() {
let old = report("r", "a", 10, 8, 4, 4);
let new = report("r", "a", 10, 4, 4, 2);
let md = new.diff(&old).to_markdown(0.0);
assert!(md.contains("### coverage diff — r"), "{md}");
assert!(md.contains("| overall"), "{md}");
// The regressed crate row carries the ⚠ flag and a signed negative delta.
assert!(md.contains("⚠"), "regression flagged in table: {md}");
assert!(md.contains("-40.0"), "signed line delta present: {md}");
}
}