Skip to main content

dev_report/
diff.rs

1//! Report diffing. Available unconditionally (no feature gate).
2//!
3//! Compare two reports and surface differences a CI gate or AI agent
4//! can act on: newly failing/passing checks, severity changes, duration
5//! regressions, added/removed checks.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{CheckResult, Report, Severity, Verdict};
12
13/// Options controlling diff sensitivity.
14///
15/// # Example
16///
17/// ```
18/// use dev_report::DiffOptions;
19///
20/// let opts = DiffOptions {
21///     duration_regression_pct: Some(50.0),
22///     duration_regression_abs_ms: Some(100),
23/// };
24/// assert_eq!(opts.duration_regression_pct, Some(50.0));
25/// ```
26#[derive(Debug, Clone, PartialEq)]
27pub struct DiffOptions {
28    /// Flag a duration regression when `current_ms` exceeds
29    /// `baseline_ms * (1 + pct / 100)`. `None` disables percent-based
30    /// detection.
31    pub duration_regression_pct: Option<f64>,
32    /// Flag a duration regression when `current_ms - baseline_ms`
33    /// exceeds this absolute number of milliseconds. `None` disables
34    /// absolute-threshold detection.
35    pub duration_regression_abs_ms: Option<u64>,
36}
37
38impl Default for DiffOptions {
39    fn default() -> Self {
40        Self {
41            duration_regression_pct: Some(20.0),
42            duration_regression_abs_ms: None,
43        }
44    }
45}
46
47/// A change in severity for a check that exists in both reports.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct SeverityChange {
50    /// Check name.
51    pub name: String,
52    /// Severity in the baseline report.
53    pub from: Option<Severity>,
54    /// Severity in the current report.
55    pub to: Option<Severity>,
56}
57
58/// A duration regression for a check that exists in both reports.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct DurationRegression {
61    /// Check name.
62    pub name: String,
63    /// Baseline duration, in milliseconds.
64    pub baseline_ms: u64,
65    /// Current duration, in milliseconds.
66    pub current_ms: u64,
67    /// Percent slower than baseline (e.g. `25.0` for 25% slower).
68    pub delta_pct: f64,
69}
70
71/// Result of comparing two [`Report`]s.
72///
73/// All vectors are sorted alphabetically by check name so two diffs of
74/// the same input pair produce equal `Diff` values.
75///
76/// # Example
77///
78/// ```
79/// use dev_report::{CheckResult, Report, Severity};
80///
81/// let mut prev = Report::new("crate", "0.1.0");
82/// prev.push(CheckResult::pass("a"));
83/// prev.push(CheckResult::pass("b"));
84///
85/// let mut curr = Report::new("crate", "0.1.0");
86/// curr.push(CheckResult::pass("a"));
87/// curr.push(CheckResult::fail("b", Severity::Error));
88/// curr.push(CheckResult::pass("c"));
89///
90/// let diff = curr.diff(&prev);
91/// assert_eq!(diff.newly_failing, vec!["b".to_string()]);
92/// assert_eq!(diff.added, vec!["c".to_string()]);
93/// ```
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct Diff {
96    /// Checks that are `Fail` in current but were not `Fail` in baseline
97    /// (or did not exist in baseline).
98    pub newly_failing: Vec<String>,
99    /// Checks that are `Pass` in current but were not `Pass` in baseline.
100    pub newly_passing: Vec<String>,
101    /// Severity transitions for checks present in both reports.
102    pub severity_changes: Vec<SeverityChange>,
103    /// Duration regressions for checks present in both reports, where
104    /// the slowdown exceeds the configured threshold.
105    pub duration_regressions: Vec<DurationRegression>,
106    /// Checks present in current but not in baseline.
107    pub added: Vec<String>,
108    /// Checks present in baseline but not in current.
109    pub removed: Vec<String>,
110}
111
112impl Diff {
113    /// `true` if the diff contains no differences worth flagging.
114    ///
115    /// # Example
116    ///
117    /// ```
118    /// use dev_report::{CheckResult, Report};
119    ///
120    /// let mut a = Report::new("c", "0.1.0");
121    /// a.push(CheckResult::pass("x"));
122    /// let b = a.clone();
123    /// assert!(a.diff(&b).is_clean());
124    /// ```
125    pub fn is_clean(&self) -> bool {
126        self.newly_failing.is_empty()
127            && self.newly_passing.is_empty()
128            && self.severity_changes.is_empty()
129            && self.duration_regressions.is_empty()
130            && self.added.is_empty()
131            && self.removed.is_empty()
132    }
133}
134
135pub(crate) fn diff_reports(current: &Report, baseline: &Report, opts: &DiffOptions) -> Diff {
136    // First-occurrence-wins indexing on check name.
137    let curr_idx: BTreeMap<&str, &CheckResult> = index_first(&current.checks);
138    let base_idx: BTreeMap<&str, &CheckResult> = index_first(&baseline.checks);
139
140    let mut newly_failing = Vec::new();
141    let mut newly_passing = Vec::new();
142    let mut severity_changes = Vec::new();
143    let mut duration_regressions = Vec::new();
144    let mut added = Vec::new();
145    let mut removed = Vec::new();
146
147    // Walk current to find: newly failing, newly passing, severity
148    // changes, duration regressions, added.
149    for (name, c) in &curr_idx {
150        match base_idx.get(name) {
151            None => {
152                added.push((*name).to_string());
153                if c.verdict == Verdict::Fail {
154                    newly_failing.push((*name).to_string());
155                }
156                if c.verdict == Verdict::Pass {
157                    newly_passing.push((*name).to_string());
158                }
159            }
160            Some(b) => {
161                if c.verdict == Verdict::Fail && b.verdict != Verdict::Fail {
162                    newly_failing.push((*name).to_string());
163                }
164                if c.verdict == Verdict::Pass && b.verdict != Verdict::Pass {
165                    newly_passing.push((*name).to_string());
166                }
167                if c.severity != b.severity {
168                    severity_changes.push(SeverityChange {
169                        name: (*name).to_string(),
170                        from: b.severity,
171                        to: c.severity,
172                    });
173                }
174                if let Some(reg) = duration_regression(name, b, c, opts) {
175                    duration_regressions.push(reg);
176                }
177            }
178        }
179    }
180
181    // Walk baseline to find removed.
182    for name in base_idx.keys() {
183        if !curr_idx.contains_key(name) {
184            removed.push((*name).to_string());
185        }
186    }
187
188    // BTreeMap iteration is alphabetical; vectors are already sorted.
189    Diff {
190        newly_failing,
191        newly_passing,
192        severity_changes,
193        duration_regressions,
194        added,
195        removed,
196    }
197}
198
199fn index_first(checks: &[CheckResult]) -> BTreeMap<&str, &CheckResult> {
200    let mut map = BTreeMap::new();
201    for c in checks {
202        map.entry(c.name.as_str()).or_insert(c);
203    }
204    map
205}
206
207fn duration_regression(
208    name: &str,
209    baseline: &CheckResult,
210    current: &CheckResult,
211    opts: &DiffOptions,
212) -> Option<DurationRegression> {
213    let base = baseline.duration_ms?;
214    let curr = current.duration_ms?;
215    if curr <= base {
216        return None;
217    }
218    let delta_ms = curr - base;
219    let mut flagged = false;
220
221    if let Some(abs) = opts.duration_regression_abs_ms {
222        if delta_ms > abs {
223            flagged = true;
224        }
225    }
226    if let Some(pct) = opts.duration_regression_pct {
227        let allowed = base as f64 * (1.0 + pct / 100.0);
228        if (curr as f64) > allowed {
229            flagged = true;
230        }
231    }
232    if !flagged {
233        return None;
234    }
235    let delta_pct = if base == 0 {
236        f64::INFINITY
237    } else {
238        (delta_ms as f64 / base as f64) * 100.0
239    };
240    Some(DurationRegression {
241        name: name.to_string(),
242        baseline_ms: base,
243        current_ms: curr,
244        delta_pct,
245    })
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::{CheckResult, Report, Severity};
252
253    fn r(name: &str, version: &str) -> Report {
254        Report::new(name, version)
255    }
256
257    #[test]
258    fn identical_reports_are_clean() {
259        let mut a = r("c", "0.1.0");
260        a.push(CheckResult::pass("x"));
261        a.push(CheckResult::pass("y").with_duration_ms(10));
262        let b = a.clone();
263        let d = diff_reports(&a, &b, &DiffOptions::default());
264        assert!(d.is_clean());
265    }
266
267    #[test]
268    fn newly_failing_detected() {
269        let mut prev = r("c", "0.1.0");
270        prev.push(CheckResult::pass("a"));
271        let mut curr = r("c", "0.1.0");
272        curr.push(CheckResult::fail("a", Severity::Error));
273        let d = diff_reports(&curr, &prev, &DiffOptions::default());
274        assert_eq!(d.newly_failing, vec!["a".to_string()]);
275    }
276
277    #[test]
278    fn newly_passing_detected() {
279        let mut prev = r("c", "0.1.0");
280        prev.push(CheckResult::fail("a", Severity::Error));
281        let mut curr = r("c", "0.1.0");
282        curr.push(CheckResult::pass("a"));
283        let d = diff_reports(&curr, &prev, &DiffOptions::default());
284        assert_eq!(d.newly_passing, vec!["a".to_string()]);
285    }
286
287    #[test]
288    fn added_and_removed_detected() {
289        let mut prev = r("c", "0.1.0");
290        prev.push(CheckResult::pass("a"));
291        prev.push(CheckResult::pass("gone"));
292        let mut curr = r("c", "0.1.0");
293        curr.push(CheckResult::pass("a"));
294        curr.push(CheckResult::pass("new"));
295        let d = diff_reports(&curr, &prev, &DiffOptions::default());
296        assert_eq!(d.added, vec!["new".to_string()]);
297        assert_eq!(d.removed, vec!["gone".to_string()]);
298    }
299
300    #[test]
301    fn severity_change_detected() {
302        let mut prev = r("c", "0.1.0");
303        prev.push(CheckResult::warn("a", Severity::Warning));
304        let mut curr = r("c", "0.1.0");
305        curr.push(CheckResult::warn("a", Severity::Error));
306        let d = diff_reports(&curr, &prev, &DiffOptions::default());
307        assert_eq!(d.severity_changes.len(), 1);
308        assert_eq!(d.severity_changes[0].name, "a");
309        assert_eq!(d.severity_changes[0].from, Some(Severity::Warning));
310        assert_eq!(d.severity_changes[0].to, Some(Severity::Error));
311    }
312
313    #[test]
314    fn duration_regression_pct_threshold() {
315        let mut prev = r("c", "0.1.0");
316        prev.push(CheckResult::pass("a").with_duration_ms(100));
317        let mut curr = r("c", "0.1.0");
318        curr.push(CheckResult::pass("a").with_duration_ms(150));
319        let d = diff_reports(
320            &curr,
321            &prev,
322            &DiffOptions {
323                duration_regression_pct: Some(20.0),
324                duration_regression_abs_ms: None,
325            },
326        );
327        assert_eq!(d.duration_regressions.len(), 1);
328        let reg = &d.duration_regressions[0];
329        assert_eq!(reg.name, "a");
330        assert_eq!(reg.baseline_ms, 100);
331        assert_eq!(reg.current_ms, 150);
332        assert!((reg.delta_pct - 50.0).abs() < 0.0001);
333    }
334
335    #[test]
336    fn duration_regression_below_threshold_ignored() {
337        let mut prev = r("c", "0.1.0");
338        prev.push(CheckResult::pass("a").with_duration_ms(100));
339        let mut curr = r("c", "0.1.0");
340        curr.push(CheckResult::pass("a").with_duration_ms(105));
341        let d = diff_reports(
342            &curr,
343            &prev,
344            &DiffOptions {
345                duration_regression_pct: Some(20.0),
346                duration_regression_abs_ms: None,
347            },
348        );
349        assert!(d.duration_regressions.is_empty());
350    }
351
352    #[test]
353    fn duration_regression_abs_threshold() {
354        let mut prev = r("c", "0.1.0");
355        prev.push(CheckResult::pass("a").with_duration_ms(100));
356        let mut curr = r("c", "0.1.0");
357        curr.push(CheckResult::pass("a").with_duration_ms(120));
358        let d = diff_reports(
359            &curr,
360            &prev,
361            &DiffOptions {
362                duration_regression_pct: None,
363                duration_regression_abs_ms: Some(10),
364            },
365        );
366        assert_eq!(d.duration_regressions.len(), 1);
367    }
368
369    #[test]
370    fn duration_regression_speedup_ignored() {
371        let mut prev = r("c", "0.1.0");
372        prev.push(CheckResult::pass("a").with_duration_ms(100));
373        let mut curr = r("c", "0.1.0");
374        curr.push(CheckResult::pass("a").with_duration_ms(50));
375        let d = diff_reports(&curr, &prev, &DiffOptions::default());
376        assert!(d.duration_regressions.is_empty());
377    }
378
379    #[test]
380    fn diff_is_deterministic() {
381        let mut prev = r("c", "0.1.0");
382        prev.push(CheckResult::pass("z"));
383        prev.push(CheckResult::pass("a"));
384        prev.push(CheckResult::pass("m"));
385        let mut curr = r("c", "0.1.0");
386        curr.push(CheckResult::fail("z", Severity::Error));
387        curr.push(CheckResult::fail("m", Severity::Error));
388        curr.push(CheckResult::pass("a"));
389        let d1 = diff_reports(&curr, &prev, &DiffOptions::default());
390        let d2 = diff_reports(&curr, &prev, &DiffOptions::default());
391        assert_eq!(d1, d2);
392        // Names sorted alphabetically.
393        assert_eq!(d1.newly_failing, vec!["m".to_string(), "z".to_string()]);
394    }
395
396    #[test]
397    fn diff_round_trips_through_json() {
398        let mut prev = r("c", "0.1.0");
399        prev.push(CheckResult::pass("a"));
400        let mut curr = r("c", "0.1.0");
401        curr.push(CheckResult::fail("a", Severity::Error));
402        let d = diff_reports(&curr, &prev, &DiffOptions::default());
403        let json = serde_json::to_string(&d).unwrap();
404        let back: Diff = serde_json::from_str(&json).unwrap();
405        assert_eq!(d, back);
406    }
407}