use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileCoverage {
pub path: String,
pub lines: BTreeMap<u32, u64>,
}
impl FileCoverage {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
lines: 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);
}
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) => {
for (line, hits) in file.lines {
existing.record(line, hits);
}
}
None => {
self.files.insert(file.path.clone(), 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 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) => {
for (line, hits) in std::mem::take(&mut file.lines) {
existing.record(line, hits);
}
}
None => {
remapped.insert(normalized, file);
}
}
}
self.files = remapped;
}
pub fn retain_paths<F>(&mut self, keep: F)
where
F: Fn(&str) -> bool,
{
self.files.retain(|path, _| keep(path));
}
}
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 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/omni-dev/omni-dev/src/a.rs");
f.record(1, 1);
report.insert(f);
report.strip_prefix(Path::new("/home/runner/work/omni-dev/omni-dev"));
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_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);
}
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"));
}
}