omni_dev/coverage/
lcov.rs1use anyhow::{Context, Result};
11
12use super::model::{CoverageReport, FileCoverage};
13
14pub 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 }
41
42 if let Some(file) = current.take() {
44 report.insert(file);
45 }
46
47 Ok(report)
48}
49
50fn 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 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}