Skip to main content

cargo_crap/
merge.rs

1//! Join complexity data (per-function) with coverage data (per-file) into
2//! CRAP entries.
3//!
4//! ## The path-matching problem
5//!
6//! This is where the silent failure mode lives. The complexity pass gives
7//! us absolute paths (whatever was passed to `analyze_tree`). LCOV files
8//! can contain:
9//!
10//! 1. **Absolute paths**  — `/home/alice/project/src/foo.rs`
11//! 2. **Workspace-relative paths** — `src/foo.rs`
12//! 3. **Crate-relative paths in a workspace** — `crates/core/src/foo.rs`
13//! 4. **Paths with `./` or `../` components** — `./src/foo.rs`
14//!
15//! `cargo llvm-cov` by default emits workspace-relative paths. `cargo tarpaulin`
16//! emits absolute paths. CI systems with symlinked or containerized
17//! checkouts mix both. A naïve `HashMap<PathBuf, _>` lookup will silently
18//! return `None` for 100% of files and report every function as "0%
19//! covered" — which is exactly the class of bug where a green CI suddenly
20//! starts red-lining a whole codebase.
21//!
22//! Our strategy: build a lookup keyed on **canonicalized suffix matches**.
23//! For every coverage path we can't canonicalize (because it's relative),
24//! we try progressively shorter suffixes against canonical complexity paths.
25//!
26//! Ambiguity is resolved deterministically (spec 26): among several
27//! suffix-matching keys the longest wins, and different spellings of one
28//! file (canonical aliases, `./`-prefixed variants) merge their line data
29//! instead of racing on map order.
30
31use crate::complexity::FunctionComplexity;
32use crate::coverage::FileCoverage;
33use crate::score::crap;
34use serde::{Deserialize, Serialize};
35use std::borrow::Cow;
36use std::collections::hash_map::Entry;
37use std::collections::{HashMap, HashSet};
38use std::path::{Component, Path, PathBuf};
39
40/// One row in the final report.
41#[derive(Debug, Clone, Serialize, serde::Deserialize)]
42pub struct CrapEntry {
43    pub file: PathBuf,
44    pub function: String,
45    pub line: usize,
46    pub cyclomatic: f64,
47    /// Percentage; may be `None` if we could not find coverage data for
48    /// this file at all. That's different from "0% covered" — it means the
49    /// coverage report didn't mention the file.
50    pub coverage: Option<f64>,
51    pub crap: f64,
52    /// Cargo workspace member name, set by `--workspace` runs after the
53    /// entry's file path has been suffix-matched against a member root.
54    /// Always `None` for non-workspace runs and for older baselines that
55    /// pre-date this field.
56    #[serde(rename = "crate", default, skip_serializing_if = "Option::is_none")]
57    pub crate_name: Option<String>,
58}
59
60/// Final ordering applied to the report entries (spec 17).
61///
62/// [`merge`] always sorts by CRAP descending first — that ordering is the
63/// selection invariant `--top` relies on. The user-requested sort is applied
64/// as a separate, final step via [`sort_entries`].
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
66#[serde(rename_all = "lowercase")]
67pub enum SortOrder {
68    /// CRAP score descending — the right order for humans reading top-down.
69    #[default]
70    Crap,
71    /// `(file, function, line)` ascending — stable across score changes, so a
72    /// committed JSON baseline produces minimal diffs.
73    File,
74}
75
76/// Stable `(file, function, line)` sort key. The file path is normalized to
77/// forward slashes so baselines written on different platforms sort the same.
78fn file_order_key(e: &CrapEntry) -> (String, &str, usize) {
79    (
80        e.file.to_string_lossy().replace('\\', "/"),
81        e.function.as_str(),
82        e.line,
83    )
84}
85
86/// Apply the user-requested [`SortOrder`] to an entry slice in place.
87///
88/// Call this *after* `--allow` / `--min` / `--top` have run: `--top` selects
89/// the N highest-CRAP functions against [`merge`]'s descending order, and this
90/// only reorders the survivors for display (spec 17).
91pub fn sort_entries(
92    entries: &mut [CrapEntry],
93    order: SortOrder,
94) {
95    match order {
96        SortOrder::Crap => entries.sort_by(|a, b| {
97            b.crap
98                .partial_cmp(&a.crap)
99                .unwrap_or(std::cmp::Ordering::Equal)
100        }),
101        SortOrder::File => entries.sort_by(|a, b| file_order_key(a).cmp(&file_order_key(b))),
102    }
103}
104
105/// How to treat functions we have complexity data for but no coverage data.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum MissingCoveragePolicy {
109    /// Assume 0% coverage. Pessimistic — good for CI gates, where unmapped
110    /// files are a red flag worth surfacing.
111    Pessimistic,
112    /// Assume 100% coverage. Optimistic — suitable for interactive use where
113    /// you've scoped coverage to a subset of the tree intentionally.
114    Optimistic,
115    /// Skip the function entirely; don't emit a row.
116    Skip,
117}
118
119/// Cap on example paths carried per stray side of [`ScopeDiagnostics`].
120/// `count` always holds the true total; only the examples are bounded, so
121/// a 1000-file mismatch stays readable on stderr and in JSON (spec 24).
122pub const SCOPE_EXAMPLE_CAP: usize = 10;
123
124/// One side's stray files: the true count plus at most
125/// [`SCOPE_EXAMPLE_CAP`] example paths, sorted.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct StrayFiles {
128    pub count: usize,
129    pub examples: Vec<PathBuf>,
130}
131
132impl StrayFiles {
133    fn new(mut files: Vec<PathBuf>) -> Self {
134        files.sort();
135        let count = files.len();
136        files.truncate(SCOPE_EXAMPLE_CAP);
137        Self {
138            count,
139            examples: files,
140        }
141    }
142}
143
144/// Source/LCOV scope diagnostics (spec 24): how well the analyzed source
145/// tree and the LCOV report overlap. A large stray set on either side means
146/// the two inputs describe different scopes — the classic cause of a delta
147/// full of unrelated 0%-coverage entries.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ScopeDiagnostics {
150    /// Distinct source files that produced at least one analyzed function.
151    pub analyzed_files: usize,
152    /// Distinct `SF` records in the LCOV report.
153    pub lcov_files: usize,
154    /// Files present on both sides after path matching.
155    pub matched_files: usize,
156    /// Analyzed files with no LCOV match.
157    pub source_only: StrayFiles,
158    /// LCOV `SF` files matched by no analyzed file.
159    pub lcov_only: StrayFiles,
160}
161
162/// Output of [`merge`]: the scored entries plus scope diagnostics.
163pub struct MergeResult {
164    /// CRAP entries sorted by score descending.
165    pub entries: Vec<CrapEntry>,
166    /// Source/LCOV overlap diagnostics. `Some` exactly when a non-empty
167    /// coverage map was provided; `None` for complexity-only runs.
168    pub diagnostics: Option<ScopeDiagnostics>,
169}
170
171/// Merge complexity and coverage data into a sorted [`MergeResult`]
172/// (entries ranked highest score first).
173#[expect(
174    clippy::needless_pass_by_value,
175    reason = "callers always have a fresh HashMap they don't reuse; taking by value matches the consuming pipeline and avoids `&cov` boilerplate at every call site"
176)]
177#[must_use]
178pub fn merge(
179    complexity: Vec<FunctionComplexity>,
180    coverage: HashMap<PathBuf, FileCoverage>,
181    policy: MissingCoveragePolicy,
182) -> MergeResult {
183    let index = PathIndex::build(&coverage);
184    let has_coverage = !coverage.is_empty();
185
186    let mut mapped_files: HashSet<PathBuf> = HashSet::new();
187    let mut seen_files: HashSet<PathBuf> = HashSet::new();
188    // Raw LCOV keys consumed by at least one lookup — the complement is the
189    // lcov_only side of the scope diagnostics.
190    let mut used_lcov_keys: HashSet<PathBuf> = HashSet::new();
191
192    let mut entries: Vec<CrapEntry> = complexity
193        .into_iter()
194        .filter_map(|fc| {
195            let hit = index.lookup(&fc.file);
196            let cov = hit.map(|found| found.cov.coverage_in_span(fc.start_line, fc.end_line));
197
198            if has_coverage {
199                if let Some(found) = hit {
200                    mapped_files.insert(fc.file.clone());
201                    used_lcov_keys.extend(found.spellings.iter().map(|s| s.to_path_buf()));
202                }
203                seen_files.insert(fc.file.clone());
204            }
205
206            let cov_for_scoring = match (cov, policy) {
207                (Some(c), _) => c,
208                (None, MissingCoveragePolicy::Pessimistic) => 0.0,
209                (None, MissingCoveragePolicy::Optimistic) => 100.0,
210                (None, MissingCoveragePolicy::Skip) => return None,
211            };
212
213            let crap_score = crap(fc.cyclomatic, cov_for_scoring);
214            Some(CrapEntry {
215                file: fc.file,
216                function: fc.name,
217                line: fc.start_line,
218                cyclomatic: fc.cyclomatic,
219                coverage: cov,
220                crap: crap_score,
221                crate_name: None,
222            })
223        })
224        .collect();
225
226    entries.sort_by(|a, b| {
227        b.crap
228            .partial_cmp(&a.crap)
229            .unwrap_or(std::cmp::Ordering::Equal)
230    });
231
232    let diagnostics = has_coverage.then(|| {
233        let source_only: Vec<PathBuf> = seen_files
234            .iter()
235            .filter(|f| !mapped_files.contains(*f))
236            .cloned()
237            .collect();
238        // Every spelling behind a consumed index entry — aliases of a
239        // symlinked checkout root, `lcov -a`-merged legs, `./`-prefixed
240        // variants — was recorded in `used_lcov_keys` at lookup time
241        // (spec 26), so the stray set is a plain complement. No
242        // re-canonicalization, and relative keys are never resolved.
243        let lcov_only: Vec<PathBuf> = coverage
244            .keys()
245            .filter(|k| !used_lcov_keys.contains(*k))
246            .cloned()
247            .collect();
248        ScopeDiagnostics {
249            analyzed_files: seen_files.len(),
250            lcov_files: coverage.len(),
251            matched_files: mapped_files.len(),
252            source_only: StrayFiles::new(source_only),
253            lcov_only: StrayFiles::new(lcov_only),
254        }
255    });
256
257    MergeResult {
258        entries,
259        diagnostics,
260    }
261}
262
263/// Coverage data reachable through one index key, together with every raw
264/// LCOV spelling that fed it. Entries start borrowed and are copied only
265/// when a second spelling merges in (spec 26), so the common unambiguous
266/// case stays allocation-free.
267struct IndexedCoverage<'a> {
268    /// Raw `SF` spellings behind this entry. All of them count as consumed
269    /// when a lookup binds here — the spec-24 diagnostics must not report
270    /// an alias of a matched file as a stray.
271    spellings: Vec<&'a Path>,
272    cov: Cow<'a, FileCoverage>,
273}
274
275/// A path lookup index that handles absolute-vs-relative mismatches between
276/// the complexity pass (which has whatever was on the command line) and the
277/// coverage file (which has whatever the coverage tool decided to write).
278struct PathIndex<'a> {
279    /// Canonicalized absolute paths → merged coverage. Fast path. Aliased
280    /// spellings of one real file (symlinked roots, `lcov -a` legs) merge
281    /// their line data here instead of overwriting each other (spec 26).
282    by_absolute: HashMap<PathBuf, IndexedCoverage<'a>>,
283    /// Suffix-matching tier: relative keys, plus absolute keys that don't
284    /// canonicalize (coverage produced in a container at a different root),
285    /// keyed by their normalized components. Component-equal spellings
286    /// (`src/lib.rs` vs `./src/lib.rs`) merged at build time, so the
287    /// needles are pairwise component-distinct.
288    by_relative: Vec<(PathBuf, IndexedCoverage<'a>)>,
289}
290
291impl<'a> PathIndex<'a> {
292    fn build(coverage: &'a HashMap<PathBuf, FileCoverage>) -> Self {
293        let mut by_absolute = HashMap::new();
294        let mut by_suffix = HashMap::new();
295
296        for (raw_path, cov) in coverage {
297            if let Some(abs) = fast_path_key(raw_path) {
298                insert_or_merge(&mut by_absolute, abs, raw_path, cov);
299            } else {
300                // A degenerate key with no meaningful components (`SF:.`,
301                // empty `SF:`) would become an empty needle, and an empty
302                // needle trivially suffix-matches every query. Keep it out
303                // of the index entirely so it surfaces as an lcov_only
304                // stray (spec 24) instead of silently binding unmatched
305                // files to garbage data.
306                let key = normalized(raw_path);
307                if !key.as_os_str().is_empty() {
308                    insert_or_merge(&mut by_suffix, key, raw_path, cov);
309                }
310            }
311        }
312
313        Self {
314            by_absolute,
315            by_relative: by_suffix.into_iter().collect(),
316        }
317    }
318
319    /// Find coverage for `query`. The hit carries every raw LCOV spelling
320    /// it consumed (they feed the scope diagnostics).
321    fn lookup(
322        &self,
323        query: &Path,
324    ) -> Option<&IndexedCoverage<'a>> {
325        // Fast path: direct canonical match.
326        if let Ok(abs) = query.canonicalize()
327            && let Some(hit) = self.by_absolute.get(&abs)
328        {
329            return Some(hit);
330        }
331
332        // Slow path: suffix match. A coverage path `src/foo.rs` matches a
333        // complexity path `.../project/src/foo.rs` if the former is a
334        // component-wise suffix of the latter. Among several matching
335        // needles the most specific (longest) one wins — and that maximum
336        // is unique by construction: two component-distinct needles of
337        // equal length cannot both be a suffix of one query (spec 26).
338        self.by_relative
339            .iter()
340            .filter(|(needle, _)| path_has_suffix(query, needle))
341            .max_by_key(|(needle, _)| needle.components().count())
342            .map(|(_, hit)| hit)
343    }
344}
345
346/// The canonical fast-path key for `raw_path`, or `None` when it belongs
347/// in the suffix tier.
348///
349/// CRITICAL: only *absolute* paths are canonicalized. A relative path like
350/// `src/lib.rs` in an LCOV file means "some file whose component-suffix is
351/// this" — it must NOT be resolved against the caller's CWD, because the
352/// CWD is an accident of invocation. Early versions of this code called
353/// `canonicalize()` unconditionally; if the CWD happened to contain a
354/// matching path, the coverage entry would silently bind to the wrong file
355/// and every real function would come back as 0% covered. The integration
356/// test `end_to_end_pipeline_produces_ranked_scores` exists specifically
357/// to catch a regression back into that behavior.
358fn fast_path_key(raw_path: &Path) -> Option<PathBuf> {
359    if raw_path.is_absolute() {
360        raw_path.canonicalize().ok()
361    } else {
362        None
363    }
364}
365
366/// A path reduced to its meaningful components: `./src/lib.rs` and
367/// `src/lib.rs` normalize identically, so spelling variants of one logical
368/// file share a suffix-tier key (and merge, per spec 26). Pure component
369/// surgery — no filesystem access, preserving the CWD invariant.
370fn normalized(path: &Path) -> PathBuf {
371    path.components()
372        .filter(|c| !matches!(c, Component::CurDir))
373        .collect()
374}
375
376/// Add one raw LCOV record under `key`, merging line data (per-line
377/// saturating sum) when the key is already taken. Order-independent by
378/// commutativity — this is what makes aliased inputs deterministic.
379fn insert_or_merge<'a>(
380    map: &mut HashMap<PathBuf, IndexedCoverage<'a>>,
381    key: PathBuf,
382    raw_path: &'a Path,
383    cov: &'a FileCoverage,
384) {
385    match map.entry(key) {
386        Entry::Occupied(mut slot) => {
387            let indexed = slot.get_mut();
388            indexed.spellings.push(raw_path);
389            indexed.cov.to_mut().merge_from(cov);
390        },
391        Entry::Vacant(slot) => {
392            slot.insert(IndexedCoverage {
393                spellings: vec![raw_path],
394                cov: Cow::Borrowed(cov),
395            });
396        },
397    }
398}
399
400/// True if `haystack` ends with `needle`, compared component by component.
401///
402/// This is stricter than a byte-level `ends_with`: `foo/bar.rs` must not
403/// match `oofoo/bar.rs`. Cross-platform separators are handled because
404/// `Path::components` normalizes them.
405fn path_has_suffix(
406    haystack: &Path,
407    needle: &Path,
408) -> bool {
409    let hay: Vec<_> = haystack.components().collect();
410    let nee: Vec<_> = needle.components().collect();
411    if nee.len() > hay.len() {
412        return false;
413    }
414    hay[hay.len() - nee.len()..] == nee[..]
415}
416
417#[cfg(test)]
418#[expect(
419    clippy::float_cmp,
420    reason = "coverage % is computed from integer line counts; exact equality is the right comparison"
421)]
422mod tests {
423    use super::*;
424    use std::collections::BTreeMap;
425    use std::path::PathBuf;
426
427    fn cov_with(lines: &[(u32, u64)]) -> FileCoverage {
428        FileCoverage {
429            lines: lines.iter().copied().collect::<BTreeMap<_, _>>(),
430        }
431    }
432
433    #[test]
434    fn suffix_match_works_for_relative_coverage_paths() {
435        // Simulates the realistic case: coverage file was generated with
436        // `cargo llvm-cov` in the workspace root, producing relative paths.
437        let mut cov_map = HashMap::new();
438        cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(10, 1), (11, 1)]));
439        let index = PathIndex::build(&cov_map);
440
441        let complexity_path = PathBuf::from("/home/alice/project/src/foo.rs");
442        let result = index.lookup(&complexity_path);
443        assert!(result.is_some(), "expected suffix match to succeed");
444    }
445
446    #[test]
447    fn suffix_match_rejects_partial_component_matches() {
448        // `oofoo.rs` should NOT match `foo.rs` — that's a byte-level
449        // ends_with bug we're explicitly avoiding.
450        let a = PathBuf::from("/project/src/oofoo.rs");
451        let b = PathBuf::from("foo.rs");
452        assert!(!path_has_suffix(&a, &b));
453    }
454
455    #[test]
456    fn equal_length_paths_match_when_identical() {
457        // Kills: replace > with == and > with >= in the nee.len() > hay.len() guard.
458        // If the guard fired for equal-length paths, identical paths would return false.
459        let a = PathBuf::from("/project/src/foo.rs");
460        let b = PathBuf::from("/project/src/foo.rs");
461        assert!(
462            path_has_suffix(&a, &b),
463            "identical paths must match as a suffix"
464        );
465    }
466
467    #[test]
468    fn longer_needle_does_not_match() {
469        // Needle longer than haystack must always return false.
470        let hay = PathBuf::from("src/foo.rs");
471        let needle = PathBuf::from("/abs/project/src/foo.rs");
472        assert!(!path_has_suffix(&hay, &needle));
473    }
474
475    #[test]
476    fn longest_matching_suffix_wins_over_shorter_ambiguous_key() {
477        // Spec 26: `src/lib.rs` and `vendor/dep/src/lib.rs` both suffix-match
478        // a query under vendor/dep/. The 4-component needle must win — under
479        // the old first-match-in-hash-order lookup this failed about half the
480        // time (kills max_by_key → min_by_key and dropping the preference).
481        let mut cov_map = HashMap::new();
482        cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 7)]));
483        cov_map.insert(PathBuf::from("vendor/dep/src/lib.rs"), cov_with(&[(1, 0)]));
484        let index = PathIndex::build(&cov_map);
485
486        let vendor = index
487            .lookup(Path::new("/repo/vendor/dep/src/lib.rs"))
488            .expect("vendor query matches");
489        assert_eq!(
490            vendor.cov.coverage_in_span(1, 1),
491            0.0,
492            "nested query must bind to the vendor key (line 1: 0 hits)"
493        );
494
495        let root = index
496            .lookup(Path::new("/repo/src/lib.rs"))
497            .expect("root query matches");
498        assert_eq!(
499            root.cov.coverage_in_span(1, 1),
500            100.0,
501            "the shorter key still serves its own queries"
502        );
503
504        // Through merge(): after both queries bind, neither ambiguous key
505        // is a stray (spec 26, "shorter key still serves its own queries").
506        let complexity = vec![
507            FunctionComplexity {
508                file: PathBuf::from("/repo/src/lib.rs"),
509                name: "rooted".into(),
510                start_line: 1,
511                end_line: 1,
512                cyclomatic: 1.0,
513            },
514            FunctionComplexity {
515                file: PathBuf::from("/repo/vendor/dep/src/lib.rs"),
516                name: "vendored".into(),
517                start_line: 1,
518                end_line: 1,
519                cyclomatic: 1.0,
520            },
521        ];
522        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
523        let diag = result.diagnostics.expect("diagnostics present");
524        assert_eq!(diag.matched_files, 2);
525        assert_eq!(
526            diag.lcov_only.count, 0,
527            "both ambiguous keys were consumed by their own queries"
528        );
529    }
530
531    #[test]
532    fn component_equal_spellings_merge_into_one_entry() {
533        // Spec 26: `src/lib.rs` and `./src/lib.rs` are spellings of the same
534        // logical file — merged at build time (union of lines, summed hits),
535        // and both raw spellings count as consumed.
536        let mut cov_map = HashMap::new();
537        cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 2)]));
538        cov_map.insert(PathBuf::from("./src/lib.rs"), cov_with(&[(1, 3), (2, 1)]));
539        let index = PathIndex::build(&cov_map);
540        assert_eq!(
541            index.by_relative.len(),
542            1,
543            "spelling variants collapse to one suffix-tier entry"
544        );
545
546        let hit = index
547            .lookup(Path::new("/repo/src/lib.rs"))
548            .expect("query matches the merged entry");
549        assert_eq!(hit.cov.lines.get(&1), Some(&5), "hits sum: 2 + 3");
550        assert_eq!(hit.cov.lines.get(&2), Some(&1));
551        assert_eq!(hit.cov.coverage_in_span(1, 2), 100.0);
552
553        // Through merge(): neither spelling is a stray.
554        let complexity = vec![FunctionComplexity {
555            file: PathBuf::from("/repo/src/lib.rs"),
556            name: "f".into(),
557            start_line: 1,
558            end_line: 2,
559            cyclomatic: 1.0,
560        }];
561        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
562        let diag = result.diagnostics.expect("diagnostics present");
563        assert_eq!(
564            diag.lcov_only.count, 0,
565            "both spellings of a consumed entry are consumed"
566        );
567    }
568
569    #[test]
570    fn degenerate_lcov_keys_never_wildcard_match() {
571        // `SF:.` (and an empty SF) normalize to zero components; an empty
572        // needle would trivially suffix-match EVERY query, silently binding
573        // unmapped files to garbage data. Such keys must stay out of the
574        // index and surface as lcov_only strays instead (kills dropping the
575        // empty-key guard in PathIndex::build).
576        let mut cov_map = HashMap::new();
577        cov_map.insert(PathBuf::from("."), cov_with(&[(1, 1)]));
578        cov_map.insert(PathBuf::from(""), cov_with(&[(1, 1)]));
579        cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
580        let index = PathIndex::build(&cov_map);
581
582        assert!(
583            index.lookup(Path::new("/repo/src/bar.rs")).is_none(),
584            "a file with no real LCOV record must stay unmatched"
585        );
586        assert!(
587            index.lookup(Path::new("/repo/src/foo.rs")).is_some(),
588            "legitimate keys still match"
589        );
590
591        let complexity = vec![FunctionComplexity {
592            file: PathBuf::from("/repo/src/bar.rs"),
593            name: "unmapped".into(),
594            start_line: 1,
595            end_line: 1,
596            cyclomatic: 1.0,
597        }];
598        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
599        let diag = result.diagnostics.expect("diagnostics present");
600        assert_eq!(
601            diag.source_only.count, 1,
602            "the unmapped file is reported, not silently bound"
603        );
604        assert_eq!(
605            diag.lcov_only.count, 3,
606            "degenerate keys and the unconsumed real key are strays"
607        );
608    }
609
610    #[test]
611    fn distinct_relative_files_never_merge() {
612        // Spec 26: component-inequal keys stay separate and never compete
613        // for the same query (one would have to be a suffix of the other).
614        let mut cov_map = HashMap::new();
615        cov_map.insert(PathBuf::from("a/util.rs"), cov_with(&[(1, 1)]));
616        cov_map.insert(PathBuf::from("b/util.rs"), cov_with(&[(1, 0)]));
617        let index = PathIndex::build(&cov_map);
618        assert_eq!(index.by_relative.len(), 2);
619
620        let a = index.lookup(Path::new("/repo/a/util.rs")).expect("a match");
621        assert_eq!(a.cov.coverage_in_span(1, 1), 100.0);
622        let b = index.lookup(Path::new("/repo/b/util.rs")).expect("b match");
623        assert_eq!(b.cov.coverage_in_span(1, 1), 0.0);
624    }
625
626    #[cfg(unix)]
627    #[test]
628    fn absolute_aliases_merge_line_data_instead_of_last_write_wins() {
629        // Spec 26: two SF records spelling the same real file (one through a
630        // symlink) with *different* hit data. Before, one leg's data was
631        // silently dropped and which one survived was hash-order-dependent;
632        // now the legs merge, so a function spanning both lines scores 100%
633        // instead of the 50% either single leg would give.
634        let dir = tempfile::tempdir().expect("tempdir");
635        let real = dir.path().join("a.rs");
636        std::fs::write(&real, "pub fn f() {}\npub fn g() {}\n").expect("write");
637        let link = dir.path().join("link.rs");
638        std::os::unix::fs::symlink(&real, &link).expect("symlink");
639
640        let mut cov_map = HashMap::new();
641        cov_map.insert(real.clone(), cov_with(&[(1, 1), (2, 0)]));
642        cov_map.insert(link, cov_with(&[(1, 0), (2, 1)]));
643
644        let complexity = vec![FunctionComplexity {
645            file: real,
646            name: "f".into(),
647            start_line: 1,
648            end_line: 2,
649            cyclomatic: 1.0,
650        }];
651
652        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
653        let entry = &result.entries[0];
654        assert_eq!(
655            entry.coverage,
656            Some(100.0),
657            "merged legs cover both lines; either leg alone would give 50%"
658        );
659        let diag = result.diagnostics.expect("diagnostics present");
660        assert_eq!(diag.lcov_only.count, 0);
661    }
662
663    #[test]
664    fn merge_sorts_by_descending_crap() {
665        let complexity = vec![
666            FunctionComplexity {
667                file: PathBuf::from("a.rs"),
668                name: "easy".into(),
669                start_line: 1,
670                end_line: 3,
671                cyclomatic: 1.0,
672            },
673            FunctionComplexity {
674                file: PathBuf::from("a.rs"),
675                name: "hard".into(),
676                start_line: 10,
677                end_line: 30,
678                cyclomatic: 10.0,
679            },
680        ];
681        let result = merge(
682            complexity,
683            HashMap::new(),
684            MissingCoveragePolicy::Pessimistic,
685        );
686        assert_eq!(result.entries[0].function, "hard");
687        assert_eq!(result.entries[1].function, "easy");
688    }
689
690    #[test]
691    fn skip_policy_drops_rows_without_coverage() {
692        let complexity = vec![FunctionComplexity {
693            file: PathBuf::from("nowhere.rs"),
694            name: "foo".into(),
695            start_line: 1,
696            end_line: 5,
697            cyclomatic: 3.0,
698        }];
699        let result = merge(complexity, HashMap::new(), MissingCoveragePolicy::Skip);
700        assert!(result.entries.is_empty());
701    }
702
703    #[test]
704    fn relative_coverage_paths_are_not_resolved_against_cwd() {
705        // REGRESSION TEST. A relative path in the coverage file must never
706        // be canonicalized against the process's CWD, because that causes a
707        // silent-binding bug: `src/lib.rs` in LCOV would resolve to
708        // `<cwd>/src/lib.rs` (which likely exists — it's the tool's own
709        // source), and then the lookup for a DIFFERENT file ending in
710        // `src/lib.rs` would miss, returning `None` for every function.
711        //
712        // We construct exactly this scenario: a relative coverage path that
713        // happens to match something real under CWD, and a complexity path
714        // that is the "intended" target elsewhere.
715        let mut cov_map = HashMap::new();
716        cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(10, 1)]));
717        let index = PathIndex::build(&cov_map);
718
719        // The relative path must live in `by_relative`, NOT `by_absolute`,
720        // even if a file by that relative name happens to exist under CWD.
721        assert!(
722            index.by_absolute.is_empty(),
723            "relative coverage paths must not populate by_absolute"
724        );
725        assert_eq!(index.by_relative.len(), 1);
726
727        // Lookup for an unrelated absolute path ending in src/lib.rs must
728        // succeed via suffix match.
729        let found = index.lookup(Path::new("/somewhere/else/src/lib.rs"));
730        assert!(found.is_some());
731    }
732
733    #[test]
734    fn unmapped_files_reported_when_lcov_provided() {
735        let mut cov_map = HashMap::new();
736        cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
737
738        let complexity = vec![
739            FunctionComplexity {
740                file: PathBuf::from("/project/src/foo.rs"),
741                name: "matched".into(),
742                start_line: 1,
743                end_line: 3,
744                cyclomatic: 1.0,
745            },
746            FunctionComplexity {
747                file: PathBuf::from("/project/src/bar.rs"),
748                name: "unmatched".into(),
749                start_line: 1,
750                end_line: 3,
751                cyclomatic: 1.0,
752            },
753        ];
754
755        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
756        let diag = result.diagnostics.expect("lcov provided → diagnostics");
757        assert_eq!(diag.analyzed_files, 2);
758        assert_eq!(diag.lcov_files, 1);
759        assert_eq!(diag.matched_files, 1);
760        assert_eq!(diag.source_only.count, 1);
761        assert_eq!(
762            diag.source_only.examples,
763            vec![PathBuf::from("/project/src/bar.rs")]
764        );
765        assert_eq!(diag.lcov_only.count, 0, "the only LCOV entry was consumed");
766    }
767
768    #[test]
769    fn lcov_only_files_are_reported() {
770        // The mirror case: LCOV mentions files the analysis never saw.
771        let mut cov_map = HashMap::new();
772        cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
773        cov_map.insert(PathBuf::from("src/phantom_a.rs"), cov_with(&[(1, 1)]));
774        cov_map.insert(PathBuf::from("src/phantom_b.rs"), cov_with(&[(1, 1)]));
775
776        let complexity = vec![FunctionComplexity {
777            file: PathBuf::from("/project/src/foo.rs"),
778            name: "matched".into(),
779            start_line: 1,
780            end_line: 3,
781            cyclomatic: 1.0,
782        }];
783
784        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
785        let diag = result.diagnostics.expect("diagnostics present");
786        assert_eq!(diag.lcov_files, 3);
787        assert_eq!(diag.matched_files, 1);
788        assert_eq!(diag.lcov_only.count, 2);
789        assert_eq!(
790            diag.lcov_only.examples,
791            vec![
792                PathBuf::from("src/phantom_a.rs"),
793                PathBuf::from("src/phantom_b.rs")
794            ],
795            "lcov_only examples must be sorted"
796        );
797    }
798
799    #[cfg(unix)]
800    #[test]
801    fn symlink_alias_of_a_consumed_key_is_not_lcov_only() {
802        // Two absolute SF records spelling the same real file (one through a
803        // symlink) merge into one fast-path entry at build time (spec 26);
804        // both raw spellings are recorded as consumed when a lookup binds.
805        // Neither may be reported as a stray (no spurious scope warning on
806        // a perfectly matched scope).
807        let dir = tempfile::tempdir().expect("tempdir");
808        let real = dir.path().join("a.rs");
809        std::fs::write(&real, "pub fn f() {}\n").expect("write");
810        let link = dir.path().join("link.rs");
811        std::os::unix::fs::symlink(&real, &link).expect("symlink");
812
813        let mut cov_map = HashMap::new();
814        cov_map.insert(real.clone(), cov_with(&[(1, 1)]));
815        cov_map.insert(link, cov_with(&[(1, 1)]));
816
817        let complexity = vec![FunctionComplexity {
818            file: real,
819            name: "f".into(),
820            start_line: 1,
821            end_line: 1,
822            cyclomatic: 1.0,
823        }];
824
825        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
826        let diag = result.diagnostics.expect("diagnostics present");
827        assert_eq!(diag.matched_files, 1);
828        assert_eq!(
829            diag.lcov_only.count, 0,
830            "an alias of a consumed key is not a stray"
831        );
832        assert_eq!(diag.source_only.count, 0);
833    }
834
835    #[test]
836    fn relative_key_is_never_treated_as_an_alias() {
837        // src/merge.rs exists relative to the crate root (the unit-test CWD).
838        // The absolute spelling is consumed via the fast path; the relative
839        // spelling must still be reported as lcov_only — resolving it against
840        // the CWD to discover the aliasing would violate the invariant that
841        // relative LCOV paths are never canonicalized (kills dropping the
842        // is_absolute guard in fast_path_key, which would merge the two
843        // spellings into one fast-path entry).
844        let abs = PathBuf::from("src/merge.rs")
845            .canonicalize()
846            .expect("crate-root CWD");
847
848        let mut cov_map = HashMap::new();
849        cov_map.insert(abs.clone(), cov_with(&[(1, 1)]));
850        cov_map.insert(PathBuf::from("src/merge.rs"), cov_with(&[(1, 1)]));
851
852        let complexity = vec![FunctionComplexity {
853            file: abs,
854            name: "f".into(),
855            start_line: 1,
856            end_line: 1,
857            cyclomatic: 1.0,
858        }];
859
860        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
861        let diag = result.diagnostics.expect("diagnostics present");
862        assert_eq!(diag.matched_files, 1);
863        assert_eq!(
864            diag.lcov_only.count, 1,
865            "the relative spelling stays a stray — CWD resolution is forbidden"
866        );
867        assert_eq!(diag.lcov_only.examples, vec![PathBuf::from("src/merge.rs")]);
868    }
869
870    #[test]
871    fn shared_lcov_entry_consumed_by_multiple_files_is_not_lcov_only() {
872        // Two analyzed files suffix-matching the same relative LCOV key
873        // consume it once — it must not surface as lcov_only.
874        let mut cov_map = HashMap::new();
875        cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 1)]));
876
877        let complexity = vec![
878            FunctionComplexity {
879                file: PathBuf::from("/a/src/lib.rs"),
880                name: "one".into(),
881                start_line: 1,
882                end_line: 3,
883                cyclomatic: 1.0,
884            },
885            FunctionComplexity {
886                file: PathBuf::from("/b/src/lib.rs"),
887                name: "two".into(),
888                start_line: 1,
889                end_line: 3,
890                cyclomatic: 1.0,
891            },
892        ];
893
894        let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
895        let diag = result.diagnostics.expect("diagnostics present");
896        assert_eq!(diag.matched_files, 2);
897        assert_eq!(diag.lcov_only.count, 0);
898    }
899
900    #[test]
901    fn stray_examples_are_capped_but_count_is_exact() {
902        let files: Vec<PathBuf> = (0..SCOPE_EXAMPLE_CAP + 3)
903            .map(|i| PathBuf::from(format!("src/f{i:02}.rs")))
904            .collect();
905        let strays = StrayFiles::new(files);
906        assert_eq!(strays.count, SCOPE_EXAMPLE_CAP + 3);
907        assert_eq!(strays.examples.len(), SCOPE_EXAMPLE_CAP);
908        assert_eq!(
909            strays.examples[0],
910            PathBuf::from("src/f00.rs"),
911            "examples are the sorted head, not an arbitrary subset"
912        );
913    }
914
915    #[test]
916    fn stray_examples_not_truncated_at_or_below_cap() {
917        let files: Vec<PathBuf> = (0..SCOPE_EXAMPLE_CAP)
918            .map(|i| PathBuf::from(format!("src/f{i:02}.rs")))
919            .collect();
920        let strays = StrayFiles::new(files);
921        assert_eq!(strays.count, SCOPE_EXAMPLE_CAP);
922        assert_eq!(strays.examples.len(), SCOPE_EXAMPLE_CAP);
923    }
924
925    // --- SortOrder / sort_entries (spec 17) --------------------------------
926
927    fn crap_entry(
928        file: &str,
929        function: &str,
930        line: usize,
931        crap: f64,
932    ) -> CrapEntry {
933        CrapEntry {
934            file: PathBuf::from(file),
935            function: function.into(),
936            line,
937            cyclomatic: 1.0,
938            coverage: Some(100.0),
939            crap,
940            crate_name: None,
941        }
942    }
943
944    fn order(entries: &[CrapEntry]) -> Vec<(&str, usize)> {
945        entries
946            .iter()
947            .map(|e| (e.function.as_str(), e.line))
948            .collect()
949    }
950
951    #[test]
952    fn sort_order_default_is_crap() {
953        assert_eq!(SortOrder::default(), SortOrder::Crap);
954    }
955
956    #[test]
957    fn sort_entries_crap_orders_by_score_descending() {
958        // Kills: swapping the comparator operands (ascending) in the Crap arm.
959        let mut entries = vec![
960            crap_entry("src/a.rs", "low", 1, 1.0),
961            crap_entry("src/a.rs", "high", 2, 90.0),
962            crap_entry("src/a.rs", "mid", 3, 30.0),
963        ];
964        sort_entries(&mut entries, SortOrder::Crap);
965        assert_eq!(order(&entries), [("high", 2), ("mid", 3), ("low", 1)]);
966    }
967
968    #[test]
969    fn sort_entries_file_orders_by_file_then_function_then_line() {
970        // zeta has the highest CRAP but must land last under file order.
971        let mut entries = vec![
972            crap_entry("src/b.rs", "zeta", 1, 99.0),
973            crap_entry("src/a.rs", "beta", 1, 5.0),
974            crap_entry("src/a.rs", "alpha", 1, 5.0),
975        ];
976        sort_entries(&mut entries, SortOrder::File);
977        assert_eq!(
978            order(&entries),
979            [("alpha", 1), ("beta", 1), ("zeta", 1)],
980            "file order is (file, function, line) ascending, ignoring CRAP"
981        );
982    }
983
984    #[test]
985    fn sort_entries_file_tie_breaks_on_line() {
986        // Two `new` in the same file at different lines: line 10 before line 50.
987        let mut entries = vec![
988            crap_entry("src/a.rs", "new", 50, 5.0),
989            crap_entry("src/a.rs", "new", 10, 5.0),
990        ];
991        sort_entries(&mut entries, SortOrder::File);
992        assert_eq!(order(&entries), [("new", 10), ("new", 50)]);
993    }
994
995    #[test]
996    fn sort_entries_file_normalizes_separators() {
997        // Backslash and forward-slash paths sort by the same normalized key,
998        // so a Windows-written baseline orders identically to a Linux one.
999        let mut entries = vec![
1000            crap_entry("src\\b.rs", "b", 1, 5.0),
1001            crap_entry("src/a.rs", "a", 1, 5.0),
1002        ];
1003        sort_entries(&mut entries, SortOrder::File);
1004        assert_eq!(order(&entries), [("a", 1), ("b", 1)]);
1005    }
1006
1007    #[test]
1008    fn no_diagnostics_when_no_lcov_provided() {
1009        let complexity = vec![FunctionComplexity {
1010            file: PathBuf::from("src/foo.rs"),
1011            name: "foo".into(),
1012            start_line: 1,
1013            end_line: 3,
1014            cyclomatic: 1.0,
1015        }];
1016        let result = merge(
1017            complexity,
1018            HashMap::new(),
1019            MissingCoveragePolicy::Pessimistic,
1020        );
1021        assert!(
1022            result.diagnostics.is_none(),
1023            "no lcov → no scope diagnostics, no warnings"
1024        );
1025    }
1026}