use crate::complexity::FunctionComplexity;
use crate::coverage::FileCoverage;
use crate::score::crap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct CrapEntry {
pub file: PathBuf,
pub function: String,
pub line: usize,
pub cyclomatic: f64,
pub coverage: Option<f64>,
pub crap: f64,
#[serde(rename = "crate", default, skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SortOrder {
#[default]
Crap,
File,
}
fn file_order_key(e: &CrapEntry) -> (String, &str, usize) {
(
e.file.to_string_lossy().replace('\\', "/"),
e.function.as_str(),
e.line,
)
}
pub fn sort_entries(
entries: &mut [CrapEntry],
order: SortOrder,
) {
match order {
SortOrder::Crap => entries.sort_by(|a, b| {
b.crap
.partial_cmp(&a.crap)
.unwrap_or(std::cmp::Ordering::Equal)
}),
SortOrder::File => entries.sort_by(|a, b| file_order_key(a).cmp(&file_order_key(b))),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MissingCoveragePolicy {
Pessimistic,
Optimistic,
Skip,
}
pub const SCOPE_EXAMPLE_CAP: usize = 10;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrayFiles {
pub count: usize,
pub examples: Vec<PathBuf>,
}
impl StrayFiles {
fn new(mut files: Vec<PathBuf>) -> Self {
files.sort();
let count = files.len();
files.truncate(SCOPE_EXAMPLE_CAP);
Self {
count,
examples: files,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeDiagnostics {
pub analyzed_files: usize,
pub lcov_files: usize,
pub matched_files: usize,
pub source_only: StrayFiles,
pub lcov_only: StrayFiles,
}
pub struct MergeResult {
pub entries: Vec<CrapEntry>,
pub diagnostics: Option<ScopeDiagnostics>,
}
#[expect(
clippy::needless_pass_by_value,
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"
)]
#[must_use]
pub fn merge(
complexity: Vec<FunctionComplexity>,
coverage: HashMap<PathBuf, FileCoverage>,
policy: MissingCoveragePolicy,
) -> MergeResult {
let index = PathIndex::build(&coverage);
let has_coverage = !coverage.is_empty();
let mut mapped_files: HashSet<PathBuf> = HashSet::new();
let mut seen_files: HashSet<PathBuf> = HashSet::new();
let mut used_lcov_keys: HashSet<PathBuf> = HashSet::new();
let mut entries: Vec<CrapEntry> = complexity
.into_iter()
.filter_map(|fc| {
let hit = index.lookup(&fc.file);
let cov =
hit.map(|(_, cov_file)| cov_file.coverage_in_span(fc.start_line, fc.end_line));
if has_coverage {
if let Some((raw_key, _)) = hit {
mapped_files.insert(fc.file.clone());
used_lcov_keys.insert(raw_key.to_path_buf());
}
seen_files.insert(fc.file.clone());
}
let cov_for_scoring = match (cov, policy) {
(Some(c), _) => c,
(None, MissingCoveragePolicy::Pessimistic) => 0.0,
(None, MissingCoveragePolicy::Optimistic) => 100.0,
(None, MissingCoveragePolicy::Skip) => return None,
};
let crap_score = crap(fc.cyclomatic, cov_for_scoring);
Some(CrapEntry {
file: fc.file,
function: fc.name,
line: fc.start_line,
cyclomatic: fc.cyclomatic,
coverage: cov,
crap: crap_score,
crate_name: None,
})
})
.collect();
entries.sort_by(|a, b| {
b.crap
.partial_cmp(&a.crap)
.unwrap_or(std::cmp::Ordering::Equal)
});
let diagnostics = has_coverage.then(|| {
let source_only: Vec<PathBuf> = seen_files
.iter()
.filter(|f| !mapped_files.contains(*f))
.cloned()
.collect();
let used_canonical: HashSet<PathBuf> = used_lcov_keys
.iter()
.filter(|k| k.is_absolute())
.filter_map(|k| k.canonicalize().ok())
.collect();
let lcov_only: Vec<PathBuf> = coverage
.keys()
.filter(|k| !used_lcov_keys.contains(*k) && !aliases_used(k, &used_canonical))
.cloned()
.collect();
ScopeDiagnostics {
analyzed_files: seen_files.len(),
lcov_files: coverage.len(),
matched_files: mapped_files.len(),
source_only: StrayFiles::new(source_only),
lcov_only: StrayFiles::new(lcov_only),
}
});
MergeResult {
entries,
diagnostics,
}
}
fn aliases_used(
key: &Path,
used_canonical: &HashSet<PathBuf>,
) -> bool {
if !key.is_absolute() {
return false;
}
key.canonicalize()
.is_ok_and(|c| used_canonical.contains(&c))
}
struct PathIndex<'a> {
by_absolute: HashMap<PathBuf, (&'a Path, &'a FileCoverage)>,
by_relative: Vec<(PathBuf, &'a FileCoverage)>,
}
impl<'a> PathIndex<'a> {
fn build(coverage: &'a HashMap<PathBuf, FileCoverage>) -> Self {
let mut by_absolute = HashMap::new();
let mut by_relative = Vec::new();
for (raw_path, cov) in coverage {
if raw_path.is_absolute() {
match raw_path.canonicalize() {
Ok(abs) => {
by_absolute.insert(abs, (raw_path.as_path(), cov));
},
Err(_) => {
by_relative.push((raw_path.clone(), cov));
},
}
} else {
by_relative.push((raw_path.clone(), cov));
}
}
Self {
by_absolute,
by_relative,
}
}
fn lookup(
&self,
query: &Path,
) -> Option<(&Path, &'a FileCoverage)> {
if let Ok(abs) = query.canonicalize()
&& let Some(&(raw, cov)) = self.by_absolute.get(&abs)
{
return Some((raw, cov));
}
for (rel, cov) in &self.by_relative {
if path_has_suffix(query, rel) {
return Some((rel.as_path(), cov));
}
}
None
}
}
fn path_has_suffix(
haystack: &Path,
needle: &Path,
) -> bool {
let hay: Vec<_> = haystack.components().collect();
let nee: Vec<_> = needle.components().collect();
if nee.len() > hay.len() {
return false;
}
hay[hay.len() - nee.len()..] == nee[..]
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn cov_with(lines: &[(u32, u64)]) -> FileCoverage {
FileCoverage {
lines: lines.iter().copied().collect::<BTreeMap<_, _>>(),
}
}
#[test]
fn suffix_match_works_for_relative_coverage_paths() {
let mut cov_map = HashMap::new();
cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(10, 1), (11, 1)]));
let index = PathIndex::build(&cov_map);
let complexity_path = PathBuf::from("/home/alice/project/src/foo.rs");
let result = index.lookup(&complexity_path);
assert!(result.is_some(), "expected suffix match to succeed");
}
#[test]
fn suffix_match_rejects_partial_component_matches() {
let a = PathBuf::from("/project/src/oofoo.rs");
let b = PathBuf::from("foo.rs");
assert!(!path_has_suffix(&a, &b));
}
#[test]
fn equal_length_paths_match_when_identical() {
let a = PathBuf::from("/project/src/foo.rs");
let b = PathBuf::from("/project/src/foo.rs");
assert!(
path_has_suffix(&a, &b),
"identical paths must match as a suffix"
);
}
#[test]
fn longer_needle_does_not_match() {
let hay = PathBuf::from("src/foo.rs");
let needle = PathBuf::from("/abs/project/src/foo.rs");
assert!(!path_has_suffix(&hay, &needle));
}
#[test]
fn merge_sorts_by_descending_crap() {
let complexity = vec![
FunctionComplexity {
file: PathBuf::from("a.rs"),
name: "easy".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
},
FunctionComplexity {
file: PathBuf::from("a.rs"),
name: "hard".into(),
start_line: 10,
end_line: 30,
cyclomatic: 10.0,
},
];
let result = merge(
complexity,
HashMap::new(),
MissingCoveragePolicy::Pessimistic,
);
assert_eq!(result.entries[0].function, "hard");
assert_eq!(result.entries[1].function, "easy");
}
#[test]
fn skip_policy_drops_rows_without_coverage() {
let complexity = vec![FunctionComplexity {
file: PathBuf::from("nowhere.rs"),
name: "foo".into(),
start_line: 1,
end_line: 5,
cyclomatic: 3.0,
}];
let result = merge(complexity, HashMap::new(), MissingCoveragePolicy::Skip);
assert!(result.entries.is_empty());
}
#[test]
fn relative_coverage_paths_are_not_resolved_against_cwd() {
let mut cov_map = HashMap::new();
cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(10, 1)]));
let index = PathIndex::build(&cov_map);
assert!(
index.by_absolute.is_empty(),
"relative coverage paths must not populate by_absolute"
);
assert_eq!(index.by_relative.len(), 1);
let found = index.lookup(Path::new("/somewhere/else/src/lib.rs"));
assert!(found.is_some());
}
#[test]
fn unmapped_files_reported_when_lcov_provided() {
let mut cov_map = HashMap::new();
cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
let complexity = vec![
FunctionComplexity {
file: PathBuf::from("/project/src/foo.rs"),
name: "matched".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
},
FunctionComplexity {
file: PathBuf::from("/project/src/bar.rs"),
name: "unmatched".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
},
];
let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
let diag = result.diagnostics.expect("lcov provided → diagnostics");
assert_eq!(diag.analyzed_files, 2);
assert_eq!(diag.lcov_files, 1);
assert_eq!(diag.matched_files, 1);
assert_eq!(diag.source_only.count, 1);
assert_eq!(
diag.source_only.examples,
vec![PathBuf::from("/project/src/bar.rs")]
);
assert_eq!(diag.lcov_only.count, 0, "the only LCOV entry was consumed");
}
#[test]
fn lcov_only_files_are_reported() {
let mut cov_map = HashMap::new();
cov_map.insert(PathBuf::from("src/foo.rs"), cov_with(&[(1, 1)]));
cov_map.insert(PathBuf::from("src/phantom_a.rs"), cov_with(&[(1, 1)]));
cov_map.insert(PathBuf::from("src/phantom_b.rs"), cov_with(&[(1, 1)]));
let complexity = vec![FunctionComplexity {
file: PathBuf::from("/project/src/foo.rs"),
name: "matched".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
}];
let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
let diag = result.diagnostics.expect("diagnostics present");
assert_eq!(diag.lcov_files, 3);
assert_eq!(diag.matched_files, 1);
assert_eq!(diag.lcov_only.count, 2);
assert_eq!(
diag.lcov_only.examples,
vec![
PathBuf::from("src/phantom_a.rs"),
PathBuf::from("src/phantom_b.rs")
],
"lcov_only examples must be sorted"
);
}
#[cfg(unix)]
#[test]
fn symlink_alias_of_a_consumed_key_is_not_lcov_only() {
let dir = tempfile::tempdir().expect("tempdir");
let real = dir.path().join("a.rs");
std::fs::write(&real, "pub fn f() {}\n").expect("write");
let link = dir.path().join("link.rs");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let mut cov_map = HashMap::new();
cov_map.insert(real.clone(), cov_with(&[(1, 1)]));
cov_map.insert(link, cov_with(&[(1, 1)]));
let complexity = vec![FunctionComplexity {
file: real,
name: "f".into(),
start_line: 1,
end_line: 1,
cyclomatic: 1.0,
}];
let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
let diag = result.diagnostics.expect("diagnostics present");
assert_eq!(diag.matched_files, 1);
assert_eq!(
diag.lcov_only.count, 0,
"an alias of a consumed key is not a stray"
);
assert_eq!(diag.source_only.count, 0);
}
#[test]
fn relative_key_is_never_treated_as_an_alias() {
let abs = PathBuf::from("src/merge.rs")
.canonicalize()
.expect("crate-root CWD");
let mut cov_map = HashMap::new();
cov_map.insert(abs.clone(), cov_with(&[(1, 1)]));
cov_map.insert(PathBuf::from("src/merge.rs"), cov_with(&[(1, 1)]));
let complexity = vec![FunctionComplexity {
file: abs,
name: "f".into(),
start_line: 1,
end_line: 1,
cyclomatic: 1.0,
}];
let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
let diag = result.diagnostics.expect("diagnostics present");
assert_eq!(diag.matched_files, 1);
assert_eq!(
diag.lcov_only.count, 1,
"the relative spelling stays a stray — CWD resolution is forbidden"
);
assert_eq!(diag.lcov_only.examples, vec![PathBuf::from("src/merge.rs")]);
}
#[test]
fn shared_lcov_entry_consumed_by_multiple_files_is_not_lcov_only() {
let mut cov_map = HashMap::new();
cov_map.insert(PathBuf::from("src/lib.rs"), cov_with(&[(1, 1)]));
let complexity = vec![
FunctionComplexity {
file: PathBuf::from("/a/src/lib.rs"),
name: "one".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
},
FunctionComplexity {
file: PathBuf::from("/b/src/lib.rs"),
name: "two".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
},
];
let result = merge(complexity, cov_map, MissingCoveragePolicy::Pessimistic);
let diag = result.diagnostics.expect("diagnostics present");
assert_eq!(diag.matched_files, 2);
assert_eq!(diag.lcov_only.count, 0);
}
#[test]
fn stray_examples_are_capped_but_count_is_exact() {
let files: Vec<PathBuf> = (0..SCOPE_EXAMPLE_CAP + 3)
.map(|i| PathBuf::from(format!("src/f{i:02}.rs")))
.collect();
let strays = StrayFiles::new(files);
assert_eq!(strays.count, SCOPE_EXAMPLE_CAP + 3);
assert_eq!(strays.examples.len(), SCOPE_EXAMPLE_CAP);
assert_eq!(
strays.examples[0],
PathBuf::from("src/f00.rs"),
"examples are the sorted head, not an arbitrary subset"
);
}
#[test]
fn stray_examples_not_truncated_at_or_below_cap() {
let files: Vec<PathBuf> = (0..SCOPE_EXAMPLE_CAP)
.map(|i| PathBuf::from(format!("src/f{i:02}.rs")))
.collect();
let strays = StrayFiles::new(files);
assert_eq!(strays.count, SCOPE_EXAMPLE_CAP);
assert_eq!(strays.examples.len(), SCOPE_EXAMPLE_CAP);
}
fn crap_entry(
file: &str,
function: &str,
line: usize,
crap: f64,
) -> CrapEntry {
CrapEntry {
file: PathBuf::from(file),
function: function.into(),
line,
cyclomatic: 1.0,
coverage: Some(100.0),
crap,
crate_name: None,
}
}
fn order(entries: &[CrapEntry]) -> Vec<(&str, usize)> {
entries
.iter()
.map(|e| (e.function.as_str(), e.line))
.collect()
}
#[test]
fn sort_order_default_is_crap() {
assert_eq!(SortOrder::default(), SortOrder::Crap);
}
#[test]
fn sort_entries_crap_orders_by_score_descending() {
let mut entries = vec![
crap_entry("src/a.rs", "low", 1, 1.0),
crap_entry("src/a.rs", "high", 2, 90.0),
crap_entry("src/a.rs", "mid", 3, 30.0),
];
sort_entries(&mut entries, SortOrder::Crap);
assert_eq!(order(&entries), [("high", 2), ("mid", 3), ("low", 1)]);
}
#[test]
fn sort_entries_file_orders_by_file_then_function_then_line() {
let mut entries = vec![
crap_entry("src/b.rs", "zeta", 1, 99.0),
crap_entry("src/a.rs", "beta", 1, 5.0),
crap_entry("src/a.rs", "alpha", 1, 5.0),
];
sort_entries(&mut entries, SortOrder::File);
assert_eq!(
order(&entries),
[("alpha", 1), ("beta", 1), ("zeta", 1)],
"file order is (file, function, line) ascending, ignoring CRAP"
);
}
#[test]
fn sort_entries_file_tie_breaks_on_line() {
let mut entries = vec![
crap_entry("src/a.rs", "new", 50, 5.0),
crap_entry("src/a.rs", "new", 10, 5.0),
];
sort_entries(&mut entries, SortOrder::File);
assert_eq!(order(&entries), [("new", 10), ("new", 50)]);
}
#[test]
fn sort_entries_file_normalizes_separators() {
let mut entries = vec![
crap_entry("src\\b.rs", "b", 1, 5.0),
crap_entry("src/a.rs", "a", 1, 5.0),
];
sort_entries(&mut entries, SortOrder::File);
assert_eq!(order(&entries), [("a", 1), ("b", 1)]);
}
#[test]
fn no_diagnostics_when_no_lcov_provided() {
let complexity = vec![FunctionComplexity {
file: PathBuf::from("src/foo.rs"),
name: "foo".into(),
start_line: 1,
end_line: 3,
cyclomatic: 1.0,
}];
let result = merge(
complexity,
HashMap::new(),
MissingCoveragePolicy::Pessimistic,
);
assert!(
result.diagnostics.is_none(),
"no lcov → no scope diagnostics, no warnings"
);
}
}