#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
#[cfg(test)]
use crate::error::ReadLcovError;
use crate::error::{CoverageGateError, ParseLcovError};
#[derive(Debug, Clone)]
pub(crate) struct CoverageReport {
pub(crate) files: Vec<FileReport>,
}
#[derive(Debug, Clone)]
pub(crate) struct FileReport {
pub(crate) filename: PathBuf,
pub(crate) lines_total: u32,
pub(crate) lines_covered: u32,
pub(crate) coverable_lines: Vec<u32>,
pub(crate) uncovered_lines: Vec<u32>,
}
impl CoverageReport {
#[ohno::enrich_err("failed to parse lcov tracefile")]
pub(crate) fn from_str(input: &str) -> Result<Self, CoverageGateError> {
Self::from_strs(std::slice::from_ref(&input))
}
#[ohno::enrich_err("failed to parse lcov tracefile")]
pub(crate) fn from_strs(inputs: &[&str]) -> Result<Self, CoverageGateError> {
let mut merged: Option<lcov::Report> = None;
for input in inputs {
let reader = lcov::Reader::new(input.as_bytes());
let report = lcov::Report::from_reader(reader).map_err(ParseLcovError::from)?;
match &mut merged {
None => merged = Some(report),
Some(acc) => acc.merge_lossy(report),
}
}
Ok(Self::from_lcov_report(merged.unwrap_or_default()))
}
#[cfg(test)]
#[ohno::enrich_err("failed to read lcov tracefile `{}`", path.display())]
pub(crate) fn from_path(path: &Path) -> Result<Self, CoverageGateError> {
let report = lcov::Report::from_file(path).map_err(|e| ReadLcovError::caused_by(path.display().to_string(), e))?;
Ok(Self::from_lcov_report(report))
}
fn from_lcov_report(report: lcov::Report) -> Self {
let mut files = Vec::with_capacity(report.sections.len());
for (key, section) in report.sections {
let mut total: u32 = 0;
let mut covered: u32 = 0;
let mut coverable_lines = Vec::with_capacity(section.lines.len());
let mut uncovered_lines = Vec::new();
for (key, data) in §ion.lines {
total = total.saturating_add(1);
coverable_lines.push(key.line);
if data.count > 0 {
covered = covered.saturating_add(1);
} else {
uncovered_lines.push(key.line);
}
}
sort_line_numbers(&mut coverable_lines);
sort_line_numbers(&mut uncovered_lines);
files.push(FileReport {
filename: key.source_file,
lines_total: total,
lines_covered: covered,
coverable_lines,
uncovered_lines,
});
}
files.sort_by(|a, b| a.filename.cmp(&b.filename));
Self { files }
}
}
fn sort_line_numbers(lines: &mut [u32]) {
lines.sort_unstable();
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
const SINGLE_FILE: &str = include_str!("../tests/fixtures/lcov/single_file.info");
const MULTI_FILE: &str = include_str!("../tests/fixtures/lcov/multi_file.info");
const EMPTY: &str = include_str!("../tests/fixtures/lcov/empty.info");
const MALFORMED: &str = include_str!("../tests/fixtures/lcov/malformed.info");
#[test]
fn parses_empty_tracefile() {
let report = CoverageReport::from_str(EMPTY).expect("empty fixture parses");
assert!(report.files.is_empty());
}
#[test]
fn parses_single_file() {
let report = CoverageReport::from_str(SINGLE_FILE).expect("single file parses");
assert_eq!(report.files.len(), 1);
let f = &report.files[0];
assert!(f.filename.to_string_lossy().ends_with("crates/alpha/src/lib.rs"));
assert_eq!(f.lines_total, 4);
assert_eq!(f.lines_covered, 3);
assert_eq!(f.coverable_lines, vec![1, 2, 3, 4]);
assert_eq!(f.uncovered_lines, vec![3]);
}
#[test]
fn line_numbers_are_sorted_explicitly() {
let mut lines = [8, 2, 5, 3];
sort_line_numbers(&mut lines);
assert_eq!(lines, [2, 3, 5, 8]);
}
#[test]
fn parses_multi_file() {
let report = CoverageReport::from_str(MULTI_FILE).expect("multi-file parses");
assert_eq!(report.files.len(), 4);
let total_lines: u32 = report.files.iter().map(|f| f.lines_total).sum();
let total_covered: u32 = report.files.iter().map(|f| f.lines_covered).sum();
assert_eq!(total_lines, 100 + 40 + 50 + 80);
assert_eq!(total_covered, 95 + 30 + 25 + 80);
}
#[test]
fn lenient_line_semantics_match_lcov() {
let input = "\
TN:
SF:/repo/crates/alpha/src/lib.rs
DA:1,1
DA:2,0
DA:3,5
end_of_record
";
let report = CoverageReport::from_str(input).expect("parses");
assert_eq!(report.files[0].lines_total, 3);
assert_eq!(report.files[0].lines_covered, 2);
}
#[test]
fn malformed_is_rejected() {
let err = CoverageReport::from_str(MALFORMED).expect_err("garbage should fail to parse");
assert!(err.to_string().contains("lcov tracefile"));
}
#[test]
fn from_strs_empty_slice_yields_empty_report() {
let report = CoverageReport::from_strs(&[]).expect("empty slice parses");
assert!(report.files.is_empty());
}
#[test]
fn from_strs_merges_line_counts_and_unions_lines() {
let config_a = "\
TN:
SF:/repo/crates/alpha/src/lib.rs
DA:1,1
DA:2,3
DA:3,0
end_of_record
";
let config_b = "\
TN:
SF:/repo/crates/alpha/src/lib.rs
DA:1,0
DA:2,0
DA:3,2
DA:4,0
end_of_record
";
let report = CoverageReport::from_strs(&[config_a, config_b]).expect("merge parses");
assert_eq!(report.files.len(), 1, "same file must merge into one entry");
let f = &report.files[0];
assert_eq!(f.lines_total, 4, "line set is the union across configs");
assert_eq!(f.lines_covered, 3, "covered if hit in either config");
assert_eq!(f.coverable_lines, vec![1, 2, 3, 4]);
assert_eq!(f.uncovered_lines, vec![4]);
}
#[test]
fn from_strs_unions_distinct_files() {
let file_x = "\
TN:
SF:/repo/crates/x/src/lib.rs
DA:1,1
end_of_record
";
let file_y = "\
TN:
SF:/repo/crates/y/src/lib.rs
DA:1,1
DA:2,0
end_of_record
";
let report = CoverageReport::from_strs(&[file_x, file_y]).expect("merge parses");
assert_eq!(report.files.len(), 2);
}
#[test]
fn from_strs_propagates_parse_error_from_any_input() {
let err = CoverageReport::from_strs(&[SINGLE_FILE, MALFORMED]).expect_err("a malformed input must fail the merge");
assert!(err.to_string().contains("lcov tracefile"));
}
#[cfg_attr(miri, ignore = "uses real filesystem and open(); miri isolation forbids both")]
#[test]
fn from_path_reads_from_disk() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), SINGLE_FILE).expect("write fixture");
let report = CoverageReport::from_path(tmp.path()).expect("parses from disk");
assert_eq!(report.files.len(), 1);
}
}