patchcov 0.4.0

Patch coverage for git diffs: attribute lcov, llvm-cov, Cobertura, Go and JaCoCo reports to a diff and gate on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! lcov trace-file parser and line-only writer.
//!
//! lcov records one source file per `SF:`…`end_of_record` block. Within a block,
//! `DA:<line>,<hits>[,<checksum>]` gives the hit count for an instrumented line.
//! The default parser ignores branch (`BRDA`) and function (`FN*`) records.
//! [`parse_with_branches`] retains branch outcomes for opt-in diff analysis.
//!
//! Reference: <https://manpages.debian.org/unstable/lcov/geninfo.1.en.html>

use std::fmt::Write as _;

use anyhow::{bail, Context, Result};

use super::model::{CoverageReport, FileCoverage};

/// Parses lcov trace text into a [`CoverageReport`].
pub fn parse(content: &str) -> Result<CoverageReport> {
    parse_impl(content, false)
}

/// Parses line hits and BRDA outcomes for opt-in branch-aware diff analysis.
/// Unknown (`-`) execution counts are treated as missed branches.
pub fn parse_with_branches(content: &str) -> Result<CoverageReport> {
    parse_impl(content, true)
}

fn parse_impl(content: &str, branch_coverage: bool) -> Result<CoverageReport> {
    let mut report = CoverageReport::new();
    let mut current: Option<FileCoverage> = None;

    for (lineno, raw) in content.lines().enumerate() {
        let mut line = raw.trim();

        // `cargo llvm-cov` writes no newline after its final `end_of_record`, so
        // two reports concatenated with `cat` put the next file's `SF:` on the
        // same line. What follows the terminator is an ordinary record, not
        // noise: dropping it loses a whole file's coverage without an error.
        while let Some(rest) = line.strip_prefix("end_of_record") {
            if let Some(file) = current.take() {
                report.insert(file);
            }
            line = rest.trim_start();
        }
        if line.is_empty() {
            continue;
        }

        if let Some(path) = line.strip_prefix("SF:") {
            // A block that never saw its `end_of_record` (a truncated report
            // followed by another) is kept, not silently replaced.
            if let Some(unfinished) = current.replace(FileCoverage::new(path.trim())) {
                report.insert(unfinished);
            }
        } else if let Some(rest) = line.strip_prefix("DA:") {
            let file = current.as_mut().with_context(|| {
                format!("lcov line {}: DA record outside of an SF block", lineno + 1)
            })?;
            let (number, hits) = parse_da(rest)
                .with_context(|| format!("lcov line {}: malformed DA record", lineno + 1))?;
            file.record(number, hits);
        } else if branch_coverage && line.starts_with("BRDA:") {
            let file = current
                .as_mut()
                .context("BRDA record outside of an SF block")?;
            let parts: Vec<_> = line[5..].split(',').map(str::trim).collect();
            anyhow::ensure!(
                parts.len() == 4 && !parts[1].is_empty() && !parts[2].is_empty(),
                "lcov line {}: malformed BRDA record",
                lineno + 1
            );
            let number: u32 = parts[0].parse().context("invalid BRDA line number")?;
            let covered = if parts[3] == "-" {
                false
            } else {
                parts[3]
                    .parse::<u64>()
                    .context("invalid BRDA execution count")?
                    > 0
            };
            file.branches
                .entry((number, parts[1].to_string(), parts[2].to_string()))
                .and_modify(|v| *v |= covered)
                .or_insert(covered);
        }
        // All other records (TN, BRDA, FN, FNDA, LF, LH, …) are ignored.
    }

    // Tolerate a trailing block with no explicit end_of_record.
    if let Some(file) = current.take() {
        report.insert(file);
    }

    Ok(report)
}

/// Renders `report` as lcov trace text.
///
/// The output is a function of the report alone: files come out in path order
/// and each file's `DA` records in line order, so two reports that compare equal
/// write byte-identical text, whatever order their inputs were merged in. Every
/// record ends in a newline — the last `end_of_record` included, which
/// `cargo llvm-cov` omits and which makes its files unsafe to join with `cat`.
///
/// Only what the line model holds is written: `TN`, `SF`, `DA`, `LF`, `LH` and
/// `end_of_record`. Function (`FN*`) and branch (`BRDA`) records are dropped,
/// including any branch evidence retained by [`parse_with_branches`].
/// `LF`/`LH` count the `DA` records written, so a
/// file is consistent with itself; `llvm-cov` writes them from its own summary
/// instead, which counts some lines more than once.
///
/// A file with no executable lines is written as an empty record (`LF:0`,
/// `LH:0`), the way [`parse`] reads one: leaving it out would make the text read
/// back as a different report, and a consumer that ranks a missing file against
/// an empty one would answer differently.
///
/// # Errors
///
/// Fails when a path has a line break or leading or trailing whitespace. lcov has
/// no way to carry either — [`parse`] trims the `SF:` value and splits on lines —
/// so the text would read back keyed under a different path.
pub fn write(report: &CoverageReport) -> Result<String> {
    let mut out = String::new();
    for (path, file) in &report.files {
        if path.contains(['\n', '\r']) || path.trim() != path {
            bail!(
                "cannot write {path:?} to lcov: a path with a line break or surrounding \
                 whitespace would read back as a different path"
            );
        }
        // Writing to a `String` cannot fail.
        let _ = writeln!(out, "TN:\nSF:{path}");
        for (line, hits) in &file.lines {
            let _ = writeln!(out, "DA:{line},{hits}");
        }
        let _ = writeln!(
            out,
            "LF:{}\nLH:{}\nend_of_record",
            file.total_lines(),
            file.covered_lines()
        );
    }
    Ok(out)
}

/// Parses the payload of a `DA:` record (`<line>,<hits>[,<checksum>]`).
fn parse_da(rest: &str) -> Result<(u32, u64)> {
    let mut parts = rest.split(',');
    let number: u32 = parts
        .next()
        .context("missing line number")?
        .trim()
        .parse()
        .context("invalid line number")?;
    // Hit counts can overflow i64 in pathological cases; lcov emits them as
    // decimal integers. Saturate negative or out-of-range values to 0.
    let hits: u64 = parts
        .next()
        .context("missing hit count")?
        .trim()
        .parse()
        .unwrap_or(0);
    Ok((number, hits))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn parses_single_file() {
        let lcov = "\
TN:
SF:/repo/src/a.rs
DA:1,5
DA:2,0
DA:3,1
LF:3
LH:2
end_of_record
";
        let report = parse(lcov).unwrap();
        assert_eq!(report.files.len(), 1);
        assert_eq!(report.hits("/repo/src/a.rs", 1), Some(5));
        assert_eq!(report.hits("/repo/src/a.rs", 2), Some(0));
        assert_eq!(report.hits("/repo/src/a.rs", 3), Some(1));
        assert_eq!(report.total_lines(), 3);
        assert_eq!(report.covered_lines(), 2);
    }

    #[test]
    fn parses_multiple_files() {
        let lcov = "\
SF:src/a.rs
DA:1,1
end_of_record
SF:src/b.rs
DA:1,0
DA:2,2
end_of_record
";
        let report = parse(lcov).unwrap();
        assert_eq!(report.files.len(), 2);
        assert_eq!(report.hits("src/a.rs", 1), Some(1));
        assert_eq!(report.hits("src/b.rs", 2), Some(2));
    }

    /// Shard files joined with a newline parse as the same union
    /// `CoverageReport::merge` builds. `patchcov merge` is the supported way to a
    /// single file, but a caller that joined shards by hand before it existed is
    /// still read correctly, so that has to stay true.
    ///
    /// The shards end like real `cargo llvm-cov` output — **no trailing newline**
    /// after the last `end_of_record`, which is why the join needs its own.
    #[test]
    fn newline_joined_shards_parse_as_their_merge() {
        let shard_a = "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record";
        let shard_b = "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nDA:3,0\nend_of_record\n\
                       SF:src/b.rs\nDA:1,1\nend_of_record";

        let mut merged = parse(shard_a).unwrap();
        merged.merge(parse(shard_b).unwrap());
        let concatenated = parse(&format!("{shard_a}\n{shard_b}")).unwrap();

        assert_eq!(concatenated, merged);
        assert_eq!(concatenated.hits("src/a.rs", 1), Some(3));
        assert_eq!(concatenated.hits("src/a.rs", 2), Some(2));
        assert_eq!(concatenated.hits("src/a.rs", 3), Some(0));
    }

    /// Plain `cat` of real `cargo llvm-cov` shards (no trailing newline) glues
    /// `end_of_record` onto the next `SF:`. Both files must survive with their
    /// own lines: before this was handled the last file of each shard vanished
    /// and the next file's lines landed on it, understating coverage silently.
    #[test]
    fn a_glued_end_of_record_does_not_lose_the_next_file() {
        let lcov = "SF:src/a.rs\nDA:1,1\nend_of_recordSF:src/b.rs\nDA:1,0\nDA:2,3\nend_of_record";
        let report = parse(lcov).unwrap();
        assert_eq!(report.files.len(), 2);
        assert_eq!(report.hits("src/a.rs", 1), Some(1));
        assert_eq!(report.hits("src/a.rs", 2), None);
        assert_eq!(report.hits("src/b.rs", 1), Some(0));
        assert_eq!(report.hits("src/b.rs", 2), Some(3));
    }

    #[test]
    fn plainly_concatenated_shards_parse_as_their_merge() {
        let shard_a =
            "TN:\nSF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record";
        let shard_b =
            "TN:\nSF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/c.rs\nDA:1,1\nend_of_record";

        let mut merged = parse(shard_a).unwrap();
        merged.merge(parse(shard_b).unwrap());
        // No separator at all: exactly what `cat shard-*.lcov` produces.
        let catted = parse(&format!("{shard_a}{shard_b}")).unwrap();

        assert_eq!(catted, merged);
        assert_eq!(catted.hits("src/c.rs", 1), Some(1));
    }

    #[test]
    fn a_block_without_end_of_record_is_kept_when_another_file_follows() {
        let lcov = "SF:src/a.rs\nDA:1,1\nSF:src/b.rs\nDA:1,0\nend_of_record\n";
        let report = parse(lcov).unwrap();
        assert_eq!(report.hits("src/a.rs", 1), Some(1));
        assert_eq!(report.hits("src/b.rs", 1), Some(0));
    }

    #[test]
    fn ignores_branch_and_function_records() {
        let lcov = "\
SF:src/a.rs
FN:1,foo
FNDA:3,foo
DA:1,3
BRDA:1,0,0,1
end_of_record
";
        let report = parse(lcov).unwrap();
        let f = &report.files["src/a.rs"];
        assert_eq!(f.lines.len(), 1);
        assert_eq!(f.lines.get(&1), Some(&3));
    }

    #[test]
    fn da_with_checksum() {
        let lcov = "SF:src/a.rs\nDA:1,4,abcdef\nend_of_record\n";
        let report = parse(lcov).unwrap();
        assert_eq!(report.hits("src/a.rs", 1), Some(4));
    }

    #[test]
    fn tolerates_missing_end_of_record() {
        let lcov = "SF:src/a.rs\nDA:1,1\n";
        let report = parse(lcov).unwrap();
        assert_eq!(report.hits("src/a.rs", 1), Some(1));
    }

    #[test]
    fn da_outside_sf_is_error() {
        let lcov = "DA:1,1\n";
        assert!(parse(lcov).is_err());
    }

    #[test]
    fn skips_blank_lines() {
        let lcov = "\nSF:a.rs\n\nDA:1,1\n\nend_of_record\n\n";
        let report = parse(lcov).unwrap();
        assert_eq!(report.hits("a.rs", 1), Some(1));
    }

    #[test]
    fn negative_hit_count_saturates_to_zero() {
        let report = parse("SF:a.rs\nDA:1,-5\nend_of_record\n").unwrap();
        assert_eq!(report.hits("a.rs", 1), Some(0));
    }

    // ── write ────────────────────────────────────────────────────

    fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
        let mut report = CoverageReport::new();
        for (path, lines) in files {
            let mut file = FileCoverage::new(*path);
            for &(line, hits) in *lines {
                file.record(line, hits);
            }
            report.insert(file);
        }
        report
    }

    #[test]
    fn write_emits_one_sorted_record_per_file() {
        // Inserted out of order on purpose: the output must not depend on it.
        let report = report(&[("src/b.rs", &[(2, 0), (1, 3)]), ("src/a.rs", &[(5, 1)])]);
        assert_eq!(
            write(&report).unwrap(),
            "TN:\nSF:src/a.rs\nDA:5,1\nLF:1\nLH:1\nend_of_record\n\
             TN:\nSF:src/b.rs\nDA:1,3\nDA:2,0\nLF:2\nLH:1\nend_of_record\n"
        );
    }

    /// `cargo llvm-cov` omits the newline after its last `end_of_record`, so a
    /// file that stops there cannot be concatenated safely. Ours must not.
    #[test]
    fn write_ends_every_record_with_a_newline() {
        let text = write(&report(&[("src/a.rs", &[(1, 1)])])).unwrap();
        assert!(text.ends_with("end_of_record\n"), "{text:?}");
    }

    #[test]
    fn write_of_an_empty_report_is_empty() {
        assert_eq!(write(&CoverageReport::new()).unwrap(), "");
    }

    #[test]
    fn write_keeps_a_file_with_no_executable_lines_as_an_empty_record() {
        let report = report(&[("src/empty.rs", &[]), ("src/a.rs", &[(1, 0)])]);
        let text = write(&report).unwrap();
        assert!(
            text.contains("SF:src/empty.rs\nLF:0\nLH:0\nend_of_record\n"),
            "{text}"
        );
        assert_eq!(parse(&text).unwrap(), report);
    }

    #[test]
    fn write_reads_back_as_the_same_report() {
        let original = report(&[
            ("src/a.rs", &[(1, 0), (2, 7), (40, 1)]),
            ("/abs/b.rs", &[(3, 2)]),
            ("src/empty.rs", &[]),
        ]);
        assert_eq!(parse(&write(&original).unwrap()).unwrap(), original);
    }

    /// `LF`/`LH` are written from the `DA` records, so the totals agree with the
    /// lines listed — unlike `llvm-cov`'s own lcov.
    #[test]
    fn write_counts_lf_and_lh_from_its_own_da_records() {
        let text = write(&report(&[("a.rs", &[(1, 0), (2, 5), (3, 9)])])).unwrap();
        assert!(text.contains("\nLF:3\nLH:2\n"), "{text}");
    }

    #[test]
    fn write_is_independent_of_the_order_shards_were_merged_in() {
        let shards = [
            "SF:src/a.rs\nDA:1,3\nDA:2,0\nend_of_record\nSF:src/c.rs\nDA:1,0\nend_of_record",
            "SF:src/a.rs\nDA:1,0\nDA:2,2\nend_of_record\nSF:src/b.rs\nDA:9,1\nend_of_record",
            "SF:src/c.rs\nDA:1,4\nDA:7,0\nend_of_record",
        ];
        let merged_in = |order: [usize; 3]| {
            let mut merged = CoverageReport::new();
            for index in order {
                merged.merge(parse(shards[index]).unwrap());
            }
            write(&merged).unwrap()
        };
        let reference = merged_in([0, 1, 2]);
        for order in [[0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
            assert_eq!(merged_in(order), reference, "{order:?}");
        }
    }

    #[test]
    fn write_rejects_a_path_that_would_not_read_back() {
        for path in [
            "src/a\nSF:evil.rs",
            "src/a\rb.rs",
            " src/a.rs",
            "src/a.rs\t",
        ] {
            let message = write(&report(&[(path, &[(1, 1)])]))
                .unwrap_err()
                .to_string();
            assert!(
                message.contains("read back as a different path"),
                "{message}"
            );
        }
    }
    #[test]
    fn branch_mode_scores_outcomes_and_unions_duplicate_identities() {
        let text = "SF:a.rs\nDA:1,3\nDA:2,1\nDA:3,1\nDA:4,1\nBRDA:1,0,0,2\nBRDA:1,0,1,0\nBRDA:2,0,0,-\nBRDA:3,0,0,0\nBRDA:3,0,0,1\nend_of_record";
        let mut report = parse_with_branches(text).unwrap();
        assert_eq!(report.hits("a.rs", 1), Some(3));
        report.apply_branch_coverage();
        assert_eq!(report.hits("a.rs", 1), Some(0));
        assert_eq!(report.hits("a.rs", 2), Some(0));
        assert_eq!(report.hits("a.rs", 3), Some(1));
        assert_eq!(report.hits("a.rs", 4), Some(1));
        assert_eq!(parse(text).unwrap().covered_lines(), 4);
    }

    #[test]
    fn malformed_branches_fail_only_in_branch_mode() {
        for record in ["BRDA:1,0,0", "BRDA:x,0,0,1", "BRDA:1,0,0,x", "BRDA:1,,0,1"] {
            let text = format!("SF:a.rs\nDA:1,1\n{record}\nend_of_record");
            assert!(parse_with_branches(&text).is_err(), "{record}");
            assert!(parse(&text).is_ok());
        }
        assert!(parse_with_branches("BRDA:1,0,0,1").is_err());
    }
}