use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LcovError {
#[error("failed to read LCOV file: {0}")]
Io(#[from] std::io::Error),
#[error("malformed DA record in LCOV: {0:?}")]
MalformedDa(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct CoverageReport {
pub lines_instrumented: u64,
pub lines_hit: u64,
pub net_pct: f64,
pub file_coverage: HashMap<String, FileCoverage>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FileCoverage {
pub line_hits: HashMap<u32, u64>,
}
impl FileCoverage {
pub fn instrumented(&self) -> usize {
self.line_hits.len()
}
pub fn hit(&self) -> usize {
self.line_hits.values().filter(|&&h| h > 0).count()
}
}
pub fn parse_lcov(text: &str) -> Result<CoverageReport, LcovError> {
let mut file_coverage: HashMap<String, FileCoverage> = HashMap::new();
let mut current_file: Option<String> = None;
for line in text.lines() {
let line = line.trim();
if let Some(sf_path) = line.strip_prefix("SF:") {
let path = sf_path.to_string();
current_file = Some(path.clone());
file_coverage.entry(path).or_default();
} else if let Some(da_data) = line.strip_prefix("DA:") {
let data = da_data;
let mut parts = data.splitn(3, ',');
let line_no_str = parts.next().unwrap_or("");
let hits_str = parts.next().unwrap_or("");
let line_no = line_no_str
.trim()
.parse::<u32>()
.map_err(|_| LcovError::MalformedDa(line.to_string()))?;
let hits = hits_str
.trim()
.parse::<u64>()
.map_err(|_| LcovError::MalformedDa(line.to_string()))?;
if let Some(ref fname) = current_file {
file_coverage
.entry(fname.clone())
.or_default()
.line_hits
.insert(line_no, hits);
}
} else if line == "end_of_record" {
current_file = None;
}
}
let mut lines_instrumented: u64 = 0;
let mut lines_hit: u64 = 0;
for fc in file_coverage.values() {
lines_instrumented += fc.instrumented() as u64;
lines_hit += fc.hit() as u64;
}
let net_pct = if lines_instrumented == 0 {
100.0 } else {
(lines_hit as f64 / lines_instrumented as f64) * 100.0
};
Ok(CoverageReport {
lines_instrumented,
lines_hit,
net_pct,
file_coverage,
})
}
pub fn parse_lcov_file(path: &std::path::Path) -> Result<CoverageReport, LcovError> {
let text = std::fs::read_to_string(path)?;
parse_lcov(&text)
}
pub fn new_code_coverage(
report: &CoverageReport,
added_lines: &HashMap<String, Vec<u32>>,
) -> Option<f64> {
let mut total_new: u64 = 0;
let mut hit_new: u64 = 0;
for (file, lines) in added_lines {
for &lineno in lines {
let hits = report
.file_coverage
.get(file)
.and_then(|fc| fc.line_hits.get(&lineno))
.or_else(|| {
report
.file_coverage
.iter()
.find(|(k, _)| k.ends_with(file.as_str()))
.and_then(|(_, fc)| fc.line_hits.get(&lineno))
});
if let Some(&h) = hits {
total_new += 1;
if h > 0 {
hit_new += 1;
}
}
}
}
if total_new == 0 {
return None; }
Some((hit_new as f64 / total_new as f64) * 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_lcov_empty() {
let report = parse_lcov("").expect("empty LCOV must parse");
assert_eq!(report.lines_instrumented, 0);
assert_eq!(report.lines_hit, 0);
assert!(
(report.net_pct - 100.0).abs() < f64::EPSILON,
"empty report must return 100% (trivial)"
);
assert!(report.file_coverage.is_empty());
}
#[test]
fn parse_lcov_basic() {
let lcov = "SF:src/foo.rs\nDA:1,1\nDA:2,0\nDA:3,5\nend_of_record\n";
let report = parse_lcov(lcov).expect("parse");
assert_eq!(report.lines_instrumented, 3);
assert_eq!(report.lines_hit, 2);
let expected_pct = (2.0 / 3.0) * 100.0;
assert!(
(report.net_pct - expected_pct).abs() < 0.01,
"expected ~66.67%, got {}",
report.net_pct
);
}
#[test]
fn parse_lcov_multiple_files() {
let lcov = "SF:src/a.rs\nDA:1,1\nDA:2,1\nend_of_record\nSF:src/b.rs\nDA:10,1\nDA:11,0\nend_of_record\n";
let report = parse_lcov(lcov).expect("parse");
assert_eq!(report.lines_instrumented, 4);
assert_eq!(report.lines_hit, 3);
let expected_pct = 75.0;
assert!(
(report.net_pct - expected_pct).abs() < 0.01,
"expected 75%, got {}",
report.net_pct
);
}
#[test]
fn parse_lcov_bad_da_line() {
let lcov = "SF:src/foo.rs\nDA:notanumber,1\nend_of_record\n";
let result = parse_lcov(lcov);
assert!(
result.is_err(),
"malformed DA line must produce an error, got {result:?}"
);
}
#[test]
fn new_code_coverage_all_hit() {
let lcov = "SF:src/foo.rs\nDA:5,3\nDA:6,1\nend_of_record\n";
let report = parse_lcov(lcov).expect("parse");
let mut added: HashMap<String, Vec<u32>> = HashMap::new();
added.insert("src/foo.rs".to_string(), vec![5, 6]);
let pct = new_code_coverage(&report, &added).expect("Some");
assert!(
(pct - 100.0).abs() < f64::EPSILON,
"all hit → 100%, got {pct}"
);
}
#[test]
fn new_code_coverage_partial() {
let lcov = "SF:src/foo.rs\nDA:5,1\nDA:6,0\nend_of_record\n";
let report = parse_lcov(lcov).expect("parse");
let mut added: HashMap<String, Vec<u32>> = HashMap::new();
added.insert("src/foo.rs".to_string(), vec![5, 6]);
let pct = new_code_coverage(&report, &added).expect("Some");
assert!(
(pct - 50.0).abs() < 0.01,
"50% new-code coverage expected, got {pct}"
);
}
#[test]
fn new_code_coverage_no_new_lines() {
let lcov = "SF:src/foo.rs\nDA:5,1\nend_of_record\n";
let report = parse_lcov(lcov).expect("parse");
let added: HashMap<String, Vec<u32>> = HashMap::new();
let result = new_code_coverage(&report, &added);
assert!(result.is_none(), "empty added_lines must return None");
}
#[test]
fn new_code_coverage_absolute_vs_relative() {
let lcov = "SF:/home/ci/workspace/trusty-tools/src/foo.rs\nDA:10,2\nend_of_record\n";
let report = parse_lcov(lcov).expect("parse");
let mut added: HashMap<String, Vec<u32>> = HashMap::new();
added.insert("src/foo.rs".to_string(), vec![10]);
let pct = new_code_coverage(&report, &added).expect("Some via suffix match");
assert!(
(pct - 100.0).abs() < f64::EPSILON,
"suffix-match fallback must hit → 100%, got {pct}"
);
}
}