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    /// Render this diff as a TTY-friendly string. Monochrome.
135    ///
136    /// Available with the `terminal` feature.
137    #[cfg(feature = "terminal")]
138    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
139    pub fn to_terminal(&self) -> String {
140        crate::terminal::diff_to_terminal(self)
141    }
142
143    /// Render this diff with ANSI color codes.
144    ///
145    /// Available with the `terminal` feature.
146    #[cfg(feature = "terminal")]
147    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
148    pub fn to_terminal_color(&self) -> String {
149        crate::terminal::diff_to_terminal_color(self)
150    }
151
152    /// Render this diff to a Markdown string.
153    ///
154    /// Available with the `markdown` feature.
155    #[cfg(feature = "markdown")]
156    #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
157    pub fn to_markdown(&self) -> String {
158        crate::markdown::diff_to_markdown(self)
159    }
160}
161
162pub(crate) fn diff_reports(current: &Report, baseline: &Report, opts: &DiffOptions) -> Diff {
163    // First-occurrence-wins indexing on check name.
164    let curr_idx: BTreeMap<&str, &CheckResult> = index_first(&current.checks);
165    let base_idx: BTreeMap<&str, &CheckResult> = index_first(&baseline.checks);
166
167    let mut newly_failing = Vec::new();
168    let mut newly_passing = Vec::new();
169    let mut severity_changes = Vec::new();
170    let mut duration_regressions = Vec::new();
171    let mut added = Vec::new();
172    let mut removed = Vec::new();
173
174    // Walk current to find: newly failing, newly passing, severity
175    // changes, duration regressions, added.
176    for (name, c) in &curr_idx {
177        match base_idx.get(name) {
178            None => {
179                added.push((*name).to_string());
180                if c.verdict == Verdict::Fail {
181                    newly_failing.push((*name).to_string());
182                }
183                if c.verdict == Verdict::Pass {
184                    newly_passing.push((*name).to_string());
185                }
186            }
187            Some(b) => {
188                if c.verdict == Verdict::Fail && b.verdict != Verdict::Fail {
189                    newly_failing.push((*name).to_string());
190                }
191                if c.verdict == Verdict::Pass && b.verdict != Verdict::Pass {
192                    newly_passing.push((*name).to_string());
193                }
194                if c.severity != b.severity {
195                    severity_changes.push(SeverityChange {
196                        name: (*name).to_string(),
197                        from: b.severity,
198                        to: c.severity,
199                    });
200                }
201                if let Some(reg) = duration_regression(name, b, c, opts) {
202                    duration_regressions.push(reg);
203                }
204            }
205        }
206    }
207
208    // Walk baseline to find removed.
209    for name in base_idx.keys() {
210        if !curr_idx.contains_key(name) {
211            removed.push((*name).to_string());
212        }
213    }
214
215    // BTreeMap iteration is alphabetical; vectors are already sorted.
216    Diff {
217        newly_failing,
218        newly_passing,
219        severity_changes,
220        duration_regressions,
221        added,
222        removed,
223    }
224}
225
226fn index_first(checks: &[CheckResult]) -> BTreeMap<&str, &CheckResult> {
227    let mut map = BTreeMap::new();
228    for c in checks {
229        map.entry(c.name.as_str()).or_insert(c);
230    }
231    map
232}
233
234fn duration_regression(
235    name: &str,
236    baseline: &CheckResult,
237    current: &CheckResult,
238    opts: &DiffOptions,
239) -> Option<DurationRegression> {
240    let base = baseline.duration_ms?;
241    let curr = current.duration_ms?;
242    if curr <= base {
243        return None;
244    }
245    let delta_ms = curr - base;
246    let mut flagged = false;
247
248    if let Some(abs) = opts.duration_regression_abs_ms {
249        if delta_ms > abs {
250            flagged = true;
251        }
252    }
253    if let Some(pct) = opts.duration_regression_pct {
254        let allowed = base as f64 * (1.0 + pct / 100.0);
255        if (curr as f64) > allowed {
256            flagged = true;
257        }
258    }
259    if !flagged {
260        return None;
261    }
262    let delta_pct = if base == 0 {
263        f64::INFINITY
264    } else {
265        (delta_ms as f64 / base as f64) * 100.0
266    };
267    Some(DurationRegression {
268        name: name.to_string(),
269        baseline_ms: base,
270        current_ms: curr,
271        delta_pct,
272    })
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::{CheckResult, Report, Severity};
279
280    fn r(name: &str, version: &str) -> Report {
281        Report::new(name, version)
282    }
283
284    #[test]
285    fn identical_reports_are_clean() {
286        let mut a = r("c", "0.1.0");
287        a.push(CheckResult::pass("x"));
288        a.push(CheckResult::pass("y").with_duration_ms(10));
289        let b = a.clone();
290        let d = diff_reports(&a, &b, &DiffOptions::default());
291        assert!(d.is_clean());
292    }
293
294    #[test]
295    fn newly_failing_detected() {
296        let mut prev = r("c", "0.1.0");
297        prev.push(CheckResult::pass("a"));
298        let mut curr = r("c", "0.1.0");
299        curr.push(CheckResult::fail("a", Severity::Error));
300        let d = diff_reports(&curr, &prev, &DiffOptions::default());
301        assert_eq!(d.newly_failing, vec!["a".to_string()]);
302    }
303
304    #[test]
305    fn newly_passing_detected() {
306        let mut prev = r("c", "0.1.0");
307        prev.push(CheckResult::fail("a", Severity::Error));
308        let mut curr = r("c", "0.1.0");
309        curr.push(CheckResult::pass("a"));
310        let d = diff_reports(&curr, &prev, &DiffOptions::default());
311        assert_eq!(d.newly_passing, vec!["a".to_string()]);
312    }
313
314    #[test]
315    fn added_and_removed_detected() {
316        let mut prev = r("c", "0.1.0");
317        prev.push(CheckResult::pass("a"));
318        prev.push(CheckResult::pass("gone"));
319        let mut curr = r("c", "0.1.0");
320        curr.push(CheckResult::pass("a"));
321        curr.push(CheckResult::pass("new"));
322        let d = diff_reports(&curr, &prev, &DiffOptions::default());
323        assert_eq!(d.added, vec!["new".to_string()]);
324        assert_eq!(d.removed, vec!["gone".to_string()]);
325    }
326
327    #[test]
328    fn severity_change_detected() {
329        let mut prev = r("c", "0.1.0");
330        prev.push(CheckResult::warn("a", Severity::Warning));
331        let mut curr = r("c", "0.1.0");
332        curr.push(CheckResult::warn("a", Severity::Error));
333        let d = diff_reports(&curr, &prev, &DiffOptions::default());
334        assert_eq!(d.severity_changes.len(), 1);
335        assert_eq!(d.severity_changes[0].name, "a");
336        assert_eq!(d.severity_changes[0].from, Some(Severity::Warning));
337        assert_eq!(d.severity_changes[0].to, Some(Severity::Error));
338    }
339
340    #[test]
341    fn duration_regression_pct_threshold() {
342        let mut prev = r("c", "0.1.0");
343        prev.push(CheckResult::pass("a").with_duration_ms(100));
344        let mut curr = r("c", "0.1.0");
345        curr.push(CheckResult::pass("a").with_duration_ms(150));
346        let d = diff_reports(
347            &curr,
348            &prev,
349            &DiffOptions {
350                duration_regression_pct: Some(20.0),
351                duration_regression_abs_ms: None,
352            },
353        );
354        assert_eq!(d.duration_regressions.len(), 1);
355        let reg = &d.duration_regressions[0];
356        assert_eq!(reg.name, "a");
357        assert_eq!(reg.baseline_ms, 100);
358        assert_eq!(reg.current_ms, 150);
359        assert!((reg.delta_pct - 50.0).abs() < 0.0001);
360    }
361
362    #[test]
363    fn duration_regression_below_threshold_ignored() {
364        let mut prev = r("c", "0.1.0");
365        prev.push(CheckResult::pass("a").with_duration_ms(100));
366        let mut curr = r("c", "0.1.0");
367        curr.push(CheckResult::pass("a").with_duration_ms(105));
368        let d = diff_reports(
369            &curr,
370            &prev,
371            &DiffOptions {
372                duration_regression_pct: Some(20.0),
373                duration_regression_abs_ms: None,
374            },
375        );
376        assert!(d.duration_regressions.is_empty());
377    }
378
379    #[test]
380    fn duration_regression_abs_threshold() {
381        let mut prev = r("c", "0.1.0");
382        prev.push(CheckResult::pass("a").with_duration_ms(100));
383        let mut curr = r("c", "0.1.0");
384        curr.push(CheckResult::pass("a").with_duration_ms(120));
385        let d = diff_reports(
386            &curr,
387            &prev,
388            &DiffOptions {
389                duration_regression_pct: None,
390                duration_regression_abs_ms: Some(10),
391            },
392        );
393        assert_eq!(d.duration_regressions.len(), 1);
394    }
395
396    #[test]
397    fn duration_regression_speedup_ignored() {
398        let mut prev = r("c", "0.1.0");
399        prev.push(CheckResult::pass("a").with_duration_ms(100));
400        let mut curr = r("c", "0.1.0");
401        curr.push(CheckResult::pass("a").with_duration_ms(50));
402        let d = diff_reports(&curr, &prev, &DiffOptions::default());
403        assert!(d.duration_regressions.is_empty());
404    }
405
406    #[test]
407    fn diff_is_deterministic() {
408        let mut prev = r("c", "0.1.0");
409        prev.push(CheckResult::pass("z"));
410        prev.push(CheckResult::pass("a"));
411        prev.push(CheckResult::pass("m"));
412        let mut curr = r("c", "0.1.0");
413        curr.push(CheckResult::fail("z", Severity::Error));
414        curr.push(CheckResult::fail("m", Severity::Error));
415        curr.push(CheckResult::pass("a"));
416        let d1 = diff_reports(&curr, &prev, &DiffOptions::default());
417        let d2 = diff_reports(&curr, &prev, &DiffOptions::default());
418        assert_eq!(d1, d2);
419        // Names sorted alphabetically.
420        assert_eq!(d1.newly_failing, vec!["m".to_string(), "z".to_string()]);
421    }
422
423    #[test]
424    fn diff_round_trips_through_json() {
425        let mut prev = r("c", "0.1.0");
426        prev.push(CheckResult::pass("a"));
427        let mut curr = r("c", "0.1.0");
428        curr.push(CheckResult::fail("a", Severity::Error));
429        let d = diff_reports(&curr, &prev, &DiffOptions::default());
430        let json = serde_json::to_string(&d).unwrap();
431        let back: Diff = serde_json::from_str(&json).unwrap();
432        assert_eq!(d, back);
433    }
434}