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    ///
69    /// `f64::INFINITY` when the baseline duration was 0 ms. JSON has no
70    /// infinity, so that value is written as `null` and read back as
71    /// `f64::INFINITY`.
72    #[serde(serialize_with = "serialize_pct", deserialize_with = "deserialize_pct")]
73    pub delta_pct: f64,
74}
75
76fn serialize_pct<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
77where
78    S: serde::Serializer,
79{
80    if value.is_finite() {
81        serializer.serialize_f64(*value)
82    } else {
83        serializer.serialize_none()
84    }
85}
86
87fn deserialize_pct<'de, D>(deserializer: D) -> Result<f64, D::Error>
88where
89    D: serde::Deserializer<'de>,
90{
91    Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::INFINITY))
92}
93
94/// Result of comparing two [`Report`]s.
95///
96/// All vectors are sorted alphabetically by check name so two diffs of
97/// the same input pair produce equal `Diff` values.
98///
99/// # Example
100///
101/// ```
102/// use dev_report::{CheckResult, Report, Severity};
103///
104/// let mut prev = Report::new("crate", "0.1.0");
105/// prev.push(CheckResult::pass("a"));
106/// prev.push(CheckResult::pass("b"));
107///
108/// let mut curr = Report::new("crate", "0.1.0");
109/// curr.push(CheckResult::pass("a"));
110/// curr.push(CheckResult::fail("b", Severity::Error));
111/// curr.push(CheckResult::pass("c"));
112///
113/// let diff = curr.diff(&prev);
114/// assert_eq!(diff.newly_failing, vec!["b".to_string()]);
115/// assert_eq!(diff.added, vec!["c".to_string()]);
116/// ```
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct Diff {
119    /// Checks that are `Fail` in current but were not `Fail` in baseline
120    /// (or did not exist in baseline).
121    pub newly_failing: Vec<String>,
122    /// Checks that are `Pass` in current but were not `Pass` in baseline.
123    pub newly_passing: Vec<String>,
124    /// Severity transitions for checks present in both reports.
125    pub severity_changes: Vec<SeverityChange>,
126    /// Duration regressions for checks present in both reports, where
127    /// the slowdown exceeds the configured threshold.
128    pub duration_regressions: Vec<DurationRegression>,
129    /// Checks present in current but not in baseline.
130    pub added: Vec<String>,
131    /// Checks present in baseline but not in current.
132    pub removed: Vec<String>,
133}
134
135impl Diff {
136    /// `true` if the diff contains no differences worth flagging.
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// use dev_report::{CheckResult, Report};
142    ///
143    /// let mut a = Report::new("c", "0.1.0");
144    /// a.push(CheckResult::pass("x"));
145    /// let b = a.clone();
146    /// assert!(a.diff(&b).is_clean());
147    /// ```
148    pub fn is_clean(&self) -> bool {
149        self.newly_failing.is_empty()
150            && self.newly_passing.is_empty()
151            && self.severity_changes.is_empty()
152            && self.duration_regressions.is_empty()
153            && self.added.is_empty()
154            && self.removed.is_empty()
155    }
156
157    /// One-line summary of the diff suitable for log output or CI status.
158    ///
159    /// Returns `"clean"` when [`is_clean`](Self::is_clean) is true; otherwise
160    /// returns a comma-separated list of non-empty categories with counts,
161    /// e.g. `"2 newly failing, 1 added, 1 duration regression"`.
162    ///
163    /// # Example
164    ///
165    /// ```
166    /// use dev_report::{CheckResult, Report, Severity};
167    ///
168    /// // Identical reports -> "clean"
169    /// let mut a = Report::new("c", "0.1.0");
170    /// a.push(CheckResult::pass("x"));
171    /// assert_eq!(a.diff(&a).summary(), "clean");
172    ///
173    /// // Mixed differences -> comma-separated counts.
174    /// let mut prev = Report::new("c", "0.1.0");
175    /// prev.push(CheckResult::pass("a"));
176    /// let mut curr = Report::new("c", "0.1.0");
177    /// curr.push(CheckResult::fail("a", Severity::Error));
178    /// let s = curr.diff(&prev).summary();
179    /// assert!(s.contains("1 newly failing"));
180    /// assert!(s.contains("1 severity change"));
181    /// ```
182    pub fn summary(&self) -> String {
183        if self.is_clean() {
184            return "clean".to_string();
185        }
186        let mut parts = Vec::new();
187        if !self.newly_failing.is_empty() {
188            parts.push(format!("{} newly failing", self.newly_failing.len()));
189        }
190        if !self.newly_passing.is_empty() {
191            parts.push(format!("{} newly passing", self.newly_passing.len()));
192        }
193        if !self.severity_changes.is_empty() {
194            parts.push(format!(
195                "{} severity {}",
196                self.severity_changes.len(),
197                if self.severity_changes.len() == 1 {
198                    "change"
199                } else {
200                    "changes"
201                }
202            ));
203        }
204        if !self.duration_regressions.is_empty() {
205            parts.push(format!(
206                "{} duration {}",
207                self.duration_regressions.len(),
208                if self.duration_regressions.len() == 1 {
209                    "regression"
210                } else {
211                    "regressions"
212                }
213            ));
214        }
215        if !self.added.is_empty() {
216            parts.push(format!("{} added", self.added.len()));
217        }
218        if !self.removed.is_empty() {
219            parts.push(format!("{} removed", self.removed.len()));
220        }
221        parts.join(", ")
222    }
223
224    /// Render this diff as a TTY-friendly string. Monochrome.
225    ///
226    /// Available with the `terminal` feature.
227    #[cfg(feature = "terminal")]
228    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
229    pub fn to_terminal(&self) -> String {
230        crate::terminal::diff_to_terminal(self)
231    }
232
233    /// Render this diff with ANSI color codes.
234    ///
235    /// Available with the `terminal` feature.
236    #[cfg(feature = "terminal")]
237    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
238    pub fn to_terminal_color(&self) -> String {
239        crate::terminal::diff_to_terminal_color(self)
240    }
241
242    /// Render this diff to a Markdown string.
243    ///
244    /// Available with the `markdown` feature.
245    #[cfg(feature = "markdown")]
246    #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
247    pub fn to_markdown(&self) -> String {
248        crate::markdown::diff_to_markdown(self)
249    }
250}
251
252pub(crate) fn diff_reports(current: &Report, baseline: &Report, opts: &DiffOptions) -> Diff {
253    // First-occurrence-wins indexing on check name.
254    let curr_idx: BTreeMap<&str, &CheckResult> = index_first(&current.checks);
255    let base_idx: BTreeMap<&str, &CheckResult> = index_first(&baseline.checks);
256
257    let mut newly_failing = Vec::new();
258    let mut newly_passing = Vec::new();
259    let mut severity_changes = Vec::new();
260    let mut duration_regressions = Vec::new();
261    let mut added = Vec::new();
262    let mut removed = Vec::new();
263
264    // Walk current to find: newly failing, newly passing, severity
265    // changes, duration regressions, added.
266    for (name, c) in &curr_idx {
267        match base_idx.get(name) {
268            None => {
269                added.push((*name).to_string());
270                if c.verdict == Verdict::Fail {
271                    newly_failing.push((*name).to_string());
272                }
273                if c.verdict == Verdict::Pass {
274                    newly_passing.push((*name).to_string());
275                }
276            }
277            Some(b) => {
278                if c.verdict == Verdict::Fail && b.verdict != Verdict::Fail {
279                    newly_failing.push((*name).to_string());
280                }
281                if c.verdict == Verdict::Pass && b.verdict != Verdict::Pass {
282                    newly_passing.push((*name).to_string());
283                }
284                if c.severity != b.severity {
285                    severity_changes.push(SeverityChange {
286                        name: (*name).to_string(),
287                        from: b.severity,
288                        to: c.severity,
289                    });
290                }
291                if let Some(reg) = duration_regression(name, b, c, opts) {
292                    duration_regressions.push(reg);
293                }
294            }
295        }
296    }
297
298    // Walk baseline to find removed.
299    for name in base_idx.keys() {
300        if !curr_idx.contains_key(name) {
301            removed.push((*name).to_string());
302        }
303    }
304
305    // BTreeMap iteration is alphabetical; vectors are already sorted.
306    Diff {
307        newly_failing,
308        newly_passing,
309        severity_changes,
310        duration_regressions,
311        added,
312        removed,
313    }
314}
315
316fn index_first(checks: &[CheckResult]) -> BTreeMap<&str, &CheckResult> {
317    let mut map = BTreeMap::new();
318    for c in checks {
319        map.entry(c.name.as_str()).or_insert(c);
320    }
321    map
322}
323
324fn duration_regression(
325    name: &str,
326    baseline: &CheckResult,
327    current: &CheckResult,
328    opts: &DiffOptions,
329) -> Option<DurationRegression> {
330    let base = baseline.duration_ms?;
331    let curr = current.duration_ms?;
332    if curr <= base {
333        return None;
334    }
335    let delta_ms = curr - base;
336    let mut flagged = false;
337
338    if let Some(abs) = opts.duration_regression_abs_ms {
339        if delta_ms > abs {
340            flagged = true;
341        }
342    }
343    if let Some(pct) = opts.duration_regression_pct {
344        let allowed = base as f64 * (1.0 + pct / 100.0);
345        if (curr as f64) > allowed {
346            flagged = true;
347        }
348    }
349    if !flagged {
350        return None;
351    }
352    let delta_pct = if base == 0 {
353        f64::INFINITY
354    } else {
355        (delta_ms as f64 / base as f64) * 100.0
356    };
357    Some(DurationRegression {
358        name: name.to_string(),
359        baseline_ms: base,
360        current_ms: curr,
361        delta_pct,
362    })
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::{CheckResult, Report, Severity};
369
370    fn r(name: &str, version: &str) -> Report {
371        Report::new(name, version)
372    }
373
374    #[test]
375    fn identical_reports_are_clean() {
376        let mut a = r("c", "0.1.0");
377        a.push(CheckResult::pass("x"));
378        a.push(CheckResult::pass("y").with_duration_ms(10));
379        let b = a.clone();
380        let d = diff_reports(&a, &b, &DiffOptions::default());
381        assert!(d.is_clean());
382    }
383
384    #[test]
385    fn newly_failing_detected() {
386        let mut prev = r("c", "0.1.0");
387        prev.push(CheckResult::pass("a"));
388        let mut curr = r("c", "0.1.0");
389        curr.push(CheckResult::fail("a", Severity::Error));
390        let d = diff_reports(&curr, &prev, &DiffOptions::default());
391        assert_eq!(d.newly_failing, vec!["a".to_string()]);
392    }
393
394    #[test]
395    fn newly_passing_detected() {
396        let mut prev = r("c", "0.1.0");
397        prev.push(CheckResult::fail("a", Severity::Error));
398        let mut curr = r("c", "0.1.0");
399        curr.push(CheckResult::pass("a"));
400        let d = diff_reports(&curr, &prev, &DiffOptions::default());
401        assert_eq!(d.newly_passing, vec!["a".to_string()]);
402    }
403
404    #[test]
405    fn added_and_removed_detected() {
406        let mut prev = r("c", "0.1.0");
407        prev.push(CheckResult::pass("a"));
408        prev.push(CheckResult::pass("gone"));
409        let mut curr = r("c", "0.1.0");
410        curr.push(CheckResult::pass("a"));
411        curr.push(CheckResult::pass("new"));
412        let d = diff_reports(&curr, &prev, &DiffOptions::default());
413        assert_eq!(d.added, vec!["new".to_string()]);
414        assert_eq!(d.removed, vec!["gone".to_string()]);
415    }
416
417    #[test]
418    fn severity_change_detected() {
419        let mut prev = r("c", "0.1.0");
420        prev.push(CheckResult::warn("a", Severity::Warning));
421        let mut curr = r("c", "0.1.0");
422        curr.push(CheckResult::warn("a", Severity::Error));
423        let d = diff_reports(&curr, &prev, &DiffOptions::default());
424        assert_eq!(d.severity_changes.len(), 1);
425        assert_eq!(d.severity_changes[0].name, "a");
426        assert_eq!(d.severity_changes[0].from, Some(Severity::Warning));
427        assert_eq!(d.severity_changes[0].to, Some(Severity::Error));
428    }
429
430    #[test]
431    fn duration_regression_pct_threshold() {
432        let mut prev = r("c", "0.1.0");
433        prev.push(CheckResult::pass("a").with_duration_ms(100));
434        let mut curr = r("c", "0.1.0");
435        curr.push(CheckResult::pass("a").with_duration_ms(150));
436        let d = diff_reports(
437            &curr,
438            &prev,
439            &DiffOptions {
440                duration_regression_pct: Some(20.0),
441                duration_regression_abs_ms: None,
442            },
443        );
444        assert_eq!(d.duration_regressions.len(), 1);
445        let reg = &d.duration_regressions[0];
446        assert_eq!(reg.name, "a");
447        assert_eq!(reg.baseline_ms, 100);
448        assert_eq!(reg.current_ms, 150);
449        assert!((reg.delta_pct - 50.0).abs() < 0.0001);
450    }
451
452    #[test]
453    fn duration_regression_below_threshold_ignored() {
454        let mut prev = r("c", "0.1.0");
455        prev.push(CheckResult::pass("a").with_duration_ms(100));
456        let mut curr = r("c", "0.1.0");
457        curr.push(CheckResult::pass("a").with_duration_ms(105));
458        let d = diff_reports(
459            &curr,
460            &prev,
461            &DiffOptions {
462                duration_regression_pct: Some(20.0),
463                duration_regression_abs_ms: None,
464            },
465        );
466        assert!(d.duration_regressions.is_empty());
467    }
468
469    #[test]
470    fn duration_regression_abs_threshold() {
471        let mut prev = r("c", "0.1.0");
472        prev.push(CheckResult::pass("a").with_duration_ms(100));
473        let mut curr = r("c", "0.1.0");
474        curr.push(CheckResult::pass("a").with_duration_ms(120));
475        let d = diff_reports(
476            &curr,
477            &prev,
478            &DiffOptions {
479                duration_regression_pct: None,
480                duration_regression_abs_ms: Some(10),
481            },
482        );
483        assert_eq!(d.duration_regressions.len(), 1);
484    }
485
486    #[test]
487    fn duration_regression_speedup_ignored() {
488        let mut prev = r("c", "0.1.0");
489        prev.push(CheckResult::pass("a").with_duration_ms(100));
490        let mut curr = r("c", "0.1.0");
491        curr.push(CheckResult::pass("a").with_duration_ms(50));
492        let d = diff_reports(&curr, &prev, &DiffOptions::default());
493        assert!(d.duration_regressions.is_empty());
494    }
495
496    #[test]
497    fn diff_is_deterministic() {
498        let mut prev = r("c", "0.1.0");
499        prev.push(CheckResult::pass("z"));
500        prev.push(CheckResult::pass("a"));
501        prev.push(CheckResult::pass("m"));
502        let mut curr = r("c", "0.1.0");
503        curr.push(CheckResult::fail("z", Severity::Error));
504        curr.push(CheckResult::fail("m", Severity::Error));
505        curr.push(CheckResult::pass("a"));
506        let d1 = diff_reports(&curr, &prev, &DiffOptions::default());
507        let d2 = diff_reports(&curr, &prev, &DiffOptions::default());
508        assert_eq!(d1, d2);
509        // Names sorted alphabetically.
510        assert_eq!(d1.newly_failing, vec!["m".to_string(), "z".to_string()]);
511    }
512
513    #[test]
514    fn diff_round_trips_through_json() {
515        let mut prev = r("c", "0.1.0");
516        prev.push(CheckResult::pass("a"));
517        let mut curr = r("c", "0.1.0");
518        curr.push(CheckResult::fail("a", Severity::Error));
519        let d = diff_reports(&curr, &prev, &DiffOptions::default());
520        let json = serde_json::to_string(&d).unwrap();
521        let back: Diff = serde_json::from_str(&json).unwrap();
522        assert_eq!(d, back);
523    }
524
525    #[test]
526    fn zero_ms_baseline_regression_round_trips_through_json() {
527        // A 0 ms baseline gives an infinite delta. JSON has no infinity,
528        // so the diff must still serialize and parse back.
529        let mut prev = r("c", "0.1.0");
530        prev.push(CheckResult::pass("a").with_duration_ms(0));
531        let mut curr = r("c", "0.1.0");
532        curr.push(CheckResult::pass("a").with_duration_ms(5));
533        let d = diff_reports(&curr, &prev, &DiffOptions::default());
534        assert_eq!(d.duration_regressions.len(), 1);
535        assert!(d.duration_regressions[0].delta_pct.is_infinite());
536
537        let json = serde_json::to_string(&d).unwrap();
538        assert!(json.contains("\"delta_pct\":null"), "{json}");
539        let back: Diff = serde_json::from_str(&json).unwrap();
540        assert_eq!(d, back);
541    }
542
543    #[test]
544    fn summary_reports_clean_when_identical() {
545        let mut a = r("c", "0.1.0");
546        a.push(CheckResult::pass("x"));
547        let b = a.clone();
548        assert_eq!(
549            diff_reports(&a, &b, &DiffOptions::default()).summary(),
550            "clean"
551        );
552    }
553
554    #[test]
555    fn summary_lists_all_categories() {
556        let mut prev = r("c", "0.1.0");
557        prev.push(CheckResult::fail("a", Severity::Error));
558        prev.push(CheckResult::pass("gone"));
559        let mut curr = r("c", "0.1.0");
560        curr.push(CheckResult::pass("a")); // newly_passing
561        curr.push(CheckResult::fail("b", Severity::Error)); // newly_failing + added
562        curr.push(CheckResult::pass("new")); // newly_passing + added
563
564        let d = diff_reports(&curr, &prev, &DiffOptions::default());
565        let s = d.summary();
566        assert!(s.contains("newly failing"));
567        assert!(s.contains("newly passing"));
568        assert!(s.contains("added"));
569        assert!(s.contains("removed"));
570    }
571
572    #[test]
573    fn summary_pluralizes_correctly() {
574        let mut prev = r("c", "0.1.0");
575        prev.push(CheckResult::warn("a", Severity::Warning));
576        let mut curr = r("c", "0.1.0");
577        curr.push(CheckResult::warn("a", Severity::Error));
578        // Single severity change -> singular.
579        let s = diff_reports(&curr, &prev, &DiffOptions::default()).summary();
580        assert!(s.contains("1 severity change"));
581        assert!(!s.contains("changes"));
582    }
583}