1use 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 mut line = raw.trim();
21
22 while let Some(rest) = line.strip_prefix("end_of_record") {
27 if let Some(file) = current.take() {
28 report.insert(file);
29 }
30 line = rest.trim_start();
31 }
32 if line.is_empty() {
33 continue;
34 }
35
36 if let Some(path) = line.strip_prefix("SF:") {
37 if let Some(unfinished) = current.replace(FileCoverage::new(path.trim())) {
40 report.insert(unfinished);
41 }
42 } else if let Some(rest) = line.strip_prefix("DA:") {
43 let file = current.as_mut().with_context(|| {
44 format!("lcov line {}: DA record outside of an SF block", lineno + 1)
45 })?;
46 let (number, hits) = parse_da(rest)
47 .with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
48 file.record(number, hits);
49 }
50 }
52
53 if let Some(file) = current.take() {
55 report.insert(file);
56 }
57
58 Ok(report)
59}
60
61fn parse_da(rest: &str) -> Result<(u32, u64)> {
63 let mut parts = rest.split(',');
64 let number: u32 = parts
65 .next()
66 .context("missing line number")?
67 .trim()
68 .parse()
69 .context("invalid line number")?;
70 let hits: u64 = parts
73 .next()
74 .context("missing hit count")?
75 .trim()
76 .parse()
77 .unwrap_or(0);
78 Ok((number, hits))
79}
80
81#[cfg(test)]
82#[allow(clippy::unwrap_used, clippy::expect_used)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn parses_single_file() {
88 let lcov = "\
89TN:
90SF:/repo/src/a.rs
91DA:1,5
92DA:2,0
93DA:3,1
94LF:3
95LH:2
96end_of_record
97";
98 let report = parse(lcov).unwrap();
99 assert_eq!(report.files.len(), 1);
100 assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
101 assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
102 assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
103 assert_eq!(report.total_lines(), 3);
104 assert_eq!(report.covered_lines(), 2);
105 }
106
107 #[test]
108 fn parses_multiple_files() {
109 let lcov = "\
110SF:src/a.rs
111DA:1,1
112end_of_record
113SF:src/b.rs
114DA:1,0
115DA:2,2
116end_of_record
117";
118 let report = parse(lcov).unwrap();
119 assert_eq!(report.files.len(), 2);
120 assert_eq!(report.hits("src/a.rs", 1), Some(1));
121 assert_eq!(report.hits("src/b.rs", 2), Some(2));
122 }
123
124 #[test]
131 fn newline_joined_shards_parse_as_their_merge() {
132 let shard_a = "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record";
133 let shard_b = "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nDA:3,0\nend_of_record\n\
134 SF:src/b.rs\nDA:1,1\nend_of_record";
135
136 let mut merged = parse(shard_a).unwrap();
137 merged.merge(parse(shard_b).unwrap());
138 let concatenated = parse(&format!("{shard_a}\n{shard_b}")).unwrap();
139
140 assert_eq!(concatenated, merged);
141 assert_eq!(concatenated.hits("src/a.rs", 1), Some(3));
142 assert_eq!(concatenated.hits("src/a.rs", 2), Some(2));
143 assert_eq!(concatenated.hits("src/a.rs", 3), Some(0));
144 }
145
146 #[test]
151 fn a_glued_end_of_record_does_not_lose_the_next_file() {
152 let lcov = "SF:src/a.rs\nDA:1,1\nend_of_recordSF:src/b.rs\nDA:1,0\nDA:2,3\nend_of_record";
153 let report = parse(lcov).unwrap();
154 assert_eq!(report.files.len(), 2);
155 assert_eq!(report.hits("src/a.rs", 1), Some(1));
156 assert_eq!(report.hits("src/a.rs", 2), None);
157 assert_eq!(report.hits("src/b.rs", 1), Some(0));
158 assert_eq!(report.hits("src/b.rs", 2), Some(3));
159 }
160
161 #[test]
162 fn plainly_concatenated_shards_parse_as_their_merge() {
163 let shard_a =
164 "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record";
165 let shard_b =
166 "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/c.rs\nDA:1,1\nend_of_record";
167
168 let mut merged = parse(shard_a).unwrap();
169 merged.merge(parse(shard_b).unwrap());
170 let catted = parse(&format!("{shard_a}{shard_b}")).unwrap();
172
173 assert_eq!(catted, merged);
174 assert_eq!(catted.hits("src/c.rs", 1), Some(1));
175 }
176
177 #[test]
178 fn a_block_without_end_of_record_is_kept_when_another_file_follows() {
179 let lcov = "SF:src/a.rs\nDA:1,1\nSF:src/b.rs\nDA:1,0\nend_of_record\n";
180 let report = parse(lcov).unwrap();
181 assert_eq!(report.hits("src/a.rs", 1), Some(1));
182 assert_eq!(report.hits("src/b.rs", 1), Some(0));
183 }
184
185 #[test]
186 fn ignores_branch_and_function_records() {
187 let lcov = "\
188SF:src/a.rs
189FN:1,foo
190FNDA:3,foo
191DA:1,3
192BRDA:1,0,0,1
193end_of_record
194";
195 let report = parse(lcov).unwrap();
196 let f = &report.files["src/a.rs"];
197 assert_eq!(f.lines.len(), 1);
198 assert_eq!(f.lines.get(&1), Some(&3));
199 }
200
201 #[test]
202 fn da_with_checksum() {
203 let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
204 let report = parse(lcov).unwrap();
205 assert_eq!(report.hits("src/a.rs", 1), Some(4));
206 }
207
208 #[test]
209 fn tolerates_missing_end_of_record() {
210 let lcov = "SF:src/a.rs\nDA:1,1\n";
211 let report = parse(lcov).unwrap();
212 assert_eq!(report.hits("src/a.rs", 1), Some(1));
213 }
214
215 #[test]
216 fn da_outside_sf_is_error() {
217 let lcov = "DA:1,1\n";
218 assert!(parse(lcov).is_err());
219 }
220
221 #[test]
222 fn skips_blank_lines() {
223 let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
224 let report = parse(lcov).unwrap();
225 assert_eq!(report.hits("a.rs", 1), Some(1));
226 }
227
228 #[test]
229 fn negative_hit_count_saturates_to_zero() {
230 let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
231 assert_eq!(report.hits("a.rs", 1), Some(0));
232 }
233}