1use std::fmt::Write as _;
11
12use anyhow::{bail, Context, Result};
13
14use super::model::{CoverageReport, FileCoverage};
15
16pub fn parse(content: &str) -> Result<CoverageReport> {
18 let mut report = CoverageReport::new();
19 let mut current: Option<FileCoverage> = None;
20
21 for (lineno, raw) in content.lines().enumerate() {
22 let mut line = raw.trim();
23
24 while let Some(rest) = line.strip_prefix("end_of_record") {
29 if let Some(file) = current.take() {
30 report.insert(file);
31 }
32 line = rest.trim_start();
33 }
34 if line.is_empty() {
35 continue;
36 }
37
38 if let Some(path) = line.strip_prefix("SF:") {
39 if let Some(unfinished) = current.replace(FileCoverage::new(path.trim())) {
42 report.insert(unfinished);
43 }
44 } else if let Some(rest) = line.strip_prefix("DA:") {
45 let file = current.as_mut().with_context(|| {
46 format!("lcov line {}: DA record outside of an SF block", lineno + 1)
47 })?;
48 let (number, hits) = parse_da(rest)
49 .with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
50 file.record(number, hits);
51 }
52 }
54
55 if let Some(file) = current.take() {
57 report.insert(file);
58 }
59
60 Ok(report)
61}
62
63pub fn write(report: &CoverageReport) -> Result<String> {
88 let mut out = String::new();
89 for (path, file) in &report.files {
90 if path.contains(['\n', '\r']) || path.trim() != path {
91 bail!(
92 "cannot write {path:?} to lcov: a path with a line break or surrounding \
93 whitespace would read back as a different path"
94 );
95 }
96 let _ = writeln!(out, "TN:\nSF:{path}");
98 for (line, hits) in &file.lines {
99 let _ = writeln!(out, "DA:{line},{hits}");
100 }
101 let _ = writeln!(
102 out,
103 "LF:{}\nLH:{}\nend_of_record",
104 file.total_lines(),
105 file.covered_lines()
106 );
107 }
108 Ok(out)
109}
110
111fn parse_da(rest: &str) -> Result<(u32, u64)> {
113 let mut parts = rest.split(',');
114 let number: u32 = parts
115 .next()
116 .context("missing line number")?
117 .trim()
118 .parse()
119 .context("invalid line number")?;
120 let hits: u64 = parts
123 .next()
124 .context("missing hit count")?
125 .trim()
126 .parse()
127 .unwrap_or(0);
128 Ok((number, hits))
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used, clippy::expect_used)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn parses_single_file() {
138 let lcov = "\
139TN:
140SF:/repo/src/a.rs
141DA:1,5
142DA:2,0
143DA:3,1
144LF:3
145LH:2
146end_of_record
147";
148 let report = parse(lcov).unwrap();
149 assert_eq!(report.files.len(), 1);
150 assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
151 assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
152 assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
153 assert_eq!(report.total_lines(), 3);
154 assert_eq!(report.covered_lines(), 2);
155 }
156
157 #[test]
158 fn parses_multiple_files() {
159 let lcov = "\
160SF:src/a.rs
161DA:1,1
162end_of_record
163SF:src/b.rs
164DA:1,0
165DA:2,2
166end_of_record
167";
168 let report = parse(lcov).unwrap();
169 assert_eq!(report.files.len(), 2);
170 assert_eq!(report.hits("src/a.rs", 1), Some(1));
171 assert_eq!(report.hits("src/b.rs", 2), Some(2));
172 }
173
174 #[test]
182 fn newline_joined_shards_parse_as_their_merge() {
183 let shard_a = "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record";
184 let shard_b = "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nDA:3,0\nend_of_record\n\
185 SF:src/b.rs\nDA:1,1\nend_of_record";
186
187 let mut merged = parse(shard_a).unwrap();
188 merged.merge(parse(shard_b).unwrap());
189 let concatenated = parse(&format!("{shard_a}\n{shard_b}")).unwrap();
190
191 assert_eq!(concatenated, merged);
192 assert_eq!(concatenated.hits("src/a.rs", 1), Some(3));
193 assert_eq!(concatenated.hits("src/a.rs", 2), Some(2));
194 assert_eq!(concatenated.hits("src/a.rs", 3), Some(0));
195 }
196
197 #[test]
202 fn a_glued_end_of_record_does_not_lose_the_next_file() {
203 let lcov = "SF:src/a.rs\nDA:1,1\nend_of_recordSF:src/b.rs\nDA:1,0\nDA:2,3\nend_of_record";
204 let report = parse(lcov).unwrap();
205 assert_eq!(report.files.len(), 2);
206 assert_eq!(report.hits("src/a.rs", 1), Some(1));
207 assert_eq!(report.hits("src/a.rs", 2), None);
208 assert_eq!(report.hits("src/b.rs", 1), Some(0));
209 assert_eq!(report.hits("src/b.rs", 2), Some(3));
210 }
211
212 #[test]
213 fn plainly_concatenated_shards_parse_as_their_merge() {
214 let shard_a =
215 "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record";
216 let shard_b =
217 "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/c.rs\nDA:1,1\nend_of_record";
218
219 let mut merged = parse(shard_a).unwrap();
220 merged.merge(parse(shard_b).unwrap());
221 let catted = parse(&format!("{shard_a}{shard_b}")).unwrap();
223
224 assert_eq!(catted, merged);
225 assert_eq!(catted.hits("src/c.rs", 1), Some(1));
226 }
227
228 #[test]
229 fn a_block_without_end_of_record_is_kept_when_another_file_follows() {
230 let lcov = "SF:src/a.rs\nDA:1,1\nSF:src/b.rs\nDA:1,0\nend_of_record\n";
231 let report = parse(lcov).unwrap();
232 assert_eq!(report.hits("src/a.rs", 1), Some(1));
233 assert_eq!(report.hits("src/b.rs", 1), Some(0));
234 }
235
236 #[test]
237 fn ignores_branch_and_function_records() {
238 let lcov = "\
239SF:src/a.rs
240FN:1,foo
241FNDA:3,foo
242DA:1,3
243BRDA:1,0,0,1
244end_of_record
245";
246 let report = parse(lcov).unwrap();
247 let f = &report.files["src/a.rs"];
248 assert_eq!(f.lines.len(), 1);
249 assert_eq!(f.lines.get(&1), Some(&3));
250 }
251
252 #[test]
253 fn da_with_checksum() {
254 let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
255 let report = parse(lcov).unwrap();
256 assert_eq!(report.hits("src/a.rs", 1), Some(4));
257 }
258
259 #[test]
260 fn tolerates_missing_end_of_record() {
261 let lcov = "SF:src/a.rs\nDA:1,1\n";
262 let report = parse(lcov).unwrap();
263 assert_eq!(report.hits("src/a.rs", 1), Some(1));
264 }
265
266 #[test]
267 fn da_outside_sf_is_error() {
268 let lcov = "DA:1,1\n";
269 assert!(parse(lcov).is_err());
270 }
271
272 #[test]
273 fn skips_blank_lines() {
274 let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
275 let report = parse(lcov).unwrap();
276 assert_eq!(report.hits("a.rs", 1), Some(1));
277 }
278
279 #[test]
280 fn negative_hit_count_saturates_to_zero() {
281 let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
282 assert_eq!(report.hits("a.rs", 1), Some(0));
283 }
284
285 fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
288 let mut report = CoverageReport::new();
289 for (path, lines) in files {
290 let mut file = FileCoverage::new(*path);
291 for &(line, hits) in *lines {
292 file.record(line, hits);
293 }
294 report.insert(file);
295 }
296 report
297 }
298
299 #[test]
300 fn write_emits_one_sorted_record_per_file() {
301 let report = report(&[("src/b.rs", &[(2, 0), (1, 3)]), ("src/a.rs", &[(5, 1)])]);
303 assert_eq!(
304 write(&report).unwrap(),
305 "TN:\nSF:src/a.rs\nDA:5,1\nLF:1\nLH:1\nend_of_record\n\
306 TN:\nSF:src/b.rs\nDA:1,3\nDA:2,0\nLF:2\nLH:1\nend_of_record\n"
307 );
308 }
309
310 #[test]
313 fn write_ends_every_record_with_a_newline() {
314 let text = write(&report(&[("src/a.rs", &[(1, 1)])])).unwrap();
315 assert!(text.ends_with("end_of_record\n"), "{text:?}");
316 }
317
318 #[test]
319 fn write_of_an_empty_report_is_empty() {
320 assert_eq!(write(&CoverageReport::new()).unwrap(), "");
321 }
322
323 #[test]
324 fn write_keeps_a_file_with_no_executable_lines_as_an_empty_record() {
325 let report = report(&[("src/empty.rs", &[]), ("src/a.rs", &[(1, 0)])]);
326 let text = write(&report).unwrap();
327 assert!(
328 text.contains("SF:src/empty.rs\nLF:0\nLH:0\nend_of_record\n"),
329 "{text}"
330 );
331 assert_eq!(parse(&text).unwrap(), report);
332 }
333
334 #[test]
335 fn write_reads_back_as_the_same_report() {
336 let original = report(&[
337 ("src/a.rs", &[(1, 0), (2, 7), (40, 1)]),
338 ("/abs/b.rs", &[(3, 2)]),
339 ("src/empty.rs", &[]),
340 ]);
341 assert_eq!(parse(&write(&original).unwrap()).unwrap(), original);
342 }
343
344 #[test]
347 fn write_counts_lf_and_lh_from_its_own_da_records() {
348 let text = write(&report(&[("a.rs", &[(1, 0), (2, 5), (3, 9)])])).unwrap();
349 assert!(text.contains("\nLF:3\nLH:2\n"), "{text}");
350 }
351
352 #[test]
353 fn write_is_independent_of_the_order_shards_were_merged_in() {
354 let shards = [
355 "SF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record",
356 "SF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/b.rs\nDA:9,1\nend_of_record",
357 "SF:src/c.rs\nDA:1,4\nDA:7,0\nend_of_record",
358 ];
359 let merged_in = |order: [usize; 3]| {
360 let mut merged = CoverageReport::new();
361 for index in order {
362 merged.merge(parse(shards[index]).unwrap());
363 }
364 write(&merged).unwrap()
365 };
366 let reference = merged_in([0, 1, 2]);
367 for order in [[0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
368 assert_eq!(merged_in(order), reference, "{order:?}");
369 }
370 }
371
372 #[test]
373 fn write_rejects_a_path_that_would_not_read_back() {
374 for path in [
375 "src/a\nSF:evil.rs",
376 "src/a\rb.rs",
377 " src/a.rs",
378 "src/a.rs\t",
379 ] {
380 let message = write(&report(&[(path, &[(1, 1)])]))
381 .unwrap_err()
382 .to_string();
383 assert!(
384 message.contains("read back as a different path"),
385 "{message}"
386 );
387 }
388 }
389}