1use crate::merge::{CrapEntry, SortOrder};
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 pub fn sort(
88 &mut self,
89 order: SortOrder,
90 ) {
91 match order {
92 SortOrder::Crap => self.entries.sort_by(|a, b| {
93 b.current
94 .crap
95 .partial_cmp(&a.current.crap)
96 .unwrap_or(std::cmp::Ordering::Equal)
97 }),
98 SortOrder::File => {
99 self.entries
100 .sort_by(|a, b| current_key(&a.current).cmp(¤t_key(&b.current)));
101 self.removed
102 .sort_by(|a, b| removed_key(a).cmp(&removed_key(b)));
103 },
104 }
105 }
106
107 #[must_use]
109 pub fn regression_count(&self) -> usize {
110 self.entries
111 .iter()
112 .filter(|e| e.status == DeltaStatus::Regressed)
113 .count()
114 }
115}
116
117pub fn load_baseline(path: &Path) -> Result<Vec<CrapEntry>> {
119 let raw = std::fs::read_to_string(path)
120 .with_context(|| format!("reading baseline {}", path.display()))?;
121 let envelope: crate::report::Envelope = serde_json::from_str(&raw).with_context(|| {
122 format!(
123 "parsing baseline {} — must be JSON from `cargo crap --format json`",
124 path.display()
125 )
126 })?;
127 Ok(envelope.entries)
128}
129
130fn path_key(p: &Path) -> String {
131 p.to_string_lossy().replace('\\', "/")
132}
133
134fn current_key(e: &CrapEntry) -> (String, &str, usize) {
137 (path_key(&e.file), e.function.as_str(), e.line)
138}
139
140fn removed_key(r: &RemovedEntry) -> (String, &str) {
142 (path_key(&r.file), r.function.as_str())
143}
144
145#[derive(Hash, Eq, PartialEq)]
146struct EntryKey {
147 file: String,
148 function: String,
149 line: usize,
150}
151
152impl EntryKey {
153 fn new(e: &CrapEntry) -> Self {
154 Self {
155 file: path_key(&e.file),
156 function: e.function.clone(),
157 line: e.line,
158 }
159 }
160}
161
162fn classify_score(
164 delta: f64,
165 epsilon: f64,
166) -> DeltaStatus {
167 if delta > epsilon {
168 DeltaStatus::Regressed
169 } else if delta < -epsilon {
170 DeltaStatus::Improved
171 } else {
172 DeltaStatus::Unchanged
173 }
174}
175
176fn build_pass_one_entry(
180 e: &CrapEntry,
181 baseline_entry: Option<&CrapEntry>,
182 epsilon: f64,
183) -> DeltaEntry {
184 let (baseline_crap, delta, status) = match baseline_entry {
185 None => (None, None, DeltaStatus::New),
186 Some(b) => {
187 let d = e.crap - b.crap;
188 (Some(b.crap), Some(d), classify_score(d, epsilon))
189 },
190 };
191 DeltaEntry {
192 current: e.clone(),
193 baseline_crap,
194 delta,
195 status,
196 previous_file: None,
197 }
198}
199
200fn pass_one_exact(
204 current: &[CrapEntry],
205 baseline: &[CrapEntry],
206 epsilon: f64,
207) -> (Vec<DeltaEntry>, HashSet<EntryKey>) {
208 let baseline_index: HashMap<EntryKey, &CrapEntry> =
209 baseline.iter().map(|e| (EntryKey::new(e), e)).collect();
210 let mut matched: HashSet<EntryKey> = HashSet::new();
211 let entries = current
212 .iter()
213 .map(|e| {
214 let key = EntryKey::new(e);
215 let baseline_entry = baseline_index.get(&key).copied();
216 if baseline_entry.is_some() {
217 matched.insert(key);
218 }
219 build_pass_one_entry(e, baseline_entry, epsilon)
220 })
221 .collect();
222 (entries, matched)
223}
224
225fn apply_move_pairing(
229 entry: &mut DeltaEntry,
230 baseline_entry: &CrapEntry,
231 epsilon: f64,
232) {
233 let d = entry.current.crap - baseline_entry.crap;
234 let score_status = classify_score(d, epsilon);
235 entry.baseline_crap = Some(baseline_entry.crap);
236 entry.delta = Some(d);
237 entry.previous_file = Some(baseline_entry.file.clone());
238 entry.status = match score_status {
239 DeltaStatus::Unchanged => DeltaStatus::Moved,
240 other => other,
241 };
242}
243
244fn apply_suffix_pairing(
248 entry: &mut DeltaEntry,
249 baseline_entry: &CrapEntry,
250 epsilon: f64,
251) {
252 let d = entry.current.crap - baseline_entry.crap;
253 entry.baseline_crap = Some(baseline_entry.crap);
254 entry.delta = Some(d);
255 entry.status = classify_score(d, epsilon);
256}
257
258fn common_suffix_len(
261 a: &Path,
262 b: &Path,
263) -> usize {
264 let a = path_key(a);
265 let b = path_key(b);
266 a.split('/')
267 .rev()
268 .zip(b.split('/').rev())
269 .take_while(|(x, y)| x == y)
270 .count()
271}
272
273fn unique_best_by_suffix<T>(
277 from: &Path,
278 candidates: &[T],
279 file_of: impl Fn(&T) -> &Path,
280) -> Option<usize> {
281 let mut best: Option<(usize, usize)> = None; let mut tied = false;
283 for (i, c) in candidates.iter().enumerate() {
284 let score = common_suffix_len(from, file_of(c));
285 if score == 0 {
286 continue;
287 }
288 match best {
289 None => best = Some((i, score)),
290 Some((_, s)) => match score.cmp(&s) {
291 std::cmp::Ordering::Equal => tied = true,
292 std::cmp::Ordering::Greater => {
293 best = Some((i, score));
294 tied = false;
295 },
296 std::cmp::Ordering::Less => {},
297 },
298 }
299 }
300 match (best, tied) {
301 (Some((i, _)), false) => Some(i),
302 _ => None,
303 }
304}
305
306fn pass_suffix_match(
318 entries: &mut [DeltaEntry],
319 baseline: &[CrapEntry],
320 matched: &mut HashSet<EntryKey>,
321 epsilon: f64,
322) {
323 let mut new_by_name: HashMap<String, Vec<(usize, PathBuf)>> = HashMap::new();
324 for (i, de) in entries.iter().enumerate() {
325 if de.status == DeltaStatus::New {
326 new_by_name
327 .entry(de.current.function.clone())
328 .or_default()
329 .push((i, de.current.file.clone()));
330 }
331 }
332 let mut baseline_by_name: HashMap<&str, Vec<&CrapEntry>> = HashMap::new();
333 for b in baseline {
334 if !matched.contains(&EntryKey::new(b)) {
335 baseline_by_name
336 .entry(b.function.as_str())
337 .or_default()
338 .push(b);
339 }
340 }
341 for (name, cur_group) in &new_by_name {
342 let Some(bas_group) = baseline_by_name.get(name.as_str()) else {
343 continue;
344 };
345 for (entry_idx, cur_file) in cur_group {
346 let Some(bi) = unique_best_by_suffix(cur_file, bas_group, |b| b.file.as_path()) else {
347 continue;
348 };
349 let Some(ci) =
352 unique_best_by_suffix(&bas_group[bi].file, cur_group, |(_, f)| f.as_path())
353 else {
354 continue;
355 };
356 if cur_group[ci].0 != *entry_idx {
357 continue;
358 }
359 apply_suffix_pairing(&mut entries[*entry_idx], bas_group[bi], epsilon);
360 matched.insert(EntryKey::new(bas_group[bi]));
361 }
362 }
363}
364
365fn pass_two_name_fallback(
368 entries: &mut [DeltaEntry],
369 baseline: &[CrapEntry],
370 matched: &mut HashSet<EntryKey>,
371 epsilon: f64,
372) {
373 let mut new_idx_by_name: HashMap<String, Vec<usize>> = HashMap::new();
374 for (i, de) in entries.iter().enumerate() {
375 if de.status == DeltaStatus::New {
376 new_idx_by_name
377 .entry(de.current.function.clone())
378 .or_default()
379 .push(i);
380 }
381 }
382 let mut baseline_unmatched_by_name: HashMap<String, Vec<&CrapEntry>> = HashMap::new();
383 for e in baseline {
384 if !matched.contains(&EntryKey::new(e)) {
385 baseline_unmatched_by_name
386 .entry(e.function.clone())
387 .or_default()
388 .push(e);
389 }
390 }
391 for (name, new_idxs) in &new_idx_by_name {
392 if new_idxs.len() != 1 {
393 continue;
394 }
395 let Some(baseline_group) = baseline_unmatched_by_name.get(name) else {
396 continue;
397 };
398 if baseline_group.len() != 1 {
399 continue;
400 }
401 let baseline_entry = baseline_group[0];
402 apply_move_pairing(&mut entries[new_idxs[0]], baseline_entry, epsilon);
403 matched.insert(EntryKey::new(baseline_entry));
404 }
405}
406
407fn collect_removed(
409 baseline: &[CrapEntry],
410 matched: &HashSet<EntryKey>,
411) -> Vec<RemovedEntry> {
412 baseline
413 .iter()
414 .filter(|e| !matched.contains(&EntryKey::new(e)))
415 .map(|e| RemovedEntry {
416 function: e.function.clone(),
417 file: e.file.clone(),
418 baseline_crap: e.crap,
419 })
420 .collect()
421}
422
423#[must_use]
448pub fn compute_delta(
449 current: &[CrapEntry],
450 baseline: &[CrapEntry],
451 epsilon: f64,
452) -> DeltaReport {
453 let (mut entries, mut matched) = pass_one_exact(current, baseline, epsilon);
454 pass_suffix_match(&mut entries, baseline, &mut matched, epsilon);
455 pass_two_name_fallback(&mut entries, baseline, &mut matched, epsilon);
456 let removed = collect_removed(baseline, &matched);
457 DeltaReport { entries, removed }
458}
459
460#[cfg(test)]
461#[expect(
462 clippy::float_cmp,
463 reason = "CRAP-score deltas are deterministic floats; exact equality is the right comparison"
464)]
465mod tests {
466 use super::*;
467 use std::path::PathBuf;
468
469 fn entry(
470 function: &str,
471 crap: f64,
472 ) -> CrapEntry {
473 CrapEntry {
474 file: PathBuf::from("src/lib.rs"),
475 function: function.to_string(),
476 line: 1,
477 cyclomatic: 1.0,
478 coverage: Some(100.0),
479 crap,
480 crate_name: None,
481 }
482 }
483
484 #[test]
485 fn new_when_not_in_baseline() {
486 let report = compute_delta(&[entry("foo", 5.0)], &[], DEFAULT_EPSILON);
487 assert_eq!(report.entries[0].status, DeltaStatus::New);
488 assert!(report.entries[0].baseline_crap.is_none());
489 assert!(report.entries[0].delta.is_none());
490 }
491
492 #[test]
493 fn regressed_when_score_increased() {
494 let report = compute_delta(&[entry("foo", 10.0)], &[entry("foo", 5.0)], DEFAULT_EPSILON);
495 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
496 assert_eq!(report.entries[0].baseline_crap, Some(5.0));
497 assert!((report.entries[0].delta.unwrap() - 5.0).abs() < 1e-9);
498 }
499
500 #[test]
501 fn improved_when_score_decreased() {
502 let report = compute_delta(&[entry("foo", 3.0)], &[entry("foo", 8.0)], DEFAULT_EPSILON);
503 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
504 assert!((report.entries[0].delta.unwrap() + 5.0).abs() < 1e-9);
505 }
506
507 #[test]
508 fn unchanged_within_epsilon() {
509 let report = compute_delta(
510 &[entry("foo", 5.005)],
511 &[entry("foo", 5.0)],
512 DEFAULT_EPSILON,
513 );
514 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
515 }
516
517 #[test]
518 fn epsilon_boundary_regression_is_exclusive() {
519 let report = compute_delta(
527 &[entry("foo", DEFAULT_EPSILON)],
528 &[entry("foo", 0.0)],
529 DEFAULT_EPSILON,
530 );
531 assert_eq!(
532 report.entries[0].status,
533 DeltaStatus::Unchanged,
534 "delta == DEFAULT_EPSILON must be Unchanged, not Regressed"
535 );
536 }
537
538 #[test]
539 fn above_epsilon_is_regressed() {
540 let report = compute_delta(
543 &[entry("foo", DEFAULT_EPSILON + 0.001)],
544 &[entry("foo", 0.0)],
545 DEFAULT_EPSILON,
546 );
547 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
548 }
549
550 #[test]
551 fn epsilon_boundary_improvement_is_exclusive() {
552 let report = compute_delta(
556 &[entry("foo", 0.0)],
557 &[entry("foo", DEFAULT_EPSILON)],
558 DEFAULT_EPSILON,
559 );
560 assert_eq!(
561 report.entries[0].status,
562 DeltaStatus::Unchanged,
563 "delta == -DEFAULT_EPSILON must be Unchanged, not Improved"
564 );
565 }
566
567 #[test]
568 fn below_negative_epsilon_is_improved() {
569 let report = compute_delta(
572 &[entry("foo", 0.0)],
573 &[entry("foo", DEFAULT_EPSILON + 0.001)],
574 DEFAULT_EPSILON,
575 );
576 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
577 }
578
579 #[test]
580 fn removed_entries_identified() {
581 let report = compute_delta(
582 &[entry("bar", 2.0)],
583 &[entry("foo", 5.0), entry("bar", 2.0)],
584 DEFAULT_EPSILON,
585 );
586 assert_eq!(report.removed.len(), 1);
587 assert_eq!(report.removed[0].function, "foo");
588 assert_eq!(report.removed[0].baseline_crap, 5.0);
589 }
590
591 #[test]
592 fn regression_count_is_accurate() {
593 let current = vec![entry("foo", 10.0), entry("bar", 2.0), entry("baz", 1.0)];
594 let baseline = vec![entry("foo", 5.0), entry("bar", 8.0)];
595 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
597 assert_eq!(report.regression_count(), 1);
598 }
599
600 #[test]
601 fn empty_baseline_marks_everything_new() {
602 let current = vec![entry("a", 1.0), entry("b", 2.0)];
603 let report = compute_delta(¤t, &[], DEFAULT_EPSILON);
604 assert!(report.entries.iter().all(|e| e.status == DeltaStatus::New));
605 assert!(report.removed.is_empty());
606 }
607
608 #[test]
609 fn functions_in_different_files_pair_as_moved() {
610 let current = vec![CrapEntry {
619 file: PathBuf::from("src/lib.rs"),
620 function: "foo".into(),
621 line: 1,
622 cyclomatic: 1.0,
623 coverage: Some(100.0),
624 crap: 5.0,
625 crate_name: None,
626 }];
627 let baseline = vec![CrapEntry {
628 file: PathBuf::from("src/main.rs"), function: "foo".into(),
630 line: 1,
631 cyclomatic: 1.0,
632 coverage: Some(100.0),
633 crap: 5.0,
634 crate_name: None,
635 }];
636 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
637 assert_eq!(
638 report.entries[0].status,
639 DeltaStatus::Moved,
640 "foo unique on each side must pair as Moved"
641 );
642 assert_eq!(
643 report.entries[0].previous_file,
644 Some(PathBuf::from("src/main.rs")),
645 "previous_file must record the baseline location"
646 );
647 assert!(
648 report.removed.is_empty(),
649 "paired baseline entry must not appear as removed"
650 );
651 }
652
653 #[test]
654 fn backslash_paths_match_forward_slash_baseline() {
655 let current = vec![CrapEntry {
658 file: PathBuf::from("tests\\fixtures\\src\\lib.rs"),
659 function: "foo".into(),
660 line: 1,
661 cyclomatic: 1.0,
662 coverage: Some(100.0),
663 crap: 10.0,
664 crate_name: None,
665 }];
666 let baseline = vec![CrapEntry {
667 file: PathBuf::from("tests/fixtures/src/lib.rs"),
668 function: "foo".into(),
669 line: 1,
670 cyclomatic: 1.0,
671 coverage: Some(100.0),
672 crap: 5.0,
673 crate_name: None,
674 }];
675 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
676 assert_eq!(
677 report.entries[0].status,
678 DeltaStatus::Regressed,
679 "backslash path must match its forward-slash baseline counterpart"
680 );
681 assert!(report.removed.is_empty());
682 }
683
684 #[test]
687 fn custom_epsilon_zero_catches_sub_default_deltas() {
688 let report = compute_delta(&[entry("foo", 10.001)], &[entry("foo", 10.0)], 0.0);
691 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
692 }
693
694 #[test]
695 fn custom_epsilon_tolerates_drift_within_band() {
696 let report = compute_delta(&[entry("foo", 10.4)], &[entry("foo", 10.0)], 0.5);
699 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
700 }
701
702 #[test]
703 fn custom_epsilon_zero_is_strict_on_both_sides() {
704 let report = compute_delta(&[entry("foo", 9.999)], &[entry("foo", 10.0)], 0.0);
707 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
708 }
709
710 #[test]
713 fn load_baseline_accepts_wrapped_envelope() {
714 let dir = tempfile::tempdir().expect("tempdir");
716 let path = dir.path().join("wrapped.json");
717 std::fs::write(
718 &path,
719 r#"{"version":"0.0.2","entries":[{"file":"src/lib.rs","function":"foo","line":1,"cyclomatic":1.0,"coverage":100.0,"crap":1.0}]}"#,
720 )
721 .expect("write");
722 let entries = load_baseline(&path).expect("wrapped baseline must parse");
723 assert_eq!(entries.len(), 1);
724 assert_eq!(entries[0].function, "foo");
725 }
726
727 #[test]
728 fn load_baseline_rejects_bare_array() {
729 let dir = tempfile::tempdir().expect("tempdir");
730 let path = dir.path().join("legacy.json");
731 std::fs::write(
732 &path,
733 r#"[{"file":"src/lib.rs","function":"foo","line":1,"cyclomatic":1.0,"coverage":100.0,"crap":1.0}]"#,
734 )
735 .expect("write");
736 assert!(load_baseline(&path).is_err());
737 }
738
739 fn entry_in(
744 file: &str,
745 function: &str,
746 crap: f64,
747 ) -> CrapEntry {
748 CrapEntry {
749 file: PathBuf::from(file),
750 function: function.into(),
751 line: 1,
752 cyclomatic: 5.0,
753 coverage: Some(100.0),
754 crap,
755 crate_name: None,
756 }
757 }
758
759 #[test]
760 fn move_detected_for_unique_name_same_score() {
761 let baseline = vec![entry_in("src/old.rs", "render", 5.0)];
765 let current = vec![entry_in("src/new.rs", "render", 5.0)];
766 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
767 assert_eq!(report.entries[0].status, DeltaStatus::Moved);
768 assert_eq!(
769 report.entries[0].previous_file,
770 Some(PathBuf::from("src/old.rs"))
771 );
772 assert_eq!(report.entries[0].baseline_crap, Some(5.0));
773 assert!(report.removed.is_empty());
774 }
775
776 #[test]
777 fn moved_with_regression_keeps_regressed_status() {
778 let baseline = vec![entry_in("src/old.rs", "render", 5.0)];
782 let current = vec![entry_in("src/new.rs", "render", 12.0)];
783 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
784 assert_eq!(report.entries[0].status, DeltaStatus::Regressed);
785 assert_eq!(
786 report.entries[0].previous_file,
787 Some(PathBuf::from("src/old.rs"))
788 );
789 assert_eq!(report.entries[0].delta, Some(7.0));
790 assert_eq!(report.regression_count(), 1);
791 assert!(report.removed.is_empty());
792 }
793
794 #[test]
795 fn moved_with_improvement_keeps_improved_status() {
796 let baseline = vec![entry_in("src/old.rs", "render", 12.0)];
798 let current = vec![entry_in("src/new.rs", "render", 5.0)];
799 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
800 assert_eq!(report.entries[0].status, DeltaStatus::Improved);
801 assert_eq!(
802 report.entries[0].previous_file,
803 Some(PathBuf::from("src/old.rs"))
804 );
805 }
806
807 #[test]
808 fn ambiguous_names_left_unpaired() {
809 let baseline = vec![
813 entry_in("src/a.rs", "helper", 5.0),
814 entry_in("src/b.rs", "helper", 5.0),
815 ];
816 let current = vec![
817 entry_in("src/c.rs", "helper", 5.0),
818 entry_in("src/d.rs", "helper", 5.0),
819 ];
820 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
821 assert_eq!(report.entries.len(), 2);
822 for de in &report.entries {
823 assert_eq!(de.status, DeltaStatus::New, "ambiguous → New");
824 assert!(
825 de.previous_file.is_none(),
826 "ambiguous → no previous_file pairing"
827 );
828 }
829 assert_eq!(report.removed.len(), 2, "both baseline entries are removed");
830 }
831
832 #[test]
833 fn truly_new_function_stays_new() {
834 let current = vec![entry_in("src/a.rs", "brand_new", 5.0)];
836 let baseline = vec![entry_in("src/a.rs", "something_else", 5.0)];
837 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
838 let new_entry = report
839 .entries
840 .iter()
841 .find(|e| e.current.function == "brand_new")
842 .expect("brand_new missing");
843 assert_eq!(new_entry.status, DeltaStatus::New);
844 assert!(new_entry.previous_file.is_none());
845 }
846
847 #[test]
848 fn truly_removed_function_stays_removed() {
849 let current = vec![entry_in("src/a.rs", "kept", 5.0)];
851 let baseline = vec![
852 entry_in("src/a.rs", "kept", 5.0),
853 entry_in("src/a.rs", "deleted", 8.0),
854 ];
855 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
856 assert_eq!(report.removed.len(), 1);
857 assert_eq!(report.removed[0].function, "deleted");
858 }
859
860 #[test]
861 fn exact_path_match_takes_precedence_over_name_fallback() {
862 let baseline = vec![
870 entry_in("src/a.rs", "foo", 5.0),
871 entry_in("src/b.rs", "foo", 7.0),
872 ];
873 let current = vec![entry_in("src/a.rs", "foo", 5.0)];
874 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
875 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
877 assert!(report.entries[0].previous_file.is_none());
878 assert_eq!(report.removed.len(), 1);
881 assert_eq!(report.removed[0].file, PathBuf::from("src/b.rs"));
882 }
883
884 #[test]
887 fn sort_file_orders_entries_by_current_key_and_removed_by_file_function() {
888 let mut report = DeltaReport {
889 entries: vec![
890 build_pass_one_entry(&entry_in("src/b.rs", "zeta", 99.0), None, DEFAULT_EPSILON),
891 build_pass_one_entry(&entry_in("src/a.rs", "beta", 1.0), None, DEFAULT_EPSILON),
892 build_pass_one_entry(&entry_in("src/a.rs", "alpha", 1.0), None, DEFAULT_EPSILON),
893 ],
894 removed: vec![
895 RemovedEntry {
896 function: "y".into(),
897 file: PathBuf::from("src/z.rs"),
898 baseline_crap: 1.0,
899 },
900 RemovedEntry {
901 function: "x".into(),
902 file: PathBuf::from("src/a.rs"),
903 baseline_crap: 1.0,
904 },
905 ],
906 };
907 report.sort(SortOrder::File);
908 let entry_order: Vec<&str> = report
909 .entries
910 .iter()
911 .map(|e| e.current.function.as_str())
912 .collect();
913 assert_eq!(entry_order, ["alpha", "beta", "zeta"]);
914 let removed_order: Vec<&str> = report.removed.iter().map(|r| r.function.as_str()).collect();
915 assert_eq!(
916 removed_order,
917 ["x", "y"],
918 "removed sorts by (file, function)"
919 );
920 }
921
922 #[test]
923 fn sort_crap_orders_entries_by_score_descending() {
924 let mut report = DeltaReport {
926 entries: vec![
927 build_pass_one_entry(&entry_in("src/a.rs", "low", 1.0), None, DEFAULT_EPSILON),
928 build_pass_one_entry(&entry_in("src/a.rs", "high", 90.0), None, DEFAULT_EPSILON),
929 ],
930 removed: vec![],
931 };
932 report.sort(SortOrder::Crap);
933 let entry_order: Vec<&str> = report
934 .entries
935 .iter()
936 .map(|e| e.current.function.as_str())
937 .collect();
938 assert_eq!(entry_order, ["high", "low"]);
939 }
940
941 #[test]
942 fn cfg_gated_same_name_same_file_no_spurious_regression() {
943 let baseline = vec![
948 CrapEntry {
949 file: PathBuf::from("src/lib.rs"),
950 function: "platform_handler".into(),
951 line: 2, cyclomatic: 5.0,
953 coverage: Some(0.0),
954 crap: 30.0,
955 crate_name: None,
956 },
957 CrapEntry {
958 file: PathBuf::from("src/lib.rs"),
959 function: "platform_handler".into(),
960 line: 17, cyclomatic: 1.0,
962 coverage: Some(100.0),
963 crap: 1.0,
964 crate_name: None,
965 },
966 ];
967 let current = baseline.clone();
968 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
969 assert_eq!(
970 report.regression_count(),
971 0,
972 "identical run must not regress"
973 );
974 for de in &report.entries {
975 assert_eq!(
976 de.status,
977 DeltaStatus::Unchanged,
978 "function at line {} must be Unchanged",
979 de.current.line
980 );
981 }
982 assert!(report.removed.is_empty());
983 }
984
985 fn entry_at(
986 file: &str,
987 function: &str,
988 crap: f64,
989 ) -> CrapEntry {
990 CrapEntry {
991 file: PathBuf::from(file),
992 function: function.to_string(),
993 line: 1,
994 cyclomatic: 1.0,
995 coverage: Some(100.0),
996 crap,
997 crate_name: None,
998 }
999 }
1000
1001 #[test]
1002 fn cross_root_baseline_matches_without_move_status() {
1003 let baseline = vec![entry_at("/app/src/backup.rs", "run_backup", 5.0)];
1006 let current = vec![entry_at(
1007 "/home/user/project/src/backup.rs",
1008 "run_backup",
1009 5.0,
1010 )];
1011 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1012 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1013 assert!(
1014 report.entries[0].previous_file.is_none(),
1015 "a root remap is not a move"
1016 );
1017 assert!(report.removed.is_empty());
1018 }
1019
1020 #[test]
1021 fn duplicate_names_disambiguate_by_directory_suffix() {
1022 let baseline = vec![
1025 entry_at("/app/src/backup.rs", "run", 5.0),
1026 entry_at("/app/src/restore.rs", "run", 7.0),
1027 ];
1028 let current = vec![
1029 entry_at("/work/co/src/backup.rs", "run", 5.0),
1030 entry_at("/work/co/src/restore.rs", "run", 7.0),
1031 ];
1032 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1033 for de in &report.entries {
1034 assert_eq!(
1035 de.status,
1036 DeltaStatus::Unchanged,
1037 "{} must pair with its own file's baseline entry",
1038 de.current.file.display()
1039 );
1040 }
1041 assert!(report.removed.is_empty());
1042 }
1043
1044 #[test]
1045 fn cross_root_regression_pairs_with_the_right_file() {
1046 let baseline = vec![
1049 entry_at("/app/src/backup.rs", "run", 5.0),
1050 entry_at("/app/src/restore.rs", "run", 7.0),
1051 ];
1052 let current = vec![
1053 entry_at("/work/co/src/backup.rs", "run", 12.0),
1054 entry_at("/work/co/src/restore.rs", "run", 7.0),
1055 ];
1056 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1057 assert_eq!(report.regression_count(), 1);
1058 let regressed = report
1059 .entries
1060 .iter()
1061 .find(|e| e.status == DeltaStatus::Regressed)
1062 .expect("one regression");
1063 assert!(regressed.current.file.ends_with("backup.rs"));
1064 assert_eq!(regressed.baseline_crap, Some(5.0));
1065 assert!((regressed.delta.unwrap() - 7.0).abs() < 1e-9);
1066 }
1067
1068 #[test]
1069 fn relative_current_matches_absolute_baseline() {
1070 let baseline = vec![entry_at("/home/user/project/src/lib.rs", "parse", 3.0)];
1071 let current = vec![entry_at("src/lib.rs", "parse", 3.0)];
1072 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1073 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1074 assert!(report.removed.is_empty());
1075 }
1076
1077 #[test]
1078 fn equal_suffix_ties_stay_unpaired() {
1079 let baseline = vec![entry_at("/app/x/util.rs", "helper", 2.0)];
1083 let current = vec![
1084 entry_at("a/util.rs", "helper", 2.0),
1085 entry_at("b/util.rs", "helper", 2.0),
1086 ];
1087 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1088 for de in &report.entries {
1089 assert_eq!(de.status, DeltaStatus::New);
1090 }
1091 assert_eq!(report.removed.len(), 1);
1092 }
1093
1094 #[test]
1095 fn line_shift_does_not_break_suffix_matching() {
1096 let mut b = entry_at("/app/src/lib.rs", "parse", 3.0);
1098 b.line = 10;
1099 let mut c = entry_at("/work/co/src/lib.rs", "parse", 3.0);
1100 c.line = 42;
1101 let report = compute_delta(&[c], &[b], DEFAULT_EPSILON);
1102 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1103 assert!(report.removed.is_empty());
1104 }
1105
1106 #[test]
1107 fn filename_change_still_reported_as_move() {
1108 let baseline = vec![entry_at("src/old.rs", "render", 4.0)];
1111 let current = vec![entry_at("src/new.rs", "render", 4.0)];
1112 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1113 assert_eq!(report.entries[0].status, DeltaStatus::Moved);
1114 assert_eq!(
1115 report.entries[0].previous_file,
1116 Some(PathBuf::from("src/old.rs"))
1117 );
1118 }
1119
1120 #[test]
1121 fn exact_match_takes_precedence_over_suffix() {
1122 let baseline = vec![
1128 entry_at("src/a.rs", "helper", 2.0),
1129 entry_at("src/b.rs", "helper", 9.0),
1130 ];
1131 let current = vec![
1132 entry_at("src/a.rs", "helper", 2.0),
1133 entry_at("src/c.rs", "helper", 2.0),
1134 ];
1135 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1136 let a = report
1137 .entries
1138 .iter()
1139 .find(|e| e.current.file.ends_with("a.rs"))
1140 .expect("a.rs present");
1141 assert_eq!(a.status, DeltaStatus::Unchanged);
1142 assert_eq!(a.baseline_crap, Some(2.0), "a.rs must pair with a.rs");
1143 assert!(
1144 a.previous_file.is_none(),
1145 "exact match must not be relabeled by later passes"
1146 );
1147 let c = report
1148 .entries
1149 .iter()
1150 .find(|e| e.current.file.ends_with("c.rs"))
1151 .expect("c.rs present");
1152 assert_eq!(
1153 c.status,
1154 DeltaStatus::Improved,
1155 "leftover unique name pairs via the spec-13 fallback"
1156 );
1157 assert_eq!(c.previous_file, Some(PathBuf::from("src/b.rs")));
1158 assert!(report.removed.is_empty());
1159 }
1160
1161 #[test]
1162 fn deeper_suffix_wins_when_better_candidate_comes_first() {
1163 let baseline = vec![
1167 entry_at("/app/src/backup.rs", "run", 5.0), entry_at("/app/legacy/backup.rs", "run", 9.0), ];
1170 let current = vec![entry_at("/co/src/backup.rs", "run", 5.0)];
1171 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1172 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1173 assert_eq!(
1174 report.entries[0].baseline_crap,
1175 Some(5.0),
1176 "must pair with the deeper suffix (src/backup.rs), not legacy/"
1177 );
1178 assert_eq!(report.removed.len(), 1);
1179 assert!(report.removed[0].file.ends_with("legacy/backup.rs"));
1180 }
1181
1182 #[test]
1183 fn deeper_suffix_wins_when_better_candidate_comes_second() {
1184 let baseline = vec![
1187 entry_at("/app/legacy/backup.rs", "run", 9.0), entry_at("/app/src/backup.rs", "run", 5.0), ];
1190 let current = vec![entry_at("/co/src/backup.rs", "run", 5.0)];
1191 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1192 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1193 assert_eq!(
1194 report.entries[0].baseline_crap,
1195 Some(5.0),
1196 "the later, deeper-suffix candidate must replace the incumbent"
1197 );
1198 }
1199
1200 #[test]
1201 fn tie_is_cleared_when_a_deeper_suffix_follows() {
1202 let baseline = vec![
1206 entry_at("/r1/util.rs", "helper", 7.0),
1207 entry_at("/r2/util.rs", "helper", 8.0),
1208 entry_at("/app/src/util.rs", "helper", 3.0), ];
1210 let current = vec![entry_at("/co/src/util.rs", "helper", 3.0)];
1211 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1212 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1213 assert_eq!(
1214 report.entries[0].baseline_crap,
1215 Some(3.0),
1216 "the unique deepest suffix must pair despite the earlier tie"
1217 );
1218 assert_eq!(report.removed.len(), 2);
1219 }
1220
1221 #[test]
1222 fn windows_baseline_matches_posix_paths() {
1223 let baseline = vec![entry_at(r"C:\ci\app\src\lib.rs", "parse", 3.0)];
1226 let current = vec![entry_at("/work/co/src/lib.rs", "parse", 3.0)];
1227 let report = compute_delta(¤t, &baseline, DEFAULT_EPSILON);
1228 assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1229 assert!(report.removed.is_empty());
1230 }
1231}