Skip to main content

omni_dev/coverage/
lcov.rs

1//! lcov trace-file parser (line coverage only).
2//!
3//! lcov records one source file per `SF:`…`end_of_record` block. Within a block,
4//! `DA:<line>,<hits>[,<checksum>]` gives the hit count for an instrumented line.
5//! Branch (`BRDA`) and function (`FN*`) records are ignored — v1 is scoped to
6//! line coverage to match the existing coverage comment.
7//!
8//! Reference: <https://manpages.debian.org/unstable/lcov/geninfo.1.en.html>
9
10use anyhow::{Context, Result};
11
12use super::model::{CoverageReport, FileCoverage};
13
14/// Parses lcov trace text into a [`CoverageReport`].
15pub fn parse(content: &str) -> Result<CoverageReport> {
16    let mut report = CoverageReport::new();
17    let mut current: Option<FileCoverage> = None;
18
19    for (lineno, raw) in content.lines().enumerate() {
20        let line = raw.trim();
21        if line.is_empty() {
22            continue;
23        }
24
25        if let Some(path) = line.strip_prefix("SF:") {
26            current = Some(FileCoverage::new(path.trim()));
27        } else if let Some(rest) = line.strip_prefix("DA:") {
28            let file = current.as_mut().with_context(|| {
29                format!("lcov line {}: DA record outside of an SF block", lineno + 1)
30            })?;
31            let (number, hits) = parse_da(rest)
32                .with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
33            file.record(number, hits);
34        } else if line == "end_of_record" {
35            if let Some(file) = current.take() {
36                report.insert(file);
37            }
38        }
39        // All other records (TN, BRDA, FN, FNDA, LF, LH, …) are ignored.
40    }
41
42    // Tolerate a trailing block with no explicit end_of_record.
43    if let Some(file) = current.take() {
44        report.insert(file);
45    }
46
47    Ok(report)
48}
49
50/// Parses the payload of a `DA:` record (`<line>,<hits>[,<checksum>]`).
51fn parse_da(rest: &str) -> Result<(u32, u64)> {
52    let mut parts = rest.split(',');
53    let number: u32 = parts
54        .next()
55        .context("missing line number")?
56        .trim()
57        .parse()
58        .context("invalid line number")?;
59    // Hit counts can overflow i64 in pathological cases; lcov emits them as
60    // decimal integers. Saturate negative or out-of-range values to 0.
61    let hits: u64 = parts
62        .next()
63        .context("missing hit count")?
64        .trim()
65        .parse()
66        .unwrap_or(0);
67    Ok((number, hits))
68}
69
70#[cfg(test)]
71#[allow(clippy::unwrap_used, clippy::expect_used)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn parses_single_file() {
77        let lcov = "\
78TN:
79SF:/repo/src/a.rs
80DA:1,5
81DA:2,0
82DA:3,1
83LF:3
84LH:2
85end_of_record
86";
87        let report = parse(lcov).unwrap();
88        assert_eq!(report.files.len(), 1);
89        assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
90        assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
91        assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
92        assert_eq!(report.total_lines(), 3);
93        assert_eq!(report.covered_lines(), 2);
94    }
95
96    #[test]
97    fn parses_multiple_files() {
98        let lcov = "\
99SF:src/a.rs
100DA:1,1
101end_of_record
102SF:src/b.rs
103DA:1,0
104DA:2,2
105end_of_record
106";
107        let report = parse(lcov).unwrap();
108        assert_eq!(report.files.len(), 2);
109        assert_eq!(report.hits("src/a.rs", 1), Some(1));
110        assert_eq!(report.hits("src/b.rs", 2), Some(2));
111    }
112
113    #[test]
114    fn ignores_branch_and_function_records() {
115        let lcov = "\
116SF:src/a.rs
117FN:1,foo
118FNDA:3,foo
119DA:1,3
120BRDA:1,0,0,1
121end_of_record
122";
123        let report = parse(lcov).unwrap();
124        let f = &report.files["src/a.rs"];
125        assert_eq!(f.lines.len(), 1);
126        assert_eq!(f.lines.get(&1), Some(&3));
127    }
128
129    #[test]
130    fn da_with_checksum() {
131        let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
132        let report = parse(lcov).unwrap();
133        assert_eq!(report.hits("src/a.rs", 1), Some(4));
134    }
135
136    #[test]
137    fn tolerates_missing_end_of_record() {
138        let lcov = "SF:src/a.rs\nDA:1,1\n";
139        let report = parse(lcov).unwrap();
140        assert_eq!(report.hits("src/a.rs", 1), Some(1));
141    }
142
143    #[test]
144    fn da_outside_sf_is_error() {
145        let lcov = "DA:1,1\n";
146        assert!(parse(lcov).is_err());
147    }
148
149    #[test]
150    fn skips_blank_lines() {
151        let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
152        let report = parse(lcov).unwrap();
153        assert_eq!(report.hits("a.rs", 1), Some(1));
154    }
155
156    #[test]
157    fn negative_hit_count_saturates_to_zero() {
158        let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
159        assert_eq!(report.hits("a.rs", 1), Some(0));
160    }
161}