1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{CheckResult, Report, Severity, Verdict};
12
13#[derive(Debug, Clone, PartialEq)]
27pub struct DiffOptions {
28 pub duration_regression_pct: Option<f64>,
32 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct SeverityChange {
50 pub name: String,
52 pub from: Option<Severity>,
54 pub to: Option<Severity>,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct DurationRegression {
61 pub name: String,
63 pub baseline_ms: u64,
65 pub current_ms: u64,
67 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct Diff {
119 pub newly_failing: Vec<String>,
122 pub newly_passing: Vec<String>,
124 pub severity_changes: Vec<SeverityChange>,
126 pub duration_regressions: Vec<DurationRegression>,
129 pub added: Vec<String>,
131 pub removed: Vec<String>,
133}
134
135impl Diff {
136 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 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 #[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 #[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 #[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 let curr_idx: BTreeMap<&str, &CheckResult> = index_first(¤t.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 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 for name in base_idx.keys() {
300 if !curr_idx.contains_key(name) {
301 removed.push((*name).to_string());
302 }
303 }
304
305 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 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 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")); curr.push(CheckResult::fail("b", Severity::Error)); curr.push(CheckResult::pass("new")); 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 let s = diff_reports(&curr, &prev, &DiffOptions::default()).summary();
580 assert!(s.contains("1 severity change"));
581 assert!(!s.contains("changes"));
582 }
583}