Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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}");
    }
}