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
135pub(crate) fn diff_reports(current: &Report, baseline: &Report, opts: &DiffOptions) -> Diff {
136 let curr_idx: BTreeMap<&str, &CheckResult> = index_first(¤t.checks);
138 let base_idx: BTreeMap<&str, &CheckResult> = index_first(&baseline.checks);
139
140 let mut newly_failing = Vec::new();
141 let mut newly_passing = Vec::new();
142 let mut severity_changes = Vec::new();
143 let mut duration_regressions = Vec::new();
144 let mut added = Vec::new();
145 let mut removed = Vec::new();
146
147 for (name, c) in &curr_idx {
150 match base_idx.get(name) {
151 None => {
152 added.push((*name).to_string());
153 if c.verdict == Verdict::Fail {
154 newly_failing.push((*name).to_string());
155 }
156 if c.verdict == Verdict::Pass {
157 newly_passing.push((*name).to_string());
158 }
159 }
160 Some(b) => {
161 if c.verdict == Verdict::Fail && b.verdict != Verdict::Fail {
162 newly_failing.push((*name).to_string());
163 }
164 if c.verdict == Verdict::Pass && b.verdict != Verdict::Pass {
165 newly_passing.push((*name).to_string());
166 }
167 if c.severity != b.severity {
168 severity_changes.push(SeverityChange {
169 name: (*name).to_string(),
170 from: b.severity,
171 to: c.severity,
172 });
173 }
174 if let Some(reg) = duration_regression(name, b, c, opts) {
175 duration_regressions.push(reg);
176 }
177 }
178 }
179 }
180
181 for name in base_idx.keys() {
183 if !curr_idx.contains_key(name) {
184 removed.push((*name).to_string());
185 }
186 }
187
188 Diff {
190 newly_failing,
191 newly_passing,
192 severity_changes,
193 duration_regressions,
194 added,
195 removed,
196 }
197}
198
199fn index_first(checks: &[CheckResult]) -> BTreeMap<&str, &CheckResult> {
200 let mut map = BTreeMap::new();
201 for c in checks {
202 map.entry(c.name.as_str()).or_insert(c);
203 }
204 map
205}
206
207fn duration_regression(
208 name: &str,
209 baseline: &CheckResult,
210 current: &CheckResult,
211 opts: &DiffOptions,
212) -> Option<DurationRegression> {
213 let base = baseline.duration_ms?;
214 let curr = current.duration_ms?;
215 if curr <= base {
216 return None;
217 }
218 let delta_ms = curr - base;
219 let mut flagged = false;
220
221 if let Some(abs) = opts.duration_regression_abs_ms {
222 if delta_ms > abs {
223 flagged = true;
224 }
225 }
226 if let Some(pct) = opts.duration_regression_pct {
227 let allowed = base as f64 * (1.0 + pct / 100.0);
228 if (curr as f64) > allowed {
229 flagged = true;
230 }
231 }
232 if !flagged {
233 return None;
234 }
235 let delta_pct = if base == 0 {
236 f64::INFINITY
237 } else {
238 (delta_ms as f64 / base as f64) * 100.0
239 };
240 Some(DurationRegression {
241 name: name.to_string(),
242 baseline_ms: base,
243 current_ms: curr,
244 delta_pct,
245 })
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::{CheckResult, Report, Severity};
252
253 fn r(name: &str, version: &str) -> Report {
254 Report::new(name, version)
255 }
256
257 #[test]
258 fn identical_reports_are_clean() {
259 let mut a = r("c", "0.1.0");
260 a.push(CheckResult::pass("x"));
261 a.push(CheckResult::pass("y").with_duration_ms(10));
262 let b = a.clone();
263 let d = diff_reports(&a, &b, &DiffOptions::default());
264 assert!(d.is_clean());
265 }
266
267 #[test]
268 fn newly_failing_detected() {
269 let mut prev = r("c", "0.1.0");
270 prev.push(CheckResult::pass("a"));
271 let mut curr = r("c", "0.1.0");
272 curr.push(CheckResult::fail("a", Severity::Error));
273 let d = diff_reports(&curr, &prev, &DiffOptions::default());
274 assert_eq!(d.newly_failing, vec!["a".to_string()]);
275 }
276
277 #[test]
278 fn newly_passing_detected() {
279 let mut prev = r("c", "0.1.0");
280 prev.push(CheckResult::fail("a", Severity::Error));
281 let mut curr = r("c", "0.1.0");
282 curr.push(CheckResult::pass("a"));
283 let d = diff_reports(&curr, &prev, &DiffOptions::default());
284 assert_eq!(d.newly_passing, vec!["a".to_string()]);
285 }
286
287 #[test]
288 fn added_and_removed_detected() {
289 let mut prev = r("c", "0.1.0");
290 prev.push(CheckResult::pass("a"));
291 prev.push(CheckResult::pass("gone"));
292 let mut curr = r("c", "0.1.0");
293 curr.push(CheckResult::pass("a"));
294 curr.push(CheckResult::pass("new"));
295 let d = diff_reports(&curr, &prev, &DiffOptions::default());
296 assert_eq!(d.added, vec!["new".to_string()]);
297 assert_eq!(d.removed, vec!["gone".to_string()]);
298 }
299
300 #[test]
301 fn severity_change_detected() {
302 let mut prev = r("c", "0.1.0");
303 prev.push(CheckResult::warn("a", Severity::Warning));
304 let mut curr = r("c", "0.1.0");
305 curr.push(CheckResult::warn("a", Severity::Error));
306 let d = diff_reports(&curr, &prev, &DiffOptions::default());
307 assert_eq!(d.severity_changes.len(), 1);
308 assert_eq!(d.severity_changes[0].name, "a");
309 assert_eq!(d.severity_changes[0].from, Some(Severity::Warning));
310 assert_eq!(d.severity_changes[0].to, Some(Severity::Error));
311 }
312
313 #[test]
314 fn duration_regression_pct_threshold() {
315 let mut prev = r("c", "0.1.0");
316 prev.push(CheckResult::pass("a").with_duration_ms(100));
317 let mut curr = r("c", "0.1.0");
318 curr.push(CheckResult::pass("a").with_duration_ms(150));
319 let d = diff_reports(
320 &curr,
321 &prev,
322 &DiffOptions {
323 duration_regression_pct: Some(20.0),
324 duration_regression_abs_ms: None,
325 },
326 );
327 assert_eq!(d.duration_regressions.len(), 1);
328 let reg = &d.duration_regressions[0];
329 assert_eq!(reg.name, "a");
330 assert_eq!(reg.baseline_ms, 100);
331 assert_eq!(reg.current_ms, 150);
332 assert!((reg.delta_pct - 50.0).abs() < 0.0001);
333 }
334
335 #[test]
336 fn duration_regression_below_threshold_ignored() {
337 let mut prev = r("c", "0.1.0");
338 prev.push(CheckResult::pass("a").with_duration_ms(100));
339 let mut curr = r("c", "0.1.0");
340 curr.push(CheckResult::pass("a").with_duration_ms(105));
341 let d = diff_reports(
342 &curr,
343 &prev,
344 &DiffOptions {
345 duration_regression_pct: Some(20.0),
346 duration_regression_abs_ms: None,
347 },
348 );
349 assert!(d.duration_regressions.is_empty());
350 }
351
352 #[test]
353 fn duration_regression_abs_threshold() {
354 let mut prev = r("c", "0.1.0");
355 prev.push(CheckResult::pass("a").with_duration_ms(100));
356 let mut curr = r("c", "0.1.0");
357 curr.push(CheckResult::pass("a").with_duration_ms(120));
358 let d = diff_reports(
359 &curr,
360 &prev,
361 &DiffOptions {
362 duration_regression_pct: None,
363 duration_regression_abs_ms: Some(10),
364 },
365 );
366 assert_eq!(d.duration_regressions.len(), 1);
367 }
368
369 #[test]
370 fn duration_regression_speedup_ignored() {
371 let mut prev = r("c", "0.1.0");
372 prev.push(CheckResult::pass("a").with_duration_ms(100));
373 let mut curr = r("c", "0.1.0");
374 curr.push(CheckResult::pass("a").with_duration_ms(50));
375 let d = diff_reports(&curr, &prev, &DiffOptions::default());
376 assert!(d.duration_regressions.is_empty());
377 }
378
379 #[test]
380 fn diff_is_deterministic() {
381 let mut prev = r("c", "0.1.0");
382 prev.push(CheckResult::pass("z"));
383 prev.push(CheckResult::pass("a"));
384 prev.push(CheckResult::pass("m"));
385 let mut curr = r("c", "0.1.0");
386 curr.push(CheckResult::fail("z", Severity::Error));
387 curr.push(CheckResult::fail("m", Severity::Error));
388 curr.push(CheckResult::pass("a"));
389 let d1 = diff_reports(&curr, &prev, &DiffOptions::default());
390 let d2 = diff_reports(&curr, &prev, &DiffOptions::default());
391 assert_eq!(d1, d2);
392 assert_eq!(d1.newly_failing, vec!["m".to_string(), "z".to_string()]);
394 }
395
396 #[test]
397 fn diff_round_trips_through_json() {
398 let mut prev = r("c", "0.1.0");
399 prev.push(CheckResult::pass("a"));
400 let mut curr = r("c", "0.1.0");
401 curr.push(CheckResult::fail("a", Severity::Error));
402 let d = diff_reports(&curr, &prev, &DiffOptions::default());
403 let json = serde_json::to_string(&d).unwrap();
404 let back: Diff = serde_json::from_str(&json).unwrap();
405 assert_eq!(d, back);
406 }
407}