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 #[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 #[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 #[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 let curr_idx: BTreeMap<&str, &CheckResult> = index_first(¤t.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 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 for name in base_idx.keys() {
210 if !curr_idx.contains_key(name) {
211 removed.push((*name).to_string());
212 }
213 }
214
215 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 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}