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
109#[derive(Hash, Eq, PartialEq)]
110struct EntryKey {
111 file: String,
112 function: String,
113 line: usize,
114}
115
116impl EntryKey {
117 fn new(e: &CrapEntry) -> Self {
118 Self {
119 file: path_key(&e.file),
120 function: e.function.clone(),
121 line: e.line,
122 }
123 }
124}
125
126fn classify_score(
128 delta: f64,
129 epsilon: f64,
130) -> DeltaStatus {
131 if delta > epsilon {
132 DeltaStatus::Regressed
133 } else if delta < -epsilon {
134 DeltaStatus::Improved
135 } else {
136 DeltaStatus::Unchanged
137 }
138}
139
140fn build_pass_one_entry(
144 e: &CrapEntry,
145 baseline_entry: Option<&CrapEntry>,
146 epsilon: f64,
147) -> DeltaEntry {
148 let (baseline_crap, delta, status) = match baseline_entry {
149 None => (None, None, DeltaStatus::New),
150 Some(b) => {
151 let d = e.crap - b.crap;
152 (Some(b.crap), Some(d), classify_score(d, epsilon))
153 },
154 };
155 DeltaEntry {
156 current: e.clone(),
157 baseline_crap,
158 delta,
159 status,
160 previous_file: None,
161 }
162}
163
164fn pass_one_exact(
168 current: &[CrapEntry],
169 baseline: &[CrapEntry],
170 epsilon: f64,
171) -> (Vec<DeltaEntry>, HashSet<EntryKey>) {
172 let baseline_index: HashMap<EntryKey, &CrapEntry> =
173 baseline.iter().map(|e| (EntryKey::new(e), e)).collect();
174 let mut matched: HashSet<EntryKey> = HashSet::new();
175 let entries = current
176 .iter()
177 .map(|e| {
178 let key = EntryKey::new(e);
179 let baseline_entry = baseline_index.get(&key).copied();
180 if baseline_entry.is_some() {
181 matched.insert(key);
182 }
183 build_pass_one_entry(e, baseline_entry, epsilon)
184 })
185 .collect();
186 (entries, matched)
187}
188
189fn apply_move_pairing(
193 entry: &mut DeltaEntry,
194 baseline_entry: &CrapEntry,
195 epsilon: f64,
196) {
197 let d = entry.current.crap - baseline_entry.crap;
198 let score_status = classify_score(d, epsilon);
199 entry.baseline_crap = Some(baseline_entry.crap);
200 entry.delta = Some(d);
201 entry.previous_file = Some(baseline_entry.file.clone());
202 entry.status = match score_status {
203 DeltaStatus::Unchanged => DeltaStatus::Moved,
204 other => other,
205 };
206}
207
208fn pass_two_name_fallback(
211 entries: &mut [DeltaEntry],
212 baseline: &[CrapEntry],
213 matched: &mut HashSet<EntryKey>,
214 epsilon: f64,
215) {
216 let mut new_idx_by_name: HashMap<String, Vec<usize>> = HashMap::new();
217 for (i, de) in entries.iter().enumerate() {
218 if de.status == DeltaStatus::New {
219 new_idx_by_name
220 .entry(de.current.function.clone())
221 .or_default()
222 .push(i);
223 }
224 }
225 let mut baseline_unmatched_by_name: HashMap<String, Vec<&CrapEntry>> = HashMap::new();
226 for e in baseline {
227 if !matched.contains(&EntryKey::new(e)) {
228 baseline_unmatched_by_name
229 .entry(e.function.clone())
230 .or_default()
231 .push(e);
232 }
233 }
234 for (name, new_idxs) in &new_idx_by_name {
235 if new_idxs.len() != 1 {
236 continue;
237 }
238 let Some(baseline_group) = baseline_unmatched_by_name.get(name) else {
239 continue;
240 };
241 if baseline_group.len() != 1 {
242 continue;
243 }
244 let baseline_entry = baseline_group[0];
245 apply_move_pairing(&mut entries[new_idxs[0]], baseline_entry, epsilon);
246 matched.insert(EntryKey::new(baseline_entry));
247 }
248}
249
250fn collect_removed(
252 baseline: &[CrapEntry],
253 matched: &HashSet<EntryKey>,
254) -> Vec<RemovedEntry> {
255 baseline
256 .iter()
257 .filter(|e| !matched.contains(&EntryKey::new(e)))
258 .map(|e| RemovedEntry {
259 function: e.function.clone(),
260 file: e.file.clone(),
261 baseline_crap: e.crap,
262 })
263 .collect()
264}
265
266#[must_use]
285pub fn compute_delta(
286 current: &[CrapEntry],
287 baseline: &[CrapEntry],
288 epsilon: f64,
289) -> DeltaReport {
290 let (mut entries, mut matched) = pass_one_exact(current, baseline, epsilon);
291 pass_two_name_fallback(&mut entries, baseline, &mut matched, epsilon);
292 let removed = collect_removed(baseline, &matched);
293 DeltaReport { entries, removed }
294}
295
296#[cfg(test)]
297#[expect(
298 clippy::float_cmp,
299 reason = "CRAP-score deltas are deterministic floats; exact equality is the right comparison"
300)]
301mod tests {
302 use super::*;
303 use std::path::PathBuf;
304
305 fn entry(
306 function: &str,
307 crap: f64,
308 ) -> CrapEntry {
309 CrapEntry {
310 file: PathBuf::from("src/lib.rs"),
311 function: function.to_string(),
312 line: 1,
313 cyclomatic: 1.0,
314 coverage: Some(100.0),
315 crap,
316 crate_name: None,
317 }
318 }
319
320 #[test]
321 fn new_when_not_in_baseline() {
322 let report = compute_delta(&[entry("foo", 5.0)], &[], DEFAULT_EPSILON);
323 assert_eq!(report.entries[0].status, DeltaStatus::New);
324 assert!(report.entries[0].baseline_crap.is_none());
325 assert!(report.entries[0].delta.is_none());
326 }
327
328 #[test]
329 fn regressed_when_score_increased() {
330 let report = compute_delta(&[entry("foo", 10.0)], &[entry("foo", 5.0)], DEFAULT_EPSILON);
331 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
332 assert_eq!(report.entries[0].baseline_crap, Some(5.0));
333 assert!((report.entries[0].delta.unwrap() - 5.0).abs() < 1e-9);
334 }
335
336 #[test]
337 fn improved_when_score_decreased() {
338 let report = compute_delta(&[entry("foo", 3.0)], &[entry("foo", 8.0)], DEFAULT_EPSILON);
339 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
340 assert!((report.entries[0].delta.unwrap() + 5.0).abs() < 1e-9);
341 }
342
343 #[test]
344 fn unchanged_within_epsilon() {
345 let report = compute_delta(
346 &[entry("foo", 5.005)],
347 &[entry("foo", 5.0)],
348 DEFAULT_EPSILON,
349 );
350 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
351 }
352
353 #[test]
354 fn epsilon_boundary_regression_is_exclusive() {
355 let report = compute_delta(
363 &[entry("foo", DEFAULT_EPSILON)],
364 &[entry("foo", 0.0)],
365 DEFAULT_EPSILON,
366 );
367 assert_eq!(
368 report.entries[0].status,
369 DeltaStatus::Unchanged,
370 "delta == DEFAULT_EPSILON must be Unchanged, not Regressed"
371 );
372 }
373
374 #[test]
375 fn above_epsilon_is_regressed() {
376 let report = compute_delta(
379 &[entry("foo", DEFAULT_EPSILON + 0.001)],
380 &[entry("foo", 0.0)],
381 DEFAULT_EPSILON,
382 );
383 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
384 }
385
386 #[test]
387 fn epsilon_boundary_improvement_is_exclusive() {
388 let report = compute_delta(
392 &[entry("foo", 0.0)],
393 &[entry("foo", DEFAULT_EPSILON)],
394 DEFAULT_EPSILON,
395 );
396 assert_eq!(
397 report.entries[0].status,
398 DeltaStatus::Unchanged,
399 "delta == -DEFAULT_EPSILON must be Unchanged, not Improved"
400 );
401 }
402
403 #[test]
404 fn below_negative_epsilon_is_improved() {
405 let report = compute_delta(
408 &[entry("foo", 0.0)],
409 &[entry("foo", DEFAULT_EPSILON + 0.001)],
410 DEFAULT_EPSILON,
411 );
412 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
413 }
414
415 #[test]
416 fn removed_entries_identified() {
417 let report = compute_delta(
418 &[entry("bar", 2.0)],
419 &[entry("foo", 5.0), entry("bar", 2.0)],
420 DEFAULT_EPSILON,
421 );
422 assert_eq!(report.removed.len(), 1);
423 assert_eq!(report.removed[0].function, "foo");
424 assert_eq!(report.removed[0].baseline_crap, 5.0);
425 }
426
427 #[test]
428 fn regression_count_is_accurate() {
429 let current = vec![entry("foo", 10.0), entry("bar", 2.0), entry("baz", 1.0)];
430 let baseline = vec![entry("foo", 5.0), entry("bar", 8.0)];
431 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
433 assert_eq!(report.regression_count(), 1);
434 }
435
436 #[test]
437 fn empty_baseline_marks_everything_new() {
438 let current = vec![entry("a", 1.0), entry("b", 2.0)];
439 let report = compute_delta(¤t, &[], DEFAULT_EPSILON);
440 assert!(report.entries.iter().all(|e| e.status == DeltaStatus::New));
441 assert!(report.removed.is_empty());
442 }
443
444 #[test]
445 fn functions_in_different_files_pair_as_moved() {
446 let current = vec![CrapEntry {
455 file: PathBuf::from("src/lib.rs"),
456 function: "foo".into(),
457 line: 1,
458 cyclomatic: 1.0,
459 coverage: Some(100.0),
460 crap: 5.0,
461 crate_name: None,
462 }];
463 let baseline = vec![CrapEntry {
464 file: PathBuf::from("src/main.rs"), function: "foo".into(),
466 line: 1,
467 cyclomatic: 1.0,
468 coverage: Some(100.0),
469 crap: 5.0,
470 crate_name: None,
471 }];
472 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
473 assert_eq!(
474 report.entries[0].status,
475 DeltaStatus::Moved,
476 "foo unique on each side must pair as Moved"
477 );
478 assert_eq!(
479 report.entries[0].previous_file,
480 Some(PathBuf::from("src/main.rs")),
481 "previous_file must record the baseline location"
482 );
483 assert!(
484 report.removed.is_empty(),
485 "paired baseline entry must not appear as removed"
486 );
487 }
488
489 #[test]
490 fn backslash_paths_match_forward_slash_baseline() {
491 let current = vec![CrapEntry {
494 file: PathBuf::from("tests\\fixtures\\src\\lib.rs"),
495 function: "foo".into(),
496 line: 1,
497 cyclomatic: 1.0,
498 coverage: Some(100.0),
499 crap: 10.0,
500 crate_name: None,
501 }];
502 let baseline = vec![CrapEntry {
503 file: PathBuf::from("tests/fixtures/src/lib.rs"),
504 function: "foo".into(),
505 line: 1,
506 cyclomatic: 1.0,
507 coverage: Some(100.0),
508 crap: 5.0,
509 crate_name: None,
510 }];
511 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
512 assert_eq!(
513 report.entries[0].status,
514 DeltaStatus::Regressed,
515 "backslash path must match its forward-slash baseline counterpart"
516 );
517 assert!(report.removed.is_empty());
518 }
519
520 #[test]
523 fn custom_epsilon_zero_catches_sub_default_deltas() {
524 let report = compute_delta(&[entry("foo", 10.001)], &[entry("foo", 10.0)], 0.0);
527 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
528 }
529
530 #[test]
531 fn custom_epsilon_tolerates_drift_within_band() {
532 let report = compute_delta(&[entry("foo", 10.4)], &[entry("foo", 10.0)], 0.5);
535 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
536 }
537
538 #[test]
539 fn custom_epsilon_zero_is_strict_on_both_sides() {
540 let report = compute_delta(&[entry("foo", 9.999)], &[entry("foo", 10.0)], 0.0);
543 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
544 }
545
546 #[test]
549 fn load_baseline_accepts_wrapped_envelope() {
550 let dir = tempfile::tempdir().expect("tempdir");
552 let path = dir.path().join("wrapped.json");
553 std::fs::write(
554 &path,
555 r#"{"version":"0.0.2","entries":[{"file":"src/lib.rs","function":"foo","line":1,"cyclomatic":1.0,"coverage":100.0,"crap":1.0}]}"#,
556 )
557 .expect("write");
558 let entries = load_baseline(&path).expect("wrapped baseline must parse");
559 assert_eq!(entries.len(), 1);
560 assert_eq!(entries[0].function, "foo");
561 }
562
563 #[test]
564 fn load_baseline_rejects_bare_array() {
565 let dir = tempfile::tempdir().expect("tempdir");
566 let path = dir.path().join("legacy.json");
567 std::fs::write(
568 &path,
569 r#"[{"file":"src/lib.rs","function":"foo","line":1,"cyclomatic":1.0,"coverage":100.0,"crap":1.0}]"#,
570 )
571 .expect("write");
572 assert!(load_baseline(&path).is_err());
573 }
574
575 fn entry_in(
580 file: &str,
581 function: &str,
582 crap: f64,
583 ) -> CrapEntry {
584 CrapEntry {
585 file: PathBuf::from(file),
586 function: function.into(),
587 line: 1,
588 cyclomatic: 5.0,
589 coverage: Some(100.0),
590 crap,
591 crate_name: None,
592 }
593 }
594
595 #[test]
596 fn move_detected_for_unique_name_same_score() {
597 let baseline = vec![entry_in("src/old.rs", "render", 5.0)];
601 let current = vec![entry_in("src/new.rs", "render", 5.0)];
602 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
603 assert_eq!(report.entries[0].status, DeltaStatus::Moved);
604 assert_eq!(
605 report.entries[0].previous_file,
606 Some(PathBuf::from("src/old.rs"))
607 );
608 assert_eq!(report.entries[0].baseline_crap, Some(5.0));
609 assert!(report.removed.is_empty());
610 }
611
612 #[test]
613 fn moved_with_regression_keeps_regressed_status() {
614 let baseline = vec![entry_in("src/old.rs", "render", 5.0)];
618 let current = vec![entry_in("src/new.rs", "render", 12.0)];
619 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
620 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
621 assert_eq!(
622 report.entries[0].previous_file,
623 Some(PathBuf::from("src/old.rs"))
624 );
625 assert_eq!(report.entries[0].delta, Some(7.0));
626 assert_eq!(report.regression_count(), 1);
627 assert!(report.removed.is_empty());
628 }
629
630 #[test]
631 fn moved_with_improvement_keeps_improved_status() {
632 let baseline = vec![entry_in("src/old.rs", "render", 12.0)];
634 let current = vec![entry_in("src/new.rs", "render", 5.0)];
635 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
636 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
637 assert_eq!(
638 report.entries[0].previous_file,
639 Some(PathBuf::from("src/old.rs"))
640 );
641 }
642
643 #[test]
644 fn ambiguous_names_left_unpaired() {
645 let baseline = vec![
649 entry_in("src/a.rs", "helper", 5.0),
650 entry_in("src/b.rs", "helper", 5.0),
651 ];
652 let current = vec![
653 entry_in("src/c.rs", "helper", 5.0),
654 entry_in("src/d.rs", "helper", 5.0),
655 ];
656 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
657 assert_eq!(report.entries.len(), 2);
658 for de in &report.entries {
659 assert_eq!(de.status, DeltaStatus::New, "ambiguous → New");
660 assert!(
661 de.previous_file.is_none(),
662 "ambiguous → no previous_file pairing"
663 );
664 }
665 assert_eq!(report.removed.len(), 2, "both baseline entries are removed");
666 }
667
668 #[test]
669 fn truly_new_function_stays_new() {
670 let current = vec![entry_in("src/a.rs", "brand_new", 5.0)];
672 let baseline = vec![entry_in("src/a.rs", "something_else", 5.0)];
673 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
674 let new_entry = report
675 .entries
676 .iter()
677 .find(|e| e.current.function == "brand_new")
678 .expect("brand_new missing");
679 assert_eq!(new_entry.status, DeltaStatus::New);
680 assert!(new_entry.previous_file.is_none());
681 }
682
683 #[test]
684 fn truly_removed_function_stays_removed() {
685 let current = vec![entry_in("src/a.rs", "kept", 5.0)];
687 let baseline = vec![
688 entry_in("src/a.rs", "kept", 5.0),
689 entry_in("src/a.rs", "deleted", 8.0),
690 ];
691 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
692 assert_eq!(report.removed.len(), 1);
693 assert_eq!(report.removed[0].function, "deleted");
694 }
695
696 #[test]
697 fn exact_path_match_takes_precedence_over_name_fallback() {
698 let baseline = vec![
706 entry_in("src/a.rs", "foo", 5.0),
707 entry_in("src/b.rs", "foo", 7.0),
708 ];
709 let current = vec![entry_in("src/a.rs", "foo", 5.0)];
710 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
711 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
713 assert!(report.entries[0].previous_file.is_none());
714 assert_eq!(report.removed.len(), 1);
717 assert_eq!(report.removed[0].file, PathBuf::from("src/b.rs"));
718 }
719
720 #[test]
721 fn cfg_gated_same_name_same_file_no_spurious_regression() {
722 let baseline = vec![
727 CrapEntry {
728 file: PathBuf::from("src/lib.rs"),
729 function: "platform_handler".into(),
730 line: 2, cyclomatic: 5.0,
732 coverage: Some(0.0),
733 crap: 30.0,
734 crate_name: None,
735 },
736 CrapEntry {
737 file: PathBuf::from("src/lib.rs"),
738 function: "platform_handler".into(),
739 line: 17, cyclomatic: 1.0,
741 coverage: Some(100.0),
742 crap: 1.0,
743 crate_name: None,
744 },
745 ];
746 let current = baseline.clone();
747 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
748 assert_eq!(
749 report.regression_count(),
750 0,
751 "identical run must not regress"
752 );
753 for de in &report.entries {
754 assert_eq!(
755 de.status,
756 DeltaStatus::Unchanged,
757 "function at line {} must be Unchanged",
758 de.current.line
759 );
760 }
761 assert!(report.removed.is_empty());
762 }
763}