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 pub delta_pct: f64,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct Diff {
96 pub newly_failing: Vec<String>,
99 pub newly_passing: Vec<String>,
101 pub severity_changes: Vec<SeverityChange>,
103 pub duration_regressions: Vec<DurationRegression>,
106 pub added: Vec<String>,
108 pub removed: Vec<String>,
110}
111
112impl Diff {
113 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 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 #[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 #[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 #[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 let curr_idx: BTreeMap<&str, &CheckResult> = index_first(¤t.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 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 for name in base_idx.keys() {
277 if !curr_idx.contains_key(name) {
278 removed.push((*name).to_string());
279 }
280 }
281
282 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 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")); curr.push(CheckResult::fail("b", Severity::Error)); curr.push(CheckResult::pass("new")); 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 let s = diff_reports(&curr, &prev, &DiffOptions::default()).summary();
539 assert!(s.contains("1 severity change"));
540 assert!(!s.contains("changes"));
541 }
542}