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    /// One-line summary of the diff suitable for log output or CI status.
135    ///
136    /// Returns `"clean"` when [`is_clean`](Self::is_clean) is true; otherwise
137    /// returns a comma-separated list of non-empty categories with counts,
138    /// e.g. `"2 newly failing, 1 added, 1 duration regression"`.
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use dev_report::{CheckResult, Report, Severity};
144    ///
145    /// // Identical reports -> "clean"
146    /// let mut a = Report::new("c", "0.1.0");
147    /// a.push(CheckResult::pass("x"));
148    /// assert_eq!(a.diff(&a).summary(), "clean");
149    ///
150    /// // Mixed differences -> comma-separated counts.
151    /// let mut prev = Report::new("c", "0.1.0");
152    /// prev.push(CheckResult::pass("a"));
153    /// let mut curr = Report::new("c", "0.1.0");
154    /// curr.push(CheckResult::fail("a", Severity::Error));
155    /// let s = curr.diff(&prev).summary();
156    /// assert!(s.contains("1 newly failing"));
157    /// assert!(s.contains("1 severity change"));
158    /// ```
159    pub fn summary(&self) -> String {
160        if self.is_clean() {
161            return "clean".to_string();
162        }
163        let mut parts = Vec::new();
164        if !self.newly_failing.is_empty() {
165            parts.push(format!("{} newly failing", self.newly_failing.len()));
166        }
167        if !self.newly_passing.is_empty() {
168            parts.push(format!("{} newly passing", self.newly_passing.len()));
169        }
170        if !self.severity_changes.is_empty() {
171            parts.push(format!(
172                "{} severity {}",
173                self.severity_changes.len(),
174                if self.severity_changes.len() == 1 {
175                    "change"
176                } else {
177                    "changes"
178                }
179            ));
180        }
181        if !self.duration_regressions.is_empty() {
182            parts.push(format!(
183                "{} duration {}",
184                self.duration_regressions.len(),
185                if self.duration_regressions.len() == 1 {
186                    "regression"
187                } else {
188                    "regressions"
189                }
190            ));
191        }
192        if !self.added.is_empty() {
193            parts.push(format!("{} added", self.added.len()));
194        }
195        if !self.removed.is_empty() {
196            parts.push(format!("{} removed", self.removed.len()));
197        }
198        parts.join(", ")
199    }
200
201    /// Render this diff as a TTY-friendly string. Monochrome.
202    ///
203    /// Available with the `terminal` feature.
204    #[cfg(feature = "terminal")]
205    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
206    pub fn to_terminal(&self) -> String {
207        crate::terminal::diff_to_terminal(self)
208    }
209
210    /// Render this diff with ANSI color codes.
211    ///
212    /// Available with the `terminal` feature.
213    #[cfg(feature = "terminal")]
214    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
215    pub fn to_terminal_color(&self) -> String {
216        crate::terminal::diff_to_terminal_color(self)
217    }
218
219    /// Render this diff to a Markdown string.
220    ///
221    /// Available with the `markdown` feature.
222    #[cfg(feature = "markdown")]
223    #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
224    pub fn to_markdown(&self) -> String {
225        crate::markdown::diff_to_markdown(self)
226    }
227}
228
229pub(crate) fn diff_reports(current: &Report, baseline: &Report, opts: &DiffOptions) -> Diff {
230    // First-occurrence-wins indexing on check name.
231    let curr_idx: BTreeMap<&str, &CheckResult> = index_first(&current.checks);
232    let base_idx: BTreeMap<&str, &CheckResult> = index_first(&baseline.checks);
233
234    let mut newly_failing = Vec::new();
235    let mut newly_passing = Vec::new();
236    let mut severity_changes = Vec::new();
237    let mut duration_regressions = Vec::new();
238    let mut added = Vec::new();
239    let mut removed = Vec::new();
240
241    // Walk current to find: newly failing, newly passing, severity
242    // changes, duration regressions, added.
243    for (name, c) in &curr_idx {
244        match base_idx.get(name) {
245            None => {
246                added.push((*name).to_string());
247                if c.verdict == Verdict::Fail {
248                    newly_failing.push((*name).to_string());
249                }
250                if c.verdict == Verdict::Pass {
251                    newly_passing.push((*name).to_string());
252                }
253            }
254            Some(b) => {
255                if c.verdict == Verdict::Fail && b.verdict != Verdict::Fail {
256                    newly_failing.push((*name).to_string());
257                }
258                if c.verdict == Verdict::Pass && b.verdict != Verdict::Pass {
259                    newly_passing.push((*name).to_string());
260                }
261                if c.severity != b.severity {
262                    severity_changes.push(SeverityChange {
263                        name: (*name).to_string(),
264                        from: b.severity,
265                        to: c.severity,
266                    });
267                }
268                if let Some(reg) = duration_regression(name, b, c, opts) {
269                    duration_regressions.push(reg);
270                }
271            }
272        }
273    }
274
275    // Walk baseline to find removed.
276    for name in base_idx.keys() {
277        if !curr_idx.contains_key(name) {
278            removed.push((*name).to_string());
279        }
280    }
281
282    // BTreeMap iteration is alphabetical; vectors are already sorted.
283    Diff {
284        newly_failing,
285        newly_passing,
286        severity_changes,
287        duration_regressions,
288        added,
289        removed,
290    }
291}
292
293fn index_first(checks: &[CheckResult]) -> BTreeMap<&str, &CheckResult> {
294    let mut map = BTreeMap::new();
295    for c in checks {
296        map.entry(c.name.as_str()).or_insert(c);
297    }
298    map
299}
300
301fn duration_regression(
302    name: &str,
303    baseline: &CheckResult,
304    current: &CheckResult,
305    opts: &DiffOptions,
306) -> Option<DurationRegression> {
307    let base = baseline.duration_ms?;
308    let curr = current.duration_ms?;
309    if curr <= base {
310        return None;
311    }
312    let delta_ms = curr - base;
313    let mut flagged = false;
314
315    if let Some(abs) = opts.duration_regression_abs_ms {
316        if delta_ms > abs {
317            flagged = true;
318        }
319    }
320    if let Some(pct) = opts.duration_regression_pct {
321        let allowed = base as f64 * (1.0 + pct / 100.0);
322        if (curr as f64) > allowed {
323            flagged = true;
324        }
325    }
326    if !flagged {
327        return None;
328    }
329    let delta_pct = if base == 0 {
330        f64::INFINITY
331    } else {
332        (delta_ms as f64 / base as f64) * 100.0
333    };
334    Some(DurationRegression {
335        name: name.to_string(),
336        baseline_ms: base,
337        current_ms: curr,
338        delta_pct,
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::{CheckResult, Report, Severity};
346
347    fn r(name: &str, version: &str) -> Report {
348        Report::new(name, version)
349    }
350
351    #[test]
352    fn identical_reports_are_clean() {
353        let mut a = r("c", "0.1.0");
354        a.push(CheckResult::pass("x"));
355        a.push(CheckResult::pass("y").with_duration_ms(10));
356        let b = a.clone();
357        let d = diff_reports(&a, &b, &DiffOptions::default());
358        assert!(d.is_clean());
359    }
360
361    #[test]
362    fn newly_failing_detected() {
363        let mut prev = r("c", "0.1.0");
364        prev.push(CheckResult::pass("a"));
365        let mut curr = r("c", "0.1.0");
366        curr.push(CheckResult::fail("a", Severity::Error));
367        let d = diff_reports(&curr, &prev, &DiffOptions::default());
368        assert_eq!(d.newly_failing, vec!["a".to_string()]);
369    }
370
371    #[test]
372    fn newly_passing_detected() {
373        let mut prev = r("c", "0.1.0");
374        prev.push(CheckResult::fail("a", Severity::Error));
375        let mut curr = r("c", "0.1.0");
376        curr.push(CheckResult::pass("a"));
377        let d = diff_reports(&curr, &prev, &DiffOptions::default());
378        assert_eq!(d.newly_passing, vec!["a".to_string()]);
379    }
380
381    #[test]
382    fn added_and_removed_detected() {
383        let mut prev = r("c", "0.1.0");
384        prev.push(CheckResult::pass("a"));
385        prev.push(CheckResult::pass("gone"));
386        let mut curr = r("c", "0.1.0");
387        curr.push(CheckResult::pass("a"));
388        curr.push(CheckResult::pass("new"));
389        let d = diff_reports(&curr, &prev, &DiffOptions::default());
390        assert_eq!(d.added, vec!["new".to_string()]);
391        assert_eq!(d.removed, vec!["gone".to_string()]);
392    }
393
394    #[test]
395    fn severity_change_detected() {
396        let mut prev = r("c", "0.1.0");
397        prev.push(CheckResult::warn("a", Severity::Warning));
398        let mut curr = r("c", "0.1.0");
399        curr.push(CheckResult::warn("a", Severity::Error));
400        let d = diff_reports(&curr, &prev, &DiffOptions::default());
401        assert_eq!(d.severity_changes.len(), 1);
402        assert_eq!(d.severity_changes[0].name, "a");
403        assert_eq!(d.severity_changes[0].from, Some(Severity::Warning));
404        assert_eq!(d.severity_changes[0].to, Some(Severity::Error));
405    }
406
407    #[test]
408    fn duration_regression_pct_threshold() {
409        let mut prev = r("c", "0.1.0");
410        prev.push(CheckResult::pass("a").with_duration_ms(100));
411        let mut curr = r("c", "0.1.0");
412        curr.push(CheckResult::pass("a").with_duration_ms(150));
413        let d = diff_reports(
414            &curr,
415            &prev,
416            &DiffOptions {
417                duration_regression_pct: Some(20.0),
418                duration_regression_abs_ms: None,
419            },
420        );
421        assert_eq!(d.duration_regressions.len(), 1);
422        let reg = &d.duration_regressions[0];
423        assert_eq!(reg.name, "a");
424        assert_eq!(reg.baseline_ms, 100);
425        assert_eq!(reg.current_ms, 150);
426        assert!((reg.delta_pct - 50.0).abs() < 0.0001);
427    }
428
429    #[test]
430    fn duration_regression_below_threshold_ignored() {
431        let mut prev = r("c", "0.1.0");
432        prev.push(CheckResult::pass("a").with_duration_ms(100));
433        let mut curr = r("c", "0.1.0");
434        curr.push(CheckResult::pass("a").with_duration_ms(105));
435        let d = diff_reports(
436            &curr,
437            &prev,
438            &DiffOptions {
439                duration_regression_pct: Some(20.0),
440                duration_regression_abs_ms: None,
441            },
442        );
443        assert!(d.duration_regressions.is_empty());
444    }
445
446    #[test]
447    fn duration_regression_abs_threshold() {
448        let mut prev = r("c", "0.1.0");
449        prev.push(CheckResult::pass("a").with_duration_ms(100));
450        let mut curr = r("c", "0.1.0");
451        curr.push(CheckResult::pass("a").with_duration_ms(120));
452        let d = diff_reports(
453            &curr,
454            &prev,
455            &DiffOptions {
456                duration_regression_pct: None,
457                duration_regression_abs_ms: Some(10),
458            },
459        );
460        assert_eq!(d.duration_regressions.len(), 1);
461    }
462
463    #[test]
464    fn duration_regression_speedup_ignored() {
465        let mut prev = r("c", "0.1.0");
466        prev.push(CheckResult::pass("a").with_duration_ms(100));
467        let mut curr = r("c", "0.1.0");
468        curr.push(CheckResult::pass("a").with_duration_ms(50));
469        let d = diff_reports(&curr, &prev, &DiffOptions::default());
470        assert!(d.duration_regressions.is_empty());
471    }
472
473    #[test]
474    fn diff_is_deterministic() {
475        let mut prev = r("c", "0.1.0");
476        prev.push(CheckResult::pass("z"));
477        prev.push(CheckResult::pass("a"));
478        prev.push(CheckResult::pass("m"));
479        let mut curr = r("c", "0.1.0");
480        curr.push(CheckResult::fail("z", Severity::Error));
481        curr.push(CheckResult::fail("m", Severity::Error));
482        curr.push(CheckResult::pass("a"));
483        let d1 = diff_reports(&curr, &prev, &DiffOptions::default());
484        let d2 = diff_reports(&curr, &prev, &DiffOptions::default());
485        assert_eq!(d1, d2);
486        // Names sorted alphabetically.
487        assert_eq!(d1.newly_failing, vec!["m".to_string(), "z".to_string()]);
488    }
489
490    #[test]
491    fn diff_round_trips_through_json() {
492        let mut prev = r("c", "0.1.0");
493        prev.push(CheckResult::pass("a"));
494        let mut curr = r("c", "0.1.0");
495        curr.push(CheckResult::fail("a", Severity::Error));
496        let d = diff_reports(&curr, &prev, &DiffOptions::default());
497        let json = serde_json::to_string(&d).unwrap();
498        let back: Diff = serde_json::from_str(&json).unwrap();
499        assert_eq!(d, back);
500    }
501
502    #[test]
503    fn summary_reports_clean_when_identical() {
504        let mut a = r("c", "0.1.0");
505        a.push(CheckResult::pass("x"));
506        let b = a.clone();
507        assert_eq!(
508            diff_reports(&a, &b, &DiffOptions::default()).summary(),
509            "clean"
510        );
511    }
512
513    #[test]
514    fn summary_lists_all_categories() {
515        let mut prev = r("c", "0.1.0");
516        prev.push(CheckResult::fail("a", Severity::Error));
517        prev.push(CheckResult::pass("gone"));
518        let mut curr = r("c", "0.1.0");
519        curr.push(CheckResult::pass("a")); // newly_passing
520        curr.push(CheckResult::fail("b", Severity::Error)); // newly_failing + added
521        curr.push(CheckResult::pass("new")); // newly_passing + added
522
523        let d = diff_reports(&curr, &prev, &DiffOptions::default());
524        let s = d.summary();
525        assert!(s.contains("newly failing"));
526        assert!(s.contains("newly passing"));
527        assert!(s.contains("added"));
528        assert!(s.contains("removed"));
529    }
530
531    #[test]
532    fn summary_pluralizes_correctly() {
533        let mut prev = r("c", "0.1.0");
534        prev.push(CheckResult::warn("a", Severity::Warning));
535        let mut curr = r("c", "0.1.0");
536        curr.push(CheckResult::warn("a", Severity::Error));
537        // Single severity change -> singular.
538        let s = diff_reports(&curr, &prev, &DiffOptions::default()).summary();
539        assert!(s.contains("1 severity change"));
540        assert!(!s.contains("changes"));
541    }
542}