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#[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
126/// Classify a numeric delta against the epsilon tolerance.
127fn 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
140/// Build the initial `DeltaEntry` for a single current-side entry against
141/// the pass-1 (file, function) index. Sets `previous_file = None` (pass 2
142/// fills it in for paired moves).
143fn 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
164/// Pass 1 — exact `(file_path, function_name, start_line)` match. Returns the entry
165/// list (some still `New`, awaiting pass 2) and the set of baseline keys
166/// that were matched (so pass 2 / removed-collection can skip them).
167fn 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
189/// Apply a single move pairing in place: fill in `baseline_crap` / delta /
190/// `previous_file` and choose the right status (`Moved` for pure relocations,
191/// the score-status otherwise).
192fn 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
208/// Pass 2 — name-only fallback over the unmatched. Pairings happen only
209/// when a name appears exactly once on each side (the unambiguous case).
210fn 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
250/// Collect baseline entries with no surviving pair into [`RemovedEntry`]s.
251fn 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/// Join current results against a baseline and compute per-function deltas.
267///
268/// **Two-pass match** (spec 13):
269///
270/// 1. Exact `(file_path, function_name)` pair — the original behaviour.
271/// 2. Among entries Pass 1 left as `New` (current side) and the unmatched
272///    baseline entries (Removed side), pair any function name that appears
273///    **exactly once** on each side. Score-unchanged pairings become
274///    [`DeltaStatus::Moved`]; score-changed pairings keep their
275///    `Regressed` / `Improved` status. Either way, the entry's
276///    `previous_file` records the baseline location.
277///
278/// Ambiguous names (multiple unmatched entries with the same name) are
279/// left unpaired — there's no way to tell which moved where. They keep
280/// their `New` / Removed status.
281///
282/// `epsilon` is the tolerance for the regression detector — see
283/// [`DEFAULT_EPSILON`].
284#[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        // delta = exactly DEFAULT_EPSILON must be Unchanged, not Regressed.
356        // Kills: replacing `>` with `>=` in the Regressed branch.
357        //
358        // Use baseline=0.0 so `current - 0.0 == DEFAULT_EPSILON` exactly in floating
359        // point. Using `5.0 + DEFAULT_EPSILON - 5.0` causes catastrophic cancellation
360        // that yields a value slightly below DEFAULT_EPSILON, making the `>=` mutant
361        // indistinguishable from the original `>`.
362        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        // delta strictly above DEFAULT_EPSILON must be Regressed.
377        // Paired with the boundary test to pin both sides of the comparison.
378        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        // delta = exactly -DEFAULT_EPSILON must be Unchanged, not Improved.
389        // Kills: replacing `<` with `<=` in the Improved branch.
390        // Same zero-baseline trick to guarantee exact floating-point equality.
391        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        // delta strictly below -DEFAULT_EPSILON must be Improved.
406        // Paired with the boundary test to pin both sides.
407        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        // foo: regressed(+5), bar: improved(-6), baz: new
432        let report = compute_delta(&current, &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(&current, &[], 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        // Spec 13: a function with the same name in only one file on each
447        // side gets paired by name during the second-pass matcher. Same
448        // CC + coverage + crap → status `Moved`, not `New`/`Removed`.
449        //
450        // Also kills: `path_key -> String` collapsing to a constant.
451        // Under that mutation, pass 1 would falsely match these as
452        // Unchanged with previous_file = None — distinguishable from the
453        // correct (Moved, Some(src/main.rs)) outcome.
454        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"), // different file, same function name
465            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(&current, &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        // Baseline saved on Linux (forward slashes); current run on Windows
492        // (backslashes). path_key must normalize both to the same key.
493        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(&current, &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    // --- tunable epsilon ---------------------------------------------------
521
522    #[test]
523    fn custom_epsilon_zero_catches_sub_default_deltas() {
524        // delta = 0.001 is below DEFAULT_EPSILON (0.01) and would normally
525        // be Unchanged — but with epsilon=0.0 any positive delta is a regression.
526        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        // delta = 0.4 is well above DEFAULT_EPSILON; with a relaxed
533        // epsilon=0.5 it should still classify as Unchanged.
534        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        // Improvements must also use the custom epsilon: -0.001 with eps=0.0
541        // is Improved, not Unchanged.
542        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    // --- load_baseline contract --------------------------------------------
547
548    #[test]
549    fn load_baseline_accepts_wrapped_envelope() {
550        // The format produced by `cargo crap --format json` since spec 02.
551        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    // ─── Move-aware delta detection (spec 13) ────────────────────────────
576
577    /// Build a `CrapEntry` parameterized by file + function + score so the
578    /// move-detection scenarios can mint pairs without copy-paste.
579    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        // Pure refactor: function moves between files with identical CC,
598        // coverage and crap → status `Moved`, previous_file recorded,
599        // baseline entry NOT in `removed`.
600        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(&current, &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        // Function moved AND got worse — the score-status takes precedence
615        // over the bare `Moved` label, but previous_file still records the
616        // move so renderers can show "Regressed, moved from <prev>".
617        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(&current, &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        // Symmetry test: moved + got better → Improved, not Moved.
633        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(&current, &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        // Two `helper`s on each side → can't tell which moved where.
646        // Both baseline entries become Removed; both current entries stay
647        // New with previous_file = None.
648        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(&current, &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        // Name does not appear in baseline → unchanged behaviour: New.
671        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(&current, &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        // Name does not appear in current → unchanged behaviour: Removed.
686        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(&current, &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        // `foo` lives at the same path on both sides AND another `foo`
699        // exists in the baseline at a different path. The pass-1 exact
700        // pair must win; the second `foo` must NOT trigger a name-only
701        // pairing (which would be ambiguous: 1 unmatched current, 1
702        // unmatched baseline) — but in fact the current side has zero
703        // unmatched `foo`s after pass 1, so pass 2 finds no candidate
704        // and the orphan baseline `foo` lands in `removed`.
705        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(&current, &baseline, DEFAULT_EPSILON);
711        // Pass 1: src/a.rs:foo matches exactly → Unchanged, no previous_file.
712        assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
713        assert!(report.entries[0].previous_file.is_none());
714        // Pass 2: no unmatched current entry to pair → src/b.rs:foo is
715        // a genuine deletion.
716        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        // Two cfg-gated definitions of `platform_handler` in the same file
723        // at different start lines. On an identical back-to-back run the
724        // pass-1 key must distinguish them by line number so neither is
725        // mis-paired and the result is Unchanged for both.
726        let baseline = vec![
727            CrapEntry {
728                file: PathBuf::from("src/lib.rs"),
729                function: "platform_handler".into(),
730                line: 2, // #[cfg(unix)] arm — CC 5, 0 % coverage
731                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, // #[cfg(not(unix))] arm — CC 1, 100 % coverage
740                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(&current, &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}