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)]
#[serde(rename_all = "lowercase")]
pub enum MissingCoveragePolicy {
Pessimistic,
Optimistic,
Skip,
}
pub struct MergeResult {
pub entries: Vec<CrapEntry>,
pub unmapped_files: Vec<PathBuf>,
}
#[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 entries: Vec<CrapEntry> = complexity
.into_iter()
.filter_map(|fc| {
let cov = index
.lookup(&fc.file)
.map(|cov_file| cov_file.coverage_in_span(fc.start_line, fc.end_line));
if has_coverage {
if cov.is_some() {
mapped_files.insert(fc.file.clone());
}
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 mut unmapped_files: Vec<PathBuf> = seen_files
.into_iter()
.filter(|f| !mapped_files.contains(f))
.collect();
unmapped_files.sort();
MergeResult {
entries,
unmapped_files,
}
}
struct PathIndex<'a> {
by_absolute: HashMap<PathBuf, &'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, 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<&'a FileCoverage> {
if let Ok(abs) = query.canonicalize()
&& let Some(cov) = self.by_absolute.get(&abs)
{
return Some(*cov);
}
for (rel, cov) in &self.by_relative {
if path_has_suffix(query, rel) {
return Some(*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);
assert_eq!(
result.unmapped_files,
vec![PathBuf::from("/project/src/bar.rs")]
);
}
#[test]
fn no_unmapped_files_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.unmapped_files.is_empty(),
"no lcov → no unmapped warnings"
);
}
}