1use crate::merge::CrapEntry;
17use anyhow::{Context, Result};
18use serde::Serialize;
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21
22pub const DEFAULT_EPSILON: f64 = 0.01;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum DeltaStatus {
31 Regressed,
33 Improved,
35 New,
37 Unchanged,
39 Moved,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct DeltaEntry {
50 #[serde(flatten)]
51 pub current: CrapEntry,
52 pub baseline_crap: Option<f64>,
54 pub delta: Option<f64>,
56 pub status: DeltaStatus,
57 #[serde(skip_serializing_if = "Option::is_none")]
61 pub previous_file: Option<PathBuf>,
62}
63
64#[derive(Debug, Clone, Serialize)]
66pub struct RemovedEntry {
67 pub function: String,
68 pub file: PathBuf,
69 pub baseline_crap: f64,
70}
71
72#[derive(Debug)]
74pub struct DeltaReport {
75 pub entries: Vec<DeltaEntry>,
77 pub removed: Vec<RemovedEntry>,
79}
80
81impl DeltaReport {
82 #[must_use]
84 pub fn regression_count(&self) -> usize {
85 self.entries
86 .iter()
87 .filter(|e| e.status == DeltaStatus::Regressed)
88 .count()
89 }
90}
91
92pub fn load_baseline(path: &Path) -> Result<Vec<CrapEntry>> {
94 let raw = std::fs::read_to_string(path)
95 .with_context(|| format!("reading baseline {}", path.display()))?;
96 let envelope: crate::report::Envelope = serde_json::from_str(&raw).with_context(|| {
97 format!(
98 "parsing baseline {} — must be JSON from `cargo crap --format json`",
99 path.display()
100 )
101 })?;
102 Ok(envelope.entries)
103}
104
105fn path_key(p: &Path) -> String {
106 p.to_string_lossy().replace('\\', "/")
107}
108
109fn classify_score(
111 delta: f64,
112 epsilon: f64,
113) -> DeltaStatus {
114 if delta > epsilon {
115 DeltaStatus::Regressed
116 } else if delta < -epsilon {
117 DeltaStatus::Improved
118 } else {
119 DeltaStatus::Unchanged
120 }
121}
122
123fn build_pass_one_entry(
127 e: &CrapEntry,
128 baseline_entry: Option<&CrapEntry>,
129 epsilon: f64,
130) -> DeltaEntry {
131 let (baseline_crap, delta, status) = match baseline_entry {
132 None => (None, None, DeltaStatus::New),
133 Some(b) => {
134 let d = e.crap - b.crap;
135 (Some(b.crap), Some(d), classify_score(d, epsilon))
136 },
137 };
138 DeltaEntry {
139 current: e.clone(),
140 baseline_crap,
141 delta,
142 status,
143 previous_file: None,
144 }
145}
146
147fn pass_one_exact(
151 current: &[CrapEntry],
152 baseline: &[CrapEntry],
153 epsilon: f64,
154) -> (Vec<DeltaEntry>, HashSet<(String, String)>) {
155 let baseline_index: HashMap<(String, String), &CrapEntry> = baseline
156 .iter()
157 .map(|e| ((path_key(&e.file), e.function.clone()), e))
158 .collect();
159 let mut matched: HashSet<(String, String)> = HashSet::new();
160 let entries = current
161 .iter()
162 .map(|e| {
163 let key = (path_key(&e.file), e.function.clone());
164 let baseline_entry = baseline_index.get(&key).copied();
165 if baseline_entry.is_some() {
166 matched.insert(key);
167 }
168 build_pass_one_entry(e, baseline_entry, epsilon)
169 })
170 .collect();
171 (entries, matched)
172}
173
174fn apply_move_pairing(
178 entry: &mut DeltaEntry,
179 baseline_entry: &CrapEntry,
180 epsilon: f64,
181) {
182 let d = entry.current.crap - baseline_entry.crap;
183 let score_status = classify_score(d, epsilon);
184 entry.baseline_crap = Some(baseline_entry.crap);
185 entry.delta = Some(d);
186 entry.previous_file = Some(baseline_entry.file.clone());
187 entry.status = match score_status {
188 DeltaStatus::Unchanged => DeltaStatus::Moved,
189 other => other,
190 };
191}
192
193fn pass_two_name_fallback(
196 entries: &mut [DeltaEntry],
197 baseline: &[CrapEntry],
198 matched: &mut HashSet<(String, String)>,
199 epsilon: f64,
200) {
201 let mut new_idx_by_name: HashMap<String, Vec<usize>> = HashMap::new();
202 for (i, de) in entries.iter().enumerate() {
203 if de.status == DeltaStatus::New {
204 new_idx_by_name
205 .entry(de.current.function.clone())
206 .or_default()
207 .push(i);
208 }
209 }
210 let mut baseline_unmatched_by_name: HashMap<String, Vec<&CrapEntry>> = HashMap::new();
211 for e in baseline {
212 let key = (path_key(&e.file), e.function.clone());
213 if !matched.contains(&key) {
214 baseline_unmatched_by_name
215 .entry(e.function.clone())
216 .or_default()
217 .push(e);
218 }
219 }
220 for (name, new_idxs) in &new_idx_by_name {
221 if new_idxs.len() != 1 {
222 continue;
223 }
224 let Some(baseline_group) = baseline_unmatched_by_name.get(name) else {
225 continue;
226 };
227 if baseline_group.len() != 1 {
228 continue;
229 }
230 let baseline_entry = baseline_group[0];
231 apply_move_pairing(&mut entries[new_idxs[0]], baseline_entry, epsilon);
232 matched.insert((
233 path_key(&baseline_entry.file),
234 baseline_entry.function.clone(),
235 ));
236 }
237}
238
239fn collect_removed(
241 baseline: &[CrapEntry],
242 matched: &HashSet<(String, String)>,
243) -> Vec<RemovedEntry> {
244 baseline
245 .iter()
246 .filter(|e| !matched.contains(&(path_key(&e.file), e.function.clone())))
247 .map(|e| RemovedEntry {
248 function: e.function.clone(),
249 file: e.file.clone(),
250 baseline_crap: e.crap,
251 })
252 .collect()
253}
254
255#[must_use]
274pub fn compute_delta(
275 current: &[CrapEntry],
276 baseline: &[CrapEntry],
277 epsilon: f64,
278) -> DeltaReport {
279 let (mut entries, mut matched) = pass_one_exact(current, baseline, epsilon);
280 pass_two_name_fallback(&mut entries, baseline, &mut matched, epsilon);
281 let removed = collect_removed(baseline, &matched);
282 DeltaReport { entries, removed }
283}
284
285#[cfg(test)]
286#[expect(
287 clippy::float_cmp,
288 reason = "CRAP-score deltas are deterministic floats; exact equality is the right comparison"
289)]
290mod tests {
291 use super::*;
292 use std::path::PathBuf;
293
294 fn entry(
295 function: &str,
296 crap: f64,
297 ) -> CrapEntry {
298 CrapEntry {
299 file: PathBuf::from("src/lib.rs"),
300 function: function.to_string(),
301 line: 1,
302 cyclomatic: 1.0,
303 coverage: Some(100.0),
304 crap,
305 crate_name: None,
306 }
307 }
308
309 #[test]
310 fn new_when_not_in_baseline() {
311 let report = compute_delta(&[entry("foo", 5.0)], &[], DEFAULT_EPSILON);
312 assert_eq!(report.entries[0].status, DeltaStatus::New);
313 assert!(report.entries[0].baseline_crap.is_none());
314 assert!(report.entries[0].delta.is_none());
315 }
316
317 #[test]
318 fn regressed_when_score_increased() {
319 let report = compute_delta(&[entry("foo", 10.0)], &[entry("foo", 5.0)], DEFAULT_EPSILON);
320 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
321 assert_eq!(report.entries[0].baseline_crap, Some(5.0));
322 assert!((report.entries[0].delta.unwrap() - 5.0).abs() < 1e-9);
323 }
324
325 #[test]
326 fn improved_when_score_decreased() {
327 let report = compute_delta(&[entry("foo", 3.0)], &[entry("foo", 8.0)], DEFAULT_EPSILON);
328 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
329 assert!((report.entries[0].delta.unwrap() + 5.0).abs() < 1e-9);
330 }
331
332 #[test]
333 fn unchanged_within_epsilon() {
334 let report = compute_delta(
335 &[entry("foo", 5.005)],
336 &[entry("foo", 5.0)],
337 DEFAULT_EPSILON,
338 );
339 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
340 }
341
342 #[test]
343 fn epsilon_boundary_regression_is_exclusive() {
344 let report = compute_delta(
352 &[entry("foo", DEFAULT_EPSILON)],
353 &[entry("foo", 0.0)],
354 DEFAULT_EPSILON,
355 );
356 assert_eq!(
357 report.entries[0].status,
358 DeltaStatus::Unchanged,
359 "delta == DEFAULT_EPSILON must be Unchanged, not Regressed"
360 );
361 }
362
363 #[test]
364 fn above_epsilon_is_regressed() {
365 let report = compute_delta(
368 &[entry("foo", DEFAULT_EPSILON + 0.001)],
369 &[entry("foo", 0.0)],
370 DEFAULT_EPSILON,
371 );
372 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
373 }
374
375 #[test]
376 fn epsilon_boundary_improvement_is_exclusive() {
377 let report = compute_delta(
381 &[entry("foo", 0.0)],
382 &[entry("foo", DEFAULT_EPSILON)],
383 DEFAULT_EPSILON,
384 );
385 assert_eq!(
386 report.entries[0].status,
387 DeltaStatus::Unchanged,
388 "delta == -DEFAULT_EPSILON must be Unchanged, not Improved"
389 );
390 }
391
392 #[test]
393 fn below_negative_epsilon_is_improved() {
394 let report = compute_delta(
397 &[entry("foo", 0.0)],
398 &[entry("foo", DEFAULT_EPSILON + 0.001)],
399 DEFAULT_EPSILON,
400 );
401 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
402 }
403
404 #[test]
405 fn removed_entries_identified() {
406 let report = compute_delta(
407 &[entry("bar", 2.0)],
408 &[entry("foo", 5.0), entry("bar", 2.0)],
409 DEFAULT_EPSILON,
410 );
411 assert_eq!(report.removed.len(), 1);
412 assert_eq!(report.removed[0].function, "foo");
413 assert_eq!(report.removed[0].baseline_crap, 5.0);
414 }
415
416 #[test]
417 fn regression_count_is_accurate() {
418 let current = vec![entry("foo", 10.0), entry("bar", 2.0), entry("baz", 1.0)];
419 let baseline = vec![entry("foo", 5.0), entry("bar", 8.0)];
420 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
422 assert_eq!(report.regression_count(), 1);
423 }
424
425 #[test]
426 fn empty_baseline_marks_everything_new() {
427 let current = vec![entry("a", 1.0), entry("b", 2.0)];
428 let report = compute_delta(¤t, &[], DEFAULT_EPSILON);
429 assert!(report.entries.iter().all(|e| e.status == DeltaStatus::New));
430 assert!(report.removed.is_empty());
431 }
432
433 #[test]
434 fn functions_in_different_files_pair_as_moved() {
435 let current = vec![CrapEntry {
444 file: PathBuf::from("src/lib.rs"),
445 function: "foo".into(),
446 line: 1,
447 cyclomatic: 1.0,
448 coverage: Some(100.0),
449 crap: 5.0,
450 crate_name: None,
451 }];
452 let baseline = vec![CrapEntry {
453 file: PathBuf::from("src/main.rs"), function: "foo".into(),
455 line: 1,
456 cyclomatic: 1.0,
457 coverage: Some(100.0),
458 crap: 5.0,
459 crate_name: None,
460 }];
461 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
462 assert_eq!(
463 report.entries[0].status,
464 DeltaStatus::Moved,
465 "foo unique on each side must pair as Moved"
466 );
467 assert_eq!(
468 report.entries[0].previous_file,
469 Some(PathBuf::from("src/main.rs")),
470 "previous_file must record the baseline location"
471 );
472 assert!(
473 report.removed.is_empty(),
474 "paired baseline entry must not appear as removed"
475 );
476 }
477
478 #[test]
479 fn backslash_paths_match_forward_slash_baseline() {
480 let current = vec![CrapEntry {
483 file: PathBuf::from("tests\\fixtures\\src\\lib.rs"),
484 function: "foo".into(),
485 line: 1,
486 cyclomatic: 1.0,
487 coverage: Some(100.0),
488 crap: 10.0,
489 crate_name: None,
490 }];
491 let baseline = vec![CrapEntry {
492 file: PathBuf::from("tests/fixtures/src/lib.rs"),
493 function: "foo".into(),
494 line: 1,
495 cyclomatic: 1.0,
496 coverage: Some(100.0),
497 crap: 5.0,
498 crate_name: None,
499 }];
500 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
501 assert_eq!(
502 report.entries[0].status,
503 DeltaStatus::Regressed,
504 "backslash path must match its forward-slash baseline counterpart"
505 );
506 assert!(report.removed.is_empty());
507 }
508
509 #[test]
512 fn custom_epsilon_zero_catches_sub_default_deltas() {
513 let report = compute_delta(&[entry("foo", 10.001)], &[entry("foo", 10.0)], 0.0);
516 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
517 }
518
519 #[test]
520 fn custom_epsilon_tolerates_drift_within_band() {
521 let report = compute_delta(&[entry("foo", 10.4)], &[entry("foo", 10.0)], 0.5);
524 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
525 }
526
527 #[test]
528 fn custom_epsilon_zero_is_strict_on_both_sides() {
529 let report = compute_delta(&[entry("foo", 9.999)], &[entry("foo", 10.0)], 0.0);
532 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
533 }
534
535 #[test]
538 fn load_baseline_accepts_wrapped_envelope() {
539 let dir = tempfile::tempdir().expect("tempdir");
541 let path = dir.path().join("wrapped.json");
542 std::fs::write(
543 &path,
544 r#"{"version":"0.0.2","entries":[{"file":"src/lib.rs","function":"foo","line":1,"cyclomatic":1.0,"coverage":100.0,"crap":1.0}]}"#,
545 )
546 .expect("write");
547 let entries = load_baseline(&path).expect("wrapped baseline must parse");
548 assert_eq!(entries.len(), 1);
549 assert_eq!(entries[0].function, "foo");
550 }
551
552 #[test]
553 fn load_baseline_rejects_bare_array() {
554 let dir = tempfile::tempdir().expect("tempdir");
555 let path = dir.path().join("legacy.json");
556 std::fs::write(
557 &path,
558 r#"[{"file":"src/lib.rs","function":"foo","line":1,"cyclomatic":1.0,"coverage":100.0,"crap":1.0}]"#,
559 )
560 .expect("write");
561 assert!(load_baseline(&path).is_err());
562 }
563
564 fn entry_in(
569 file: &str,
570 function: &str,
571 crap: f64,
572 ) -> CrapEntry {
573 CrapEntry {
574 file: PathBuf::from(file),
575 function: function.into(),
576 line: 1,
577 cyclomatic: 5.0,
578 coverage: Some(100.0),
579 crap,
580 crate_name: None,
581 }
582 }
583
584 #[test]
585 fn move_detected_for_unique_name_same_score() {
586 let baseline = vec![entry_in("src/old.rs", "render", 5.0)];
590 let current = vec![entry_in("src/new.rs", "render", 5.0)];
591 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
592 assert_eq!(report.entries[0].status, DeltaStatus::Moved);
593 assert_eq!(
594 report.entries[0].previous_file,
595 Some(PathBuf::from("src/old.rs"))
596 );
597 assert_eq!(report.entries[0].baseline_crap, Some(5.0));
598 assert!(report.removed.is_empty());
599 }
600
601 #[test]
602 fn moved_with_regression_keeps_regressed_status() {
603 let baseline = vec![entry_in("src/old.rs", "render", 5.0)];
607 let current = vec![entry_in("src/new.rs", "render", 12.0)];
608 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
609 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
610 assert_eq!(
611 report.entries[0].previous_file,
612 Some(PathBuf::from("src/old.rs"))
613 );
614 assert_eq!(report.entries[0].delta, Some(7.0));
615 assert_eq!(report.regression_count(), 1);
616 assert!(report.removed.is_empty());
617 }
618
619 #[test]
620 fn moved_with_improvement_keeps_improved_status() {
621 let baseline = vec![entry_in("src/old.rs", "render", 12.0)];
623 let current = vec![entry_in("src/new.rs", "render", 5.0)];
624 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
625 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
626 assert_eq!(
627 report.entries[0].previous_file,
628 Some(PathBuf::from("src/old.rs"))
629 );
630 }
631
632 #[test]
633 fn ambiguous_names_left_unpaired() {
634 let baseline = vec![
638 entry_in("src/a.rs", "helper", 5.0),
639 entry_in("src/b.rs", "helper", 5.0),
640 ];
641 let current = vec![
642 entry_in("src/c.rs", "helper", 5.0),
643 entry_in("src/d.rs", "helper", 5.0),
644 ];
645 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
646 assert_eq!(report.entries.len(), 2);
647 for de in &report.entries {
648 assert_eq!(de.status, DeltaStatus::New, "ambiguous → New");
649 assert!(
650 de.previous_file.is_none(),
651 "ambiguous → no previous_file pairing"
652 );
653 }
654 assert_eq!(report.removed.len(), 2, "both baseline entries are removed");
655 }
656
657 #[test]
658 fn truly_new_function_stays_new() {
659 let current = vec![entry_in("src/a.rs", "brand_new", 5.0)];
661 let baseline = vec![entry_in("src/a.rs", "something_else", 5.0)];
662 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
663 let new_entry = report
664 .entries
665 .iter()
666 .find(|e| e.current.function == "brand_new")
667 .expect("brand_new missing");
668 assert_eq!(new_entry.status, DeltaStatus::New);
669 assert!(new_entry.previous_file.is_none());
670 }
671
672 #[test]
673 fn truly_removed_function_stays_removed() {
674 let current = vec![entry_in("src/a.rs", "kept", 5.0)];
676 let baseline = vec![
677 entry_in("src/a.rs", "kept", 5.0),
678 entry_in("src/a.rs", "deleted", 8.0),
679 ];
680 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
681 assert_eq!(report.removed.len(), 1);
682 assert_eq!(report.removed[0].function, "deleted");
683 }
684
685 #[test]
686 fn exact_path_match_takes_precedence_over_name_fallback() {
687 let baseline = vec![
695 entry_in("src/a.rs", "foo", 5.0),
696 entry_in("src/b.rs", "foo", 7.0),
697 ];
698 let current = vec![entry_in("src/a.rs", "foo", 5.0)];
699 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
700 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
702 assert!(report.entries[0].previous_file.is_none());
703 assert_eq!(report.removed.len(), 1);
706 assert_eq!(report.removed[0].file, PathBuf::from("src/b.rs"));
707 }
708}