#![cfg_attr(coverage_nightly, coverage(off))]
use std::collections::HashMap;
use std::path::Path;
pub(crate) type LineCoverage = HashMap<String, HashMap<usize, u64>>;
pub(crate) fn load(project_path: &Path) -> Option<LineCoverage> {
crate::services::agent_context::query::discover_line_coverage(project_path)
}
pub(crate) const COVERAGE_HINT: &str =
"run 'make coverage' (writes target/coverage/lcov.info) to enable this claim";
pub(crate) fn total_percent(coverage: &LineCoverage) -> Option<f64> {
let (covered, total) = coverage.values().fold((0usize, 0usize), |(c, t), lines| {
(
c + lines.values().filter(|hits| **hits > 0).count(),
t + lines.len(),
)
});
(total > 0).then(|| covered as f64 * 100.0 / total as f64)
}
pub(crate) fn file_percent(lines: &HashMap<usize, u64>) -> Option<f64> {
(!lines.is_empty()).then(|| {
lines.values().filter(|hits| **hits > 0).count() as f64 * 100.0 / lines.len() as f64
})
}
pub(crate) fn lookup<'a>(
coverage: &'a LineCoverage,
path: &str,
) -> Option<&'a HashMap<usize, u64>> {
let normalized = path.trim_start_matches("./");
coverage
.get(normalized)
.or_else(|| coverage.get(path))
.or_else(|| {
coverage
.iter()
.find(|(k, _)| k.trim_start_matches("./") == normalized)
.map(|(_, v)| v)
})
}
pub(crate) fn changed_lines(diff: &str) -> HashMap<String, Vec<usize>> {
let mut result: HashMap<String, Vec<usize>> = HashMap::new();
let mut current: Option<String> = None;
for line in diff.lines() {
if let Some(path) = line.strip_prefix("+++ b/") {
current = (path != "/dev/null").then(|| path.to_string());
continue;
}
let Some(hunk) = line.strip_prefix("@@ ") else {
continue;
};
let Some(ref file) = current else { continue };
let Some(new_part) = hunk.split_whitespace().find(|p| p.starts_with('+')) else {
continue;
};
let mut nums = new_part.trim_start_matches('+').splitn(2, ',');
let Some(start) = nums.next().and_then(|n| n.parse::<usize>().ok()) else {
continue;
};
let len = nums.next().map_or(1, |n| n.parse::<usize>().unwrap_or(1));
if len > 0 {
result
.entry(file.clone())
.or_default()
.extend(start..start + len);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn cov(pairs: &[(&str, &[(usize, u64)])]) -> LineCoverage {
pairs
.iter()
.map(|(f, lines)| (f.to_string(), lines.iter().copied().collect()))
.collect()
}
#[test]
fn total_percent_counts_executed_lines() {
let c = cov(&[("a.rs", &[(1, 1), (2, 0)]), ("b.rs", &[(1, 5), (2, 3)])]);
assert_eq!(total_percent(&c), Some(75.0));
}
#[test]
fn total_percent_is_none_without_instrumented_lines() {
assert_eq!(total_percent(&cov(&[])), None);
assert_eq!(total_percent(&cov(&[("a.rs", &[])])), None);
}
#[test]
fn file_percent_is_per_file() {
let c = cov(&[("a.rs", &[(1, 1), (2, 0), (3, 0), (4, 0)])]);
assert_eq!(file_percent(lookup(&c, "a.rs").unwrap()), Some(25.0));
}
#[test]
fn lookup_tolerates_a_leading_dot_slash() {
let c = cov(&[("src/a.rs", &[(1, 1)])]);
assert!(lookup(&c, "./src/a.rs").is_some());
assert!(lookup(&c, "src/a.rs").is_some());
assert!(lookup(&c, "src/missing.rs").is_none());
}
#[test]
fn changed_lines_reads_the_post_image_side() {
let diff = "diff --git a/src/a.rs b/src/a.rs\n\
--- a/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -10,0 +11,3 @@\n\
+one\n+two\n+three\n";
let changed = changed_lines(diff);
assert_eq!(changed["src/a.rs"], vec![11, 12, 13]);
}
#[test]
fn changed_lines_handles_a_single_line_hunk() {
let diff = "+++ b/src/a.rs\n@@ -5 +5 @@\n-old\n+new\n";
assert_eq!(changed_lines(diff)["src/a.rs"], vec![5]);
}
#[test]
fn changed_lines_ignores_pure_deletions() {
let diff = "+++ b/src/a.rs\n@@ -7,3 +7,0 @@\n-gone\n";
assert!(!changed_lines(diff).contains_key("src/a.rs"));
}
#[test]
fn changed_lines_tracks_multiple_files() {
let diff = "+++ b/src/a.rs\n@@ -1,0 +1,2 @@\n\
+++ b/src/b.rs\n@@ -1,0 +9,1 @@\n";
let changed = changed_lines(diff);
assert_eq!(changed["src/a.rs"], vec![1, 2]);
assert_eq!(changed["src/b.rs"], vec![9]);
}
}