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, SortOrder};
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    /// Apply the user-requested [`SortOrder`] (spec 17). `entries` are ordered
83    /// by the *current* entry's key; `removed` is ordered by `(file, function)`
84    /// for `File`, making removed ordering deterministic regardless of the
85    /// match-pass internals. `Crap` orders `entries` by score descending and
86    /// leaves `removed` in baseline order.
87    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(&current_key(&b.current)));
101                self.removed
102                    .sort_by(|a, b| removed_key(a).cmp(&removed_key(b)));
103            },
104        }
105    }
106
107    /// Number of functions whose CRAP score increased since the baseline.
108    #[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
117/// Load a JSON baseline produced by a previous `cargo crap --format json` run.
118pub 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
134/// File-order key for a current entry: `(file, function, line)` ascending,
135/// normalized to forward slashes so the ordering is platform-stable (spec 17).
136fn current_key(e: &CrapEntry) -> (String, &str, usize) {
137    (path_key(&e.file), e.function.as_str(), e.line)
138}
139
140/// File-order key for a removed entry: `(file, function)` ascending.
141fn 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
162/// Classify a numeric delta against the epsilon tolerance.
163fn 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
176/// Build the initial `DeltaEntry` for a single current-side entry against
177/// the pass-1 (file, function) index. Sets `previous_file = None` (pass 2
178/// fills it in for paired moves).
179fn 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
200/// Pass 1 — exact `(file_path, function_name, start_line)` match. Returns the entry
201/// list (some still `New`, awaiting pass 2) and the set of baseline keys
202/// that were matched (so pass 2 / removed-collection can skip them).
203fn 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
225/// Apply a single move pairing in place: fill in `baseline_crap` / delta /
226/// `previous_file` and choose the right status (`Moved` for pure relocations,
227/// the score-status otherwise).
228fn 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
244/// Apply a suffix pairing in place: same logical file under a different
245/// root, so the entry is filled exactly like a pass-1 match — score-based
246/// status, no `previous_file` (spec 21: a root remap is not a move).
247fn 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
258/// Number of trailing path components two files share, after forward-slash
259/// normalization. `0` means not even the filename matches.
260fn 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
273/// For `from`, find the index in `candidates` whose file shares the longest
274/// common path suffix with it. Returns `None` when no candidate shares at
275/// least the filename, or when the best score is tied (ambiguous).
276fn 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; // (candidate idx, score)
282    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
306/// Pass 1.5 — suffix-aware file match (spec 21). Among entries pass 1 left
307/// `New` and the unmatched baseline entries, pair same-name entries whose
308/// files share their longest common component-suffix (at minimum the
309/// filename), when each is the other's unique best. This keeps function
310/// identity deterministic when the baseline was recorded under a different
311/// checkout root, instead of collapsing onto the name-only fallback.
312///
313/// Scoring runs against a snapshot taken at pass start (one round, no
314/// fixpoint): the mutual-unique-best requirement already guarantees no
315/// baseline entry is assigned twice, and snapshot semantics keep the
316/// result independent of iteration order.
317fn 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            // Mutual unique best: the chosen baseline entry must pick this
350            // current entry back, or the pairing is ambiguous.
351            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
365/// Pass 2 — name-only fallback over the unmatched. Pairings happen only
366/// when a name appears exactly once on each side (the unambiguous case).
367fn 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
407/// Collect baseline entries with no surviving pair into [`RemovedEntry`]s.
408fn 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/// Join current results against a baseline and compute per-function deltas.
424///
425/// **Three-pass match** (specs 13 and 21):
426///
427/// 1. Exact `(file_path, function_name, start_line)` pair — the original
428///    behaviour.
429/// 2. Suffix-aware file match (spec 21) — same-name entries whose files
430///    share their longest common component-suffix pair as the *same
431///    logical file* (root remap, absolute-vs-relative paths). Statuses
432///    follow the epsilon rule; `previous_file` stays `None`.
433/// 3. Name-only fallback (spec 13) — among entries still `New` (current
434///    side) and the unmatched baseline entries (Removed side), pair any
435///    function name that appears **exactly once** on each side.
436///    Score-unchanged pairings become [`DeltaStatus::Moved`];
437///    score-changed pairings keep their `Regressed` / `Improved` status.
438///    Either way, the entry's `previous_file` records the baseline
439///    location.
440///
441/// Ambiguous names (multiple unmatched entries with the same name) are
442/// left unpaired — there's no way to tell which moved where. They keep
443/// their `New` / Removed status.
444///
445/// `epsilon` is the tolerance for the regression detector — see
446/// [`DEFAULT_EPSILON`].
447#[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        // delta = exactly DEFAULT_EPSILON must be Unchanged, not Regressed.
520        // Kills: replacing `>` with `>=` in the Regressed branch.
521        //
522        // Use baseline=0.0 so `current - 0.0 == DEFAULT_EPSILON` exactly in floating
523        // point. Using `5.0 + DEFAULT_EPSILON - 5.0` causes catastrophic cancellation
524        // that yields a value slightly below DEFAULT_EPSILON, making the `>=` mutant
525        // indistinguishable from the original `>`.
526        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        // delta strictly above DEFAULT_EPSILON must be Regressed.
541        // Paired with the boundary test to pin both sides of the comparison.
542        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        // delta = exactly -DEFAULT_EPSILON must be Unchanged, not Improved.
553        // Kills: replacing `<` with `<=` in the Improved branch.
554        // Same zero-baseline trick to guarantee exact floating-point equality.
555        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        // delta strictly below -DEFAULT_EPSILON must be Improved.
570        // Paired with the boundary test to pin both sides.
571        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        // foo: regressed(+5), bar: improved(-6), baz: new
596        let report = compute_delta(&current, &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(&current, &[], 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        // Spec 13: a function with the same name in only one file on each
611        // side gets paired by name during the second-pass matcher. Same
612        // CC + coverage + crap → status `Moved`, not `New`/`Removed`.
613        //
614        // Also kills: `path_key -> String` collapsing to a constant.
615        // Under that mutation, pass 1 would falsely match these as
616        // Unchanged with previous_file = None — distinguishable from the
617        // correct (Moved, Some(src/main.rs)) outcome.
618        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"), // different file, same function name
629            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(&current, &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        // Baseline saved on Linux (forward slashes); current run on Windows
656        // (backslashes). path_key must normalize both to the same key.
657        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(&current, &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    // --- tunable epsilon ---------------------------------------------------
685
686    #[test]
687    fn custom_epsilon_zero_catches_sub_default_deltas() {
688        // delta = 0.001 is below DEFAULT_EPSILON (0.01) and would normally
689        // be Unchanged — but with epsilon=0.0 any positive delta is a regression.
690        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        // delta = 0.4 is well above DEFAULT_EPSILON; with a relaxed
697        // epsilon=0.5 it should still classify as Unchanged.
698        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        // Improvements must also use the custom epsilon: -0.001 with eps=0.0
705        // is Improved, not Unchanged.
706        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    // --- load_baseline contract --------------------------------------------
711
712    #[test]
713    fn load_baseline_accepts_wrapped_envelope() {
714        // The format produced by `cargo crap --format json` since spec 02.
715        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    // ─── Move-aware delta detection (spec 13) ────────────────────────────
740
741    /// Build a `CrapEntry` parameterized by file + function + score so the
742    /// move-detection scenarios can mint pairs without copy-paste.
743    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        // Pure refactor: function moves between files with identical CC,
762        // coverage and crap → status `Moved`, previous_file recorded,
763        // baseline entry NOT in `removed`.
764        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(&current, &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        // Function moved AND got worse — the score-status takes precedence
779        // over the bare `Moved` label, but previous_file still records the
780        // move so renderers can show "Regressed, moved from <prev>".
781        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(&current, &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        // Symmetry test: moved + got better → Improved, not Moved.
797        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(&current, &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        // Two `helper`s on each side → can't tell which moved where.
810        // Both baseline entries become Removed; both current entries stay
811        // New with previous_file = None.
812        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(&current, &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        // Name does not appear in baseline → unchanged behaviour: New.
835        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(&current, &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        // Name does not appear in current → unchanged behaviour: Removed.
850        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(&current, &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        // `foo` lives at the same path on both sides AND another `foo`
863        // exists in the baseline at a different path. The pass-1 exact
864        // pair must win; the second `foo` must NOT trigger a name-only
865        // pairing (which would be ambiguous: 1 unmatched current, 1
866        // unmatched baseline) — but in fact the current side has zero
867        // unmatched `foo`s after pass 1, so pass 2 finds no candidate
868        // and the orphan baseline `foo` lands in `removed`.
869        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(&current, &baseline, DEFAULT_EPSILON);
875        // Pass 1: src/a.rs:foo matches exactly → Unchanged, no previous_file.
876        assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
877        assert!(report.entries[0].previous_file.is_none());
878        // Pass 2: no unmatched current entry to pair → src/b.rs:foo is
879        // a genuine deletion.
880        assert_eq!(report.removed.len(), 1);
881        assert_eq!(report.removed[0].file, PathBuf::from("src/b.rs"));
882    }
883
884    // --- DeltaReport::sort (spec 17) ---------------------------------------
885
886    #[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        // Kills: ascending comparator in the Crap arm of DeltaReport::sort.
925        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        // Two cfg-gated definitions of `platform_handler` in the same file
944        // at different start lines. On an identical back-to-back run the
945        // pass-1 key must distinguish them by line number so neither is
946        // mis-paired and the result is Unchanged for both.
947        let baseline = vec![
948            CrapEntry {
949                file: PathBuf::from("src/lib.rs"),
950                function: "platform_handler".into(),
951                line: 2, // #[cfg(unix)] arm — CC 5, 0 % coverage
952                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, // #[cfg(not(unix))] arm — CC 1, 100 % coverage
961                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(&current, &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        // Kills: pass_suffix_match removed / apply_suffix_pairing setting
1004        // previous_file. A root remap must match as the same logical file.
1005        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(&current, &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        // The issue-#46 core case: `run` exists in two files; the name-only
1023        // fallback cannot pair them, but directory suffixes can.
1024        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(&current, &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        // backup.rs regressed, restore.rs did not — the pairing must not
1047        // cross the two files (which would hide the regression).
1048        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(&current, &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(&current, &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        // One baseline `util.rs:helper`, two current candidates tie at the
1080        // filename — ambiguous, so nothing pairs (and the name fallback
1081        // declines too: two current-side entries).
1082        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(&current, &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        // Kills: adding a line-equality requirement to the suffix pass.
1097        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        // Spec-13 behaviour preserved: no shared suffix → the suffix pass
1109        // declines and the name fallback pairs it as Moved.
1110        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(&current, &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        // `src/a.rs:helper` matches exactly on pass 1; the leftover baseline
1123        // `src/b.rs:helper` must not steal it via the suffix pass (b.rs and
1124        // a.rs share no suffix with each other's remaining candidates). The
1125        // leftovers — one `helper` on each side — then pair through the
1126        // spec-13 name fallback as a move, exactly as before spec 21.
1127        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(&current, &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        // Kills: mutants in unique_best_by_suffix's replace logic that pick
1164        // the later/worse candidate or flag spurious ties. The two baseline
1165        // entries carry different scores so a wrong pick changes the delta.
1166        let baseline = vec![
1167            entry_at("/app/src/backup.rs", "run", 5.0), // suffix depth 2
1168            entry_at("/app/legacy/backup.rs", "run", 9.0), // suffix depth 1
1169        ];
1170        let current = vec![entry_at("/co/src/backup.rs", "run", 5.0)];
1171        let report = compute_delta(&current, &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        // Same as above with the baseline order reversed: kills mutants
1185        // that never replace the incumbent best candidate.
1186        let baseline = vec![
1187            entry_at("/app/legacy/backup.rs", "run", 9.0), // suffix depth 1
1188            entry_at("/app/src/backup.rs", "run", 5.0),    // suffix depth 2
1189        ];
1190        let current = vec![entry_at("/co/src/backup.rs", "run", 5.0)];
1191        let report = compute_delta(&current, &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        // Two candidates tie at the filename, then a strictly deeper match
1203        // arrives: the tie must be cleared and the pairing made. Kills
1204        // mutants that drop the `tied = false` reset.
1205        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), // suffix depth 2
1209        ];
1210        let current = vec![entry_at("/co/src/util.rs", "helper", 3.0)];
1211        let report = compute_delta(&current, &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        // path_key normalizes back-slashes, so a baseline written on
1224        // Windows pairs with POSIX analysis paths.
1225        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(&current, &baseline, DEFAULT_EPSILON);
1228        assert_eq!(report.entries[0].status, DeltaStatus::Unchanged);
1229        assert!(report.removed.is_empty());
1230    }
1231}