use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Coverage {
files: HashMap<PathBuf, HashMap<u32, u64>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineCov {
Covered,
Uncovered,
None,
}
impl Coverage {
pub fn parse_lcov(text: &str) -> Coverage {
let mut files = HashMap::new();
let mut cur_path: Option<PathBuf> = None;
let mut cur: HashMap<u32, u64> = HashMap::new();
for line in text.lines() {
let line = line.trim();
if let Some(p) = line.strip_prefix("SF:") {
if let Some(path) = cur_path.take() {
merge(&mut files, path, std::mem::take(&mut cur));
}
cur_path = Some(PathBuf::from(p.trim()));
} else if let Some(da) = line.strip_prefix("DA:") {
if let Some((ln, hits)) = parse_da(da) {
let e = cur.entry(ln).or_insert(0);
*e = (*e).max(hits);
}
} else if line == "end_of_record" {
if let Some(path) = cur_path.take() {
merge(&mut files, path, std::mem::take(&mut cur));
}
}
}
if let Some(path) = cur_path.take() {
merge(&mut files, path, cur);
}
Coverage { files }
}
pub fn file_count(&self) -> usize {
self.files.len()
}
pub fn totals(&self) -> (usize, usize) {
let mut covered = 0;
let mut total = 0;
for lines in self.files.values() {
for hits in lines.values() {
total += 1;
if *hits > 0 {
covered += 1;
}
}
}
(covered, total)
}
pub fn file_totals(&self, file: &Path) -> Option<(usize, usize)> {
let lines = self.lookup(file)?;
let total = lines.len();
let covered = lines.values().filter(|h| **h > 0).count();
Some((covered, total))
}
pub fn percent(&self) -> f64 {
let (c, t) = self.totals();
if t == 0 {
0.0
} else {
100.0 * c as f64 / t as f64
}
}
pub fn line_cov(&self, file: &Path, line: u32) -> LineCov {
let Some(lines) = self.lookup(file) else {
return LineCov::None;
};
match lines.get(&line) {
Some(0) => LineCov::Uncovered,
Some(_) => LineCov::Covered,
None => LineCov::None,
}
}
fn lookup(&self, file: &Path) -> Option<&HashMap<u32, u64>> {
if let Some(m) = self.files.get(file) {
return Some(m);
}
self.files
.iter()
.find(|(p, _)| p.ends_with(file) || file.ends_with(p.as_path()))
.map(|(_, m)| m)
}
}
fn merge(files: &mut HashMap<PathBuf, HashMap<u32, u64>>, path: PathBuf, lines: HashMap<u32, u64>) {
if lines.is_empty() {
return;
}
let entry = files.entry(path).or_default();
for (ln, hits) in lines {
let e = entry.entry(ln).or_insert(0);
*e = (*e).max(hits);
}
}
fn parse_da(da: &str) -> Option<(u32, u64)> {
let mut it = da.split(',');
let ln = it.next()?.trim().parse::<u32>().ok()?;
let hits = it.next()?.trim().parse::<u64>().ok()?;
Some((ln, hits))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
TN:
SF:src/main.rs
DA:1,3
DA:2,0
DA:4,1
LF:3
LH:2
end_of_record
SF:/abs/repo/src/lib.rs
DA:10,5
end_of_record
";
#[test]
fn parses_files_and_line_hits() {
let c = Coverage::parse_lcov(SAMPLE);
assert_eq!(c.file_count(), 2);
assert_eq!(c.line_cov(Path::new("src/main.rs"), 1), LineCov::Covered);
assert_eq!(c.line_cov(Path::new("src/main.rs"), 2), LineCov::Uncovered);
assert_eq!(c.line_cov(Path::new("src/main.rs"), 4), LineCov::Covered);
assert_eq!(c.line_cov(Path::new("src/main.rs"), 3), LineCov::None);
}
#[test]
fn totals_and_percent() {
let c = Coverage::parse_lcov(SAMPLE);
assert_eq!(c.totals(), (3, 4));
assert!((c.percent() - 75.0).abs() < 0.01);
assert_eq!(
c.file_totals(Path::new("src/main.rs")),
Some((2, 3))
);
}
#[test]
fn matches_relative_against_absolute_sf_path() {
let c = Coverage::parse_lcov(SAMPLE);
assert_eq!(c.line_cov(Path::new("src/lib.rs"), 10), LineCov::Covered);
}
#[test]
fn unknown_file_or_line_is_none() {
let c = Coverage::parse_lcov(SAMPLE);
assert_eq!(c.line_cov(Path::new("nope.rs"), 1), LineCov::None);
assert_eq!(c.line_cov(Path::new("src/main.rs"), 999), LineCov::None);
}
#[test]
fn skips_malformed_da_lines() {
let c = Coverage::parse_lcov("SF:a.rs\nDA:bad\nDA:5,2\nend_of_record\n");
assert_eq!(c.line_cov(Path::new("a.rs"), 5), LineCov::Covered);
}
#[test]
fn trailing_record_without_end_is_kept() {
let c = Coverage::parse_lcov("SF:a.rs\nDA:1,1\n");
assert_eq!(c.line_cov(Path::new("a.rs"), 1), LineCov::Covered);
}
}