use anyhow::{Context, Result};
use lcov::{Reader, Record};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone)]
pub struct FileCoverage {
pub lines: BTreeMap<u32, u64>,
}
impl FileCoverage {
#[must_use]
pub fn coverage_in_span(
&self,
start: usize,
end: usize,
) -> f64 {
let start = start as u32;
let end = end as u32;
let executable: Vec<_> = self.lines.range(start..=end).collect();
if executable.is_empty() {
return 100.0;
}
let covered = executable.iter().filter(|(_, hits)| **hits > 0).count();
(covered as f64 / executable.len() as f64) * 100.0
}
}
pub fn parse_lcov(path: &Path) -> Result<HashMap<PathBuf, FileCoverage>> {
let reader =
Reader::open_file(path).with_context(|| format!("opening LCOV file {}", path.display()))?;
let mut files: HashMap<PathBuf, FileCoverage> = HashMap::new();
let mut current_path: Option<PathBuf> = None;
for record in reader {
let record = record.with_context(|| format!("parsing record in {}", path.display()))?;
match record {
Record::SourceFile { path: sf_path } => {
current_path = Some(sf_path.clone());
files.entry(sf_path).or_default();
},
Record::LineData { line, count, .. } => {
if let Some(ref p) = current_path
&& let Some(fc) = files.get_mut(p)
{
*fc.lines.entry(line).or_insert(0) += count;
}
},
Record::EndOfRecord => {
current_path = None;
},
_ => {},
}
}
Ok(files)
}
#[cfg(test)]
#[expect(
clippy::float_cmp,
reason = "coverage % is computed from integer line counts; exact equality is the right comparison"
)]
mod tests {
use super::*;
use std::io::Write;
use std::path::Path;
fn write_lcov(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("tempfile");
f.write_all(content.as_bytes()).expect("write");
f
}
#[test]
fn parse_lcov_reads_correct_file_and_hit_counts() {
let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,3\nDA:11,0\nend_of_record\n");
let result = parse_lcov(f.path()).expect("parse_lcov");
let cov = result
.get(Path::new("src/foo.rs"))
.expect("src/foo.rs must be in result");
assert_eq!(cov.lines[&10], 3, "line 10 should have 3 hits");
assert_eq!(cov.lines[&11], 0, "line 11 should have 0 hits");
}
#[test]
fn parse_lcov_accumulates_duplicate_line_entries() {
let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,2\nDA:10,3\nend_of_record\n");
let result = parse_lcov(f.path()).expect("parse_lcov");
assert_eq!(
result[Path::new("src/foo.rs")].lines[&10],
5,
"duplicate DA entries must be summed"
);
}
#[test]
fn parse_lcov_isolates_multiple_source_files() {
let f = write_lcov(
"TN:\nSF:src/a.rs\nDA:1,1\nend_of_record\nSF:src/b.rs\nDA:2,4\nend_of_record\n",
);
let result = parse_lcov(f.path()).expect("parse_lcov");
let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
let b = result.get(Path::new("src/b.rs")).expect("b.rs in result");
assert_eq!(a.lines[&1], 1);
assert_eq!(b.lines[&2], 4);
assert!(
!b.lines.contains_key(&1),
"line 1 from a.rs must not bleed into b.rs"
);
assert!(
!a.lines.contains_key(&2),
"line 2 from b.rs must not bleed into a.rs"
);
}
#[test]
fn stray_da_after_end_of_record_is_not_attributed_to_previous_file() {
let f = write_lcov(concat!(
"TN:\n",
"SF:src/a.rs\n",
"DA:1,1\n",
"end_of_record\n",
"DA:99,99\n", "SF:src/b.rs\n",
"DA:2,4\n",
"end_of_record\n",
));
let result = parse_lcov(f.path()).expect("parse_lcov");
let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
assert!(
!a.lines.contains_key(&99),
"stray DA:99,99 must not bleed into src/a.rs (EndOfRecord not resetting current_path)"
);
}
fn fc_from(lines: &[(u32, u64)]) -> FileCoverage {
FileCoverage {
lines: lines.iter().copied().collect(),
}
}
#[test]
fn empty_span_yields_full_coverage() {
let fc = fc_from(&[(5, 1), (25, 1)]);
assert_eq!(fc.coverage_in_span(10, 20), 100.0);
}
#[test]
fn all_executable_lines_hit_is_100_percent() {
let fc = fc_from(&[(10, 3), (11, 3), (12, 1)]);
assert_eq!(fc.coverage_in_span(10, 12), 100.0);
}
#[test]
fn half_hit_is_50_percent() {
let fc = fc_from(&[(10, 5), (11, 0), (12, 1), (13, 0)]);
assert_eq!(fc.coverage_in_span(10, 13), 50.0);
}
#[test]
fn span_is_inclusive_on_both_ends() {
let fc = fc_from(&[(5, 1), (10, 1), (15, 1)]);
assert_eq!(fc.coverage_in_span(10, 10), 100.0);
}
}