use crate::complexity::FunctionComplexity;
use crate::coverage::FileCoverage;
use crate::score::crap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MissingCoveragePolicy {
Pessimistic,
Optimistic,
Skip,
}
pub fn merge(
complexity: Vec<FunctionComplexity>,
coverage: HashMap<PathBuf, FileCoverage>,
policy: MissingCoveragePolicy,
) -> Vec<CrapEntry> {
let index = PathIndex::build(&coverage);
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));
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,
})
})
.collect();
entries.sort_by(|a, b| {
b.crap
.partial_cmp(&a.crap)
.unwrap_or(std::cmp::Ordering::Equal)
});
entries
}
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() {
if 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 entries = merge(
complexity,
HashMap::new(),
MissingCoveragePolicy::Pessimistic,
);
assert_eq!(entries[0].function, "hard");
assert_eq!(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 entries = merge(complexity, HashMap::new(), MissingCoveragePolicy::Skip);
assert!(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());
}
}