use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileCoverage {
pub path: String,
pub lines: BTreeMap<u32, u64>,
pub branches: BTreeMap<(u32, String, String), bool>,
pub missed_branches: BTreeMap<u32, u64>,
}
impl FileCoverage {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
lines: BTreeMap::new(),
branches: BTreeMap::new(),
missed_branches: BTreeMap::new(),
}
}
pub fn record(&mut self, line: u32, hits: u64) {
self.lines
.entry(line)
.and_modify(|h| *h = (*h).max(hits))
.or_insert(hits);
}
fn merge(&mut self, other: Self) {
for (line, hits) in other.lines {
self.record(line, hits);
}
for (key, covered) in other.branches {
self.branches
.entry(key)
.and_modify(|v| *v |= covered)
.or_insert(covered);
}
for (line, missed) in other.missed_branches {
self.missed_branches
.entry(line)
.and_modify(|v| *v = (*v).max(missed))
.or_insert(missed);
}
}
pub fn total_lines(&self) -> u64 {
self.lines.len() as u64
}
pub fn covered_lines(&self) -> u64 {
self.lines.values().filter(|&&h| h > 0).count() as u64
}
pub fn percent(&self) -> Option<f64> {
let total = self.total_lines();
if total == 0 {
None
} else {
Some(self.covered_lines() as f64 / total as f64 * 100.0)
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CoverageReport {
pub files: BTreeMap<String, FileCoverage>,
}
impl CoverageReport {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, file: FileCoverage) {
match self.files.get_mut(&file.path) {
Some(existing) => {
existing.merge(file);
}
None => {
self.files.insert(file.path.clone(), file);
}
}
}
pub fn merge(&mut self, other: Self) {
for file in other.files.into_values() {
self.insert(file);
}
}
pub fn hits(&self, path: &str, line: u32) -> Option<u64> {
self.files
.get(path)
.and_then(|f| f.lines.get(&line).copied())
}
pub fn apply_branch_coverage(&mut self) {
for file in self.files.values_mut() {
for (&(line, _, _), &covered) in &file.branches {
if !covered {
if let Some(hits) = file.lines.get_mut(&line) {
*hits = 0;
}
}
}
for (&line, &missed) in &file.missed_branches {
if missed > 0 {
if let Some(hits) = file.lines.get_mut(&line) {
*hits = 0;
}
}
}
}
}
pub fn total_lines(&self) -> u64 {
self.files.values().map(FileCoverage::total_lines).sum()
}
pub fn covered_lines(&self) -> u64 {
self.files.values().map(FileCoverage::covered_lines).sum()
}
pub fn percent(&self) -> Option<f64> {
let total = self.total_lines();
if total == 0 {
None
} else {
Some(self.covered_lines() as f64 / total as f64 * 100.0)
}
}
pub fn strip_prefix(&mut self, prefix: &Path) {
let prefix_str = prefix.to_string_lossy();
let prefix_slash = format!("{}/", prefix_str.trim_end_matches('/'));
let mut remapped: BTreeMap<String, FileCoverage> = BTreeMap::new();
for (_, mut file) in std::mem::take(&mut self.files) {
let normalized = normalize_path(&file.path, &prefix_slash);
file.path.clone_from(&normalized);
match remapped.get_mut(&normalized) {
Some(existing) => {
existing.merge(file);
}
None => {
remapped.insert(normalized, file);
}
}
}
self.files = remapped;
}
pub fn retain_paths<F>(&mut self, keep: F) -> Vec<FileCoverage>
where
F: Fn(&str) -> bool,
{
let (kept, dropped): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.files)
.into_iter()
.partition(|(path, _)| keep(path));
self.files = kept;
dropped.into_values().collect()
}
pub fn retain_lines<F>(&mut self, keep: F)
where
F: Fn(&str, u32) -> bool,
{
for (path, file) in &mut self.files {
file.lines.retain(|&line, _| keep(path, line));
file.branches
.retain(|(line, _, _), _| file.lines.contains_key(line));
file.missed_branches
.retain(|line, _| file.lines.contains_key(line));
}
self.files.retain(|_, file| file.total_lines() > 0);
}
}
fn normalize_path(path: &str, prefix_slash: &str) -> String {
let stripped = path.strip_prefix(prefix_slash).unwrap_or(path);
stripped
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn record_takes_max_hits() {
let mut f = FileCoverage::new("src/a.rs");
f.record(1, 0);
f.record(1, 3);
f.record(1, 1);
assert_eq!(f.lines.get(&1), Some(&3));
}
#[test]
fn percent_counts_only_executable_lines() {
let mut f = FileCoverage::new("src/a.rs");
f.record(1, 1);
f.record(2, 0);
f.record(3, 5);
assert_eq!(f.total_lines(), 3);
assert_eq!(f.covered_lines(), 2);
assert!((f.percent().unwrap() - 66.666_666).abs() < 1e-3);
}
#[test]
fn empty_file_has_no_percent() {
let f = FileCoverage::new("src/empty.rs");
assert_eq!(f.percent(), None);
}
#[test]
fn hits_distinguishes_uncovered_from_non_executable() {
let mut report = CoverageReport::new();
let mut f = FileCoverage::new("src/a.rs");
f.record(10, 0); report.insert(f);
assert_eq!(report.hits("src/a.rs", 10), Some(0)); assert_eq!(report.hits("src/a.rs", 11), None); assert_eq!(report.hits("src/missing.rs", 1), None);
}
#[test]
fn insert_merges_duplicate_paths() {
let mut report = CoverageReport::new();
let mut a = FileCoverage::new("src/a.rs");
a.record(1, 0);
let mut b = FileCoverage::new("src/a.rs");
b.record(1, 2);
b.record(2, 1);
report.insert(a);
report.insert(b);
assert_eq!(report.files.len(), 1);
assert_eq!(report.hits("src/a.rs", 1), Some(2));
assert_eq!(report.hits("src/a.rs", 2), Some(1));
}
#[test]
fn merge_unions_files_and_takes_the_max_per_line() {
let mut shard_a = CoverageReport::new();
let mut a = FileCoverage::new("src/shared.rs");
a.record(1, 3);
a.record(2, 0);
shard_a.insert(a);
let mut only_a = FileCoverage::new("src/only_a.rs");
only_a.record(1, 1);
shard_a.insert(only_a);
let mut shard_b = CoverageReport::new();
let mut b = FileCoverage::new("src/shared.rs");
b.record(1, 0);
b.record(2, 7);
b.record(3, 0);
shard_b.insert(b);
let mut only_b = FileCoverage::new("src/only_b.rs");
only_b.record(1, 0);
shard_b.insert(only_b);
shard_a.merge(shard_b);
assert_eq!(shard_a.files.len(), 3);
assert_eq!(shard_a.hits("src/shared.rs", 1), Some(3));
assert_eq!(shard_a.hits("src/shared.rs", 2), Some(7));
assert_eq!(shard_a.hits("src/shared.rs", 3), Some(0));
assert_eq!(shard_a.hits("src/only_a.rs", 1), Some(1));
assert_eq!(shard_a.hits("src/only_b.rs", 1), Some(0));
}
#[test]
fn merge_is_order_independent() {
let report = |lines: &[(u32, u64)]| {
let mut r = CoverageReport::new();
let mut f = FileCoverage::new("src/a.rs");
for &(line, hits) in lines {
f.record(line, hits);
}
r.insert(f);
r
};
let mut ab = report(&[(1, 1), (2, 0)]);
ab.merge(report(&[(1, 0), (2, 4)]));
let mut ba = report(&[(1, 0), (2, 4)]);
ba.merge(report(&[(1, 1), (2, 0)]));
assert_eq!(ab, ba);
}
#[test]
fn project_percent_aggregates_files() {
let mut report = CoverageReport::new();
let mut a = FileCoverage::new("src/a.rs");
a.record(1, 1);
a.record(2, 1);
let mut b = FileCoverage::new("src/b.rs");
b.record(1, 0);
b.record(2, 0);
report.insert(a);
report.insert(b);
assert_eq!(report.total_lines(), 4);
assert_eq!(report.covered_lines(), 2);
assert_eq!(report.percent(), Some(50.0));
}
#[test]
fn strip_prefix_makes_paths_repo_relative() {
let mut report = CoverageReport::new();
let mut f = FileCoverage::new("/home/runner/work/patchcov/patchcov/src/a.rs");
f.record(1, 1);
report.insert(f);
report.strip_prefix(Path::new("/home/runner/work/patchcov/patchcov"));
assert!(report.files.contains_key("src/a.rs"));
}
#[test]
fn strip_prefix_merges_colliding_paths() {
let mut report = CoverageReport::new();
let mut a = FileCoverage::new("/root/src/a.rs");
a.record(1, 0);
let mut b = FileCoverage::new("/root/./src/a.rs");
b.record(2, 1);
report.insert(a);
report.insert(b);
assert_eq!(report.files.len(), 2);
report.strip_prefix(Path::new("/root"));
assert_eq!(report.files.len(), 1);
assert_eq!(report.hits("src/a.rs", 1), Some(0));
assert_eq!(report.hits("src/a.rs", 2), Some(1));
}
#[test]
fn strip_prefix_leaves_relative_paths() {
let mut report = CoverageReport::new();
let mut f = FileCoverage::new("./src/a.rs");
f.record(1, 1);
report.insert(f);
report.strip_prefix(Path::new("/some/other/root"));
assert!(report.files.contains_key("src/a.rs"));
}
#[test]
fn retain_lines_drops_only_the_named_lines() {
let mut report = CoverageReport::new();
let mut f = FileCoverage::new("src/a.rs");
f.record(1, 1);
f.record(2, 0);
f.record(3, 5);
report.insert(f);
report.retain_lines(|path, line| !(path == "src/a.rs" && line == 2));
assert_eq!(report.hits("src/a.rs", 1), Some(1));
assert_eq!(report.hits("src/a.rs", 2), None);
assert_eq!(report.hits("src/a.rs", 3), Some(5));
assert_eq!(report.total_lines(), 2);
}
#[test]
fn retain_lines_removes_files_left_empty() {
let mut report = CoverageReport::new();
for path in ["src/a.rs", "src/gated.rs"] {
let mut f = FileCoverage::new(path);
f.record(1, 1);
report.insert(f);
}
report.retain_lines(|path, _| path != "src/gated.rs");
assert!(report.files.contains_key("src/a.rs"));
assert!(
!report.files.contains_key("src/gated.rs"),
"a file with no lines left must be dropped, not kept at 0%"
);
}
#[test]
fn retain_paths_drops_non_matching_files() {
let mut report = CoverageReport::new();
for path in ["src/a.rs", "src/gpu/mlx.rs", "src/b.rs"] {
let mut f = FileCoverage::new(path);
f.record(1, 1);
report.insert(f);
}
let dropped = report.retain_paths(|path| !path.contains("gpu/"));
assert!(report.files.contains_key("src/a.rs"));
assert!(report.files.contains_key("src/b.rs"));
assert!(!report.files.contains_key("src/gpu/mlx.rs"));
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].path, "src/gpu/mlx.rs");
assert_eq!(
dropped[0].lines.len(),
1,
"the dropped file keeps its lines"
);
}
#[test]
fn branch_evidence_survives_aliases_merges_and_line_filters() {
let mut a = crate::lcov::parse_with_branches("SF:/root/a.rs\nDA:1,1\nDA:2,1\nBRDA:1,0,0,1\nBRDA:1,0,1,0\nBRDA:2,0,0,0\nend_of_record").unwrap();
let b = crate::lcov::parse_with_branches(
"SF:/root/./a.rs\nDA:1,2\nBRDA:1,0,0,0\nBRDA:1,0,1,1\nend_of_record",
)
.unwrap();
a.merge(b);
a.strip_prefix(Path::new("/root"));
a.retain_lines(|_, line| line != 2);
assert_eq!(a.files["a.rs"].branches.len(), 2);
a.apply_branch_coverage();
assert_eq!(a.hits("a.rs", 1), Some(2));
assert_eq!(a.hits("a.rs", 2), None);
}
#[test]
fn mapped_aliases_keep_partial_branch_evidence() {
let mut report = crate::lcov::parse_with_branches(
"SF:pkg/a.rs\nDA:1,1\nBRDA:1,0,0,0\nend_of_record\nSF:a.rs\nDA:1,2\nend_of_record",
)
.unwrap();
report
.map_paths(&[crate::paths::PathMapping {
from: "pkg".into(),
to: String::new(),
}])
.unwrap();
report.apply_branch_coverage();
assert_eq!(report.hits("a.rs", 1), Some(0));
}
}