Skip to main content

cargo_crap/
delta.rs

1//! Delta comparison between two cargo-crap runs.
2//!
3//! Load a previous run's JSON output with [`load_baseline`], then call
4//! [`compute_delta`] to get per-function change status.
5//!
6//! ## Typical CI workflow
7//!
8//! ```text
9//! # On main branch — save baseline
10//! cargo crap --lcov lcov.info --format json --output baseline.json
11//!
12//! # On a PR branch — compare and fail on regressions
13//! cargo crap --lcov lcov.info --baseline baseline.json --fail-regression
14//! ```
15
16use crate::merge::CrapEntry;
17use anyhow::{Context, Result};
18use serde::Serialize;
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21
22/// Default tolerance for regression detection. Deltas with absolute value at
23/// or below this count as `Unchanged` rather than `Regressed` / `Improved`.
24/// Override with `--epsilon` or the `epsilon` config key.
25pub const DEFAULT_EPSILON: f64 = 0.01;
26
27/// Change status of a single function relative to the baseline.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum DeltaStatus {
31    /// Score increased by more than the epsilon — needs attention.
32    Regressed,
33    /// Score decreased by more than the epsilon — improved since baseline.
34    Improved,
35    /// Function was not present in the baseline (e.g. newly added code).
36    New,
37    /// Score changed by ≤ epsilon — effectively unchanged.
38    Unchanged,
39    /// Function moved to a different file with no meaningful score change
40    /// (≤ epsilon). The baseline location is preserved in
41    /// [`DeltaEntry::previous_file`]. Score-changed moves keep their
42    /// score-status (`Regressed` / `Improved`); `Moved` is exclusively for
43    /// pure relocations.
44    Moved,
45}
46
47/// One function from the current run, annotated with its change since the baseline.
48#[derive(Debug, Clone, Serialize)]
49pub struct DeltaEntry {
50    #[serde(flatten)]
51    pub current: CrapEntry,
52    /// The CRAP score from the baseline run; `None` when this function is new.
53    pub baseline_crap: Option<f64>,
54    /// `current.crap − baseline_crap`; `None` when this function is new.
55    pub delta: Option<f64>,
56    pub status: DeltaStatus,
57    /// Set when this function existed at a different path in the baseline
58    /// (paired by name during the second-pass matcher). `None` for
59    /// first-pass exact matches and genuinely-new entries.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub previous_file: Option<PathBuf>,
62}
63
64/// A function present in the baseline but absent in the current run.
65#[derive(Debug, Clone, Serialize)]
66pub struct RemovedEntry {
67    pub function: String,
68    pub file: PathBuf,
69    pub baseline_crap: f64,
70}
71
72/// The full comparison result.
73#[derive(Debug)]
74pub struct DeltaReport {
75    /// All functions from the current run, each annotated with its delta.
76    pub entries: Vec<DeltaEntry>,
77    /// Functions that existed in the baseline but are gone in the current run.
78    pub removed: Vec<RemovedEntry>,
79}
80
81impl DeltaReport {
82    /// Number of functions whose CRAP score increased since the baseline.
83    #[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
92/// Load a JSON baseline produced by a previous `cargo crap --format json` run.
93pub 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/// Classify a numeric delta against the epsilon tolerance.
110fn 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
123/// Build the initial `DeltaEntry` for a single current-side entry against
124/// the pass-1 (file, function) index. Sets `previous_file = None` (pass 2
125/// fills it in for paired moves).
126fn 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
147/// Pass 1 — exact `(file_path, function_name)` match. Returns the entry
148/// list (some still `New`, awaiting pass 2) and the set of baseline keys
149/// that were matched (so pass 2 / removed-collection can skip them).
150fn 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
174/// Apply a single move pairing in place: fill in `baseline_crap` / delta /
175/// `previous_file` and choose the right status (`Moved` for pure relocations,
176/// the score-status otherwise).
177fn 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
193/// Pass 2 — name-only fallback over the unmatched. Pairings happen only
194/// when a name appears exactly once on each side (the unambiguous case).
195fn 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
239/// Collect baseline entries with no surviving pair into [`RemovedEntry`]s.
240fn 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/// Join current results against a baseline and compute per-function deltas.
256///
257/// **Two-pass match** (spec 13):
258///
259/// 1. Exact `(file_path, function_name)` pair — the original behaviour.
260/// 2. Among entries Pass 1 left as `New` (current side) and the unmatched
261///    baseline entries (Removed side), pair any function name that appears
262///    **exactly once** on each side. Score-unchanged pairings become
263///    [`DeltaStatus::Moved`]; score-changed pairings keep their
264///    `Regressed` / `Improved` status. Either way, the entry's
265///    `previous_file` records the baseline location.
266///
267/// Ambiguous names (multiple unmatched entries with the same name) are
268/// left unpaired — there's no way to tell which moved where. They keep
269/// their `New` / Removed status.
270///
271/// `epsilon` is the tolerance for the regression detector — see
272/// [`DEFAULT_EPSILON`].
273#[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        // delta = exactly DEFAULT_EPSILON must be Unchanged, not Regressed.
345        // Kills: replacing `>` with `>=` in the Regressed branch.
346        //
347        // Use baseline=0.0 so `current - 0.0 == DEFAULT_EPSILON` exactly in floating
348        // point. Using `5.0 + DEFAULT_EPSILON - 5.0` causes catastrophic cancellation
349        // that yields a value slightly below DEFAULT_EPSILON, making the `>=` mutant
350        // indistinguishable from the original `>`.
351        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        // delta strictly above DEFAULT_EPSILON must be Regressed.
366        // Paired with the boundary test to pin both sides of the comparison.
367        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        // delta = exactly -DEFAULT_EPSILON must be Unchanged, not Improved.
378        // Kills: replacing `<` with `<=` in the Improved branch.
379        // Same zero-baseline trick to guarantee exact floating-point equality.
380        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        // delta strictly below -DEFAULT_EPSILON must be Improved.
395        // Paired with the boundary test to pin both sides.
396        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        // foo: regressed(+5), bar: improved(-6), baz: new
421        let report = compute_delta(&current, &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(&current, &[], 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        // Spec 13: a function with the same name in only one file on each
436        // side gets paired by name during the second-pass matcher. Same
437        // CC + coverage + crap → status `Moved`, not `New`/`Removed`.
438        //
439        // Also kills: `path_key -> String` collapsing to a constant.
440        // Under that mutation, pass 1 would falsely match these as
441        // Unchanged with previous_file = None — distinguishable from the
442        // correct (Moved, Some(src/main.rs)) outcome.
443        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"), // different file, same function name
454            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(&current, &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        // Baseline saved on Linux (forward slashes); current run on Windows
481        // (backslashes). path_key must normalize both to the same key.
482        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(&current, &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    // --- tunable epsilon ---------------------------------------------------
510
511    #[test]
512    fn custom_epsilon_zero_catches_sub_default_deltas() {
513        // delta = 0.001 is below DEFAULT_EPSILON (0.01) and would normally
514        // be Unchanged — but with epsilon=0.0 any positive delta is a regression.
515        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        // delta = 0.4 is well above DEFAULT_EPSILON; with a relaxed
522        // epsilon=0.5 it should still classify as Unchanged.
523        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        // Improvements must also use the custom epsilon: -0.001 with eps=0.0
530        // is Improved, not Unchanged.
531        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    // --- load_baseline contract --------------------------------------------
536
537    #[test]
538    fn load_baseline_accepts_wrapped_envelope() {
539        // The format produced by `cargo crap --format json` since spec 02.
540        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    // ─── Move-aware delta detection (spec 13) ────────────────────────────
565
566    /// Build a `CrapEntry` parameterized by file + function + score so the
567    /// move-detection scenarios can mint pairs without copy-paste.
568    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        // Pure refactor: function moves between files with identical CC,
587        // coverage and crap → status `Moved`, previous_file recorded,
588        // baseline entry NOT in `removed`.
589        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(&current, &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        // Function moved AND got worse — the score-status takes precedence
604        // over the bare `Moved` label, but previous_file still records the
605        // move so renderers can show "Regressed, moved from <prev>".
606        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(&current, &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        // Symmetry test: moved + got better → Improved, not Moved.
622        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(&current, &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        // Two `helper`s on each side → can't tell which moved where.
635        // Both baseline entries become Removed; both current entries stay
636        // New with previous_file = None.
637        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(&current, &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        // Name does not appear in baseline → unchanged behaviour: New.
660        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(&current, &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        // Name does not appear in current → unchanged behaviour: Removed.
675        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(&current, &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        // `foo` lives at the same path on both sides AND another `foo`
688        // exists in the baseline at a different path. The pass-1 exact
689        // pair must win; the second `foo` must NOT trigger a name-only
690        // pairing (which would be ambiguous: 1 unmatched current, 1
691        // unmatched baseline) — but in fact the current side has zero
692        // unmatched `foo`s after pass 1, so pass 2 finds no candidate
693        // and the orphan baseline `foo` lands in `removed`.
694        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(&current, &baseline, DEFAULT_EPSILON);
700        // Pass 1: src/a.rs:foo matches exactly → Unchanged, no previous_file.
701        assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
702        assert!(report.entries[0].previous_file.is_none());
703        // Pass 2: no unmatched current entry to pair → src/b.rs:foo is
704        // a genuine deletion.
705        assert_eq!(report.removed.len(), 1);
706        assert_eq!(report.removed[0].file, PathBuf::from("src/b.rs"));
707    }
708}