Skip to main content

cargo_crap/
coverage.rs

1//! Parse LCOV coverage reports into a per-file, per-line hit map.
2//!
3//! LCOV is the common output format for `cargo llvm-cov --lcov` and
4//! `cargo tarpaulin --out Lcov`. A minimal record looks like:
5//!
6//! ```text
7//! SF:src/foo.rs          ← source file
8//! FN:42,foo::bar         ← function at line 42
9//! FNDA:3,foo::bar        ← function hit count
10//! DA:43,7                ← line 43 was executed 7 times
11//! DA:44,0                ← line 44 was reachable but never executed
12//! end_of_record
13//! ```
14//!
15//! We only consume `SF`, `DA`, and `end_of_record`. Function-level records
16//! (`FN`/`FNDA`) are tempting but unreliable: they tell us where a function
17//! *starts* but not where it *ends*, so we can't compute coverage of the
18//! function's body from them. Instead, we intersect the line-level `DA`
19//! records with spans we already have from the AST.
20
21use anyhow::{Context, Result};
22use lcov::reader::Error as LcovReadError;
23use lcov::record::ParseRecordError;
24use lcov::{Reader, Record};
25use std::collections::{BTreeMap, HashMap};
26use std::path::{Path, PathBuf};
27
28/// Per-file coverage, indexed by line number.
29///
30/// Only lines that appear in a `DA` record are tracked — these are the
31/// "executable" lines per LLVM's coverage mapping. Blank lines, comments,
32/// and purely declarative lines (use statements, struct definitions) do
33/// not appear here, and we treat them as "not applicable" rather than
34/// "uncovered".
35#[derive(Debug, Default, Clone)]
36pub struct FileCoverage {
37    /// Line number (1-indexed) → hit count.
38    pub lines: BTreeMap<u32, u64>,
39}
40
41impl FileCoverage {
42    /// Fold another file's line data into this one: union of lines,
43    /// per-line saturating sum of hit counts (spec 26).
44    ///
45    /// This is the aggregation `lcov -a` performs, so multi-leg reports
46    /// pre-merged by lcov and raw aliased reports converge to the same
47    /// data.
48    pub fn merge_from(
49        &mut self,
50        other: &FileCoverage,
51    ) {
52        for (&line, &hits) in &other.lines {
53            let slot = self.lines.entry(line).or_insert(0);
54            *slot = slot.saturating_add(hits);
55        }
56    }
57
58    /// Percentage of executable lines in `[start..=end]` that were hit at
59    /// least once.
60    ///
61    /// Returns 100.0 if no executable lines fall inside the span. A function
62    /// composed entirely of declarative code (`fn sig() -> Type;`, unreachable
63    /// macro expansions, etc.) genuinely has nothing to cover and should not
64    /// be penalized.
65    #[must_use]
66    pub fn coverage_in_span(
67        &self,
68        start: usize,
69        end: usize,
70    ) -> f64 {
71        let start = start as u32;
72        let end = end as u32;
73        let executable: Vec<_> = self.lines.range(start..=end).collect();
74        if executable.is_empty() {
75            return 100.0;
76        }
77        let covered = executable.iter().filter(|(_, hits)| **hits > 0).count();
78        (covered as f64 / executable.len() as f64) * 100.0
79    }
80}
81
82/// Parse an LCOV file into a map keyed by the source paths it declares.
83///
84/// **Path normalization is deliberately NOT done here.** Paths in LCOV may
85/// be absolute, relative to the CWD at the time coverage was generated, or
86/// relative to the workspace root. The caller is responsible for matching
87/// them against the paths [`crate::complexity`] produces — see
88/// [`crate::merge`].
89pub fn parse_lcov(path: &Path) -> Result<HashMap<PathBuf, FileCoverage>> {
90    let reader =
91        Reader::open_file(path).with_context(|| format!("opening LCOV file {}", path.display()))?;
92
93    let mut files: HashMap<PathBuf, FileCoverage> = HashMap::new();
94    let mut current_path: Option<PathBuf> = None;
95
96    for record in reader {
97        let record = match record {
98            Ok(r) => r,
99            // cargo-llvm-cov (and future LCOV versions) may emit record types
100            // the lcov crate doesn't know about. Skip them — we only care about
101            // SF, DA, and end_of_record anyway.
102            Err(LcovReadError::ParseRecord(_, ParseRecordError::UnknownRecord)) => continue,
103            Err(e) => {
104                return Err(
105                    anyhow::Error::new(e).context(format!("parsing record in {}", path.display()))
106                );
107            },
108        };
109        match record {
110            Record::SourceFile { path: sf_path } => {
111                current_path = Some(sf_path.clone());
112                files.entry(sf_path).or_default();
113            },
114            Record::LineData { line, count, .. } => {
115                if let Some(ref p) = current_path
116                    && let Some(fc) = files.get_mut(p)
117                {
118                    // LCOV files can legitimately repeat a line in
119                    // different branches; sum the hits.
120                    *fc.lines.entry(line).or_insert(0) += count;
121                }
122            },
123            Record::EndOfRecord => {
124                current_path = None;
125            },
126            _ => {},
127        }
128    }
129
130    Ok(files)
131}
132
133#[cfg(test)]
134#[expect(
135    clippy::float_cmp,
136    reason = "coverage % is computed from integer line counts; exact equality is the right comparison"
137)]
138mod tests {
139    use super::*;
140    use std::io::Write;
141    use std::path::Path;
142
143    #[test]
144    fn unknown_lcov_records_are_skipped_not_fatal() {
145        let f = write_lcov(
146            "VER:2\nTN:\nSF:src/foo.rs\nDA:10,3\nUNKNOWN_RECORD:whatever\nend_of_record\n",
147        );
148        let result = parse_lcov(f.path()).expect("unknown records must not be fatal");
149        let cov = result
150            .get(Path::new("src/foo.rs"))
151            .expect("src/foo.rs in result");
152        assert_eq!(cov.lines[&10], 3);
153    }
154
155    fn write_lcov(content: &str) -> tempfile::NamedTempFile {
156        let mut f = tempfile::NamedTempFile::new().expect("tempfile");
157        f.write_all(content.as_bytes()).expect("write");
158        f
159    }
160
161    // --- parse_lcov tests (kill missed mutants) ---
162
163    #[test]
164    fn parse_lcov_reads_correct_file_and_hit_counts() {
165        // Kills: replace parse_lcov with dummy Ok(...), delete LineData arm, += with *=
166        let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,3\nDA:11,0\nend_of_record\n");
167        let result = parse_lcov(f.path()).expect("parse_lcov");
168
169        let cov = result
170            .get(Path::new("src/foo.rs"))
171            .expect("src/foo.rs must be in result");
172        assert_eq!(cov.lines[&10], 3, "line 10 should have 3 hits");
173        assert_eq!(cov.lines[&11], 0, "line 11 should have 0 hits");
174    }
175
176    #[test]
177    fn parse_lcov_accumulates_duplicate_line_entries() {
178        // LCOV can repeat a DA line for different branches on the same line.
179        // Hits must be summed, not overwritten. Kills: += with *=.
180        let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,2\nDA:10,3\nend_of_record\n");
181        let result = parse_lcov(f.path()).expect("parse_lcov");
182        assert_eq!(
183            result[Path::new("src/foo.rs")].lines[&10],
184            5,
185            "duplicate DA entries must be summed"
186        );
187    }
188
189    #[test]
190    fn parse_lcov_isolates_multiple_source_files() {
191        // Kills: delete EndOfRecord arm (if current_path leaks, lines can bleed).
192        // Lines from src/a.rs must never appear under src/b.rs.
193        let f = write_lcov(
194            "TN:\nSF:src/a.rs\nDA:1,1\nend_of_record\nSF:src/b.rs\nDA:2,4\nend_of_record\n",
195        );
196        let result = parse_lcov(f.path()).expect("parse_lcov");
197
198        let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
199        let b = result.get(Path::new("src/b.rs")).expect("b.rs in result");
200
201        assert_eq!(a.lines[&1], 1);
202        assert_eq!(b.lines[&2], 4);
203        assert!(
204            !b.lines.contains_key(&1),
205            "line 1 from a.rs must not bleed into b.rs"
206        );
207        assert!(
208            !a.lines.contains_key(&2),
209            "line 2 from b.rs must not bleed into a.rs"
210        );
211    }
212
213    #[test]
214    fn stray_da_after_end_of_record_is_not_attributed_to_previous_file() {
215        // Kills: delete match arm Record::EndOfRecord in parse_lcov.
216        // If EndOfRecord does not clear current_path, a stray DA line between
217        // records would be attributed to the previous file.
218        let f = write_lcov(concat!(
219            "TN:\n",
220            "SF:src/a.rs\n",
221            "DA:1,1\n",
222            "end_of_record\n",
223            "DA:99,99\n", // stray line — must be silently dropped
224            "SF:src/b.rs\n",
225            "DA:2,4\n",
226            "end_of_record\n",
227        ));
228        let result = parse_lcov(f.path()).expect("parse_lcov");
229
230        let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
231        assert!(
232            !a.lines.contains_key(&99),
233            "stray DA:99,99 must not bleed into src/a.rs (EndOfRecord not resetting current_path)"
234        );
235    }
236
237    fn fc_from(lines: &[(u32, u64)]) -> FileCoverage {
238        FileCoverage {
239            lines: lines.iter().copied().collect(),
240        }
241    }
242
243    #[test]
244    fn merge_from_unions_lines_and_sums_hits() {
245        // Spec 26: same aggregation as `lcov -a` — union of line keys,
246        // per-line sum of hit counts (kills swapping + for * or dropping
247        // the overlapping-line case).
248        let mut a = fc_from(&[(1, 2), (3, 0)]);
249        let b = fc_from(&[(1, 3), (2, 1)]);
250        a.merge_from(&b);
251        assert_eq!(a.lines.get(&1), Some(&5), "overlapping line sums: 2 + 3");
252        assert_eq!(a.lines.get(&2), Some(&1), "other-side-only line is added");
253        assert_eq!(a.lines.get(&3), Some(&0), "self-only line is kept");
254    }
255
256    #[test]
257    fn merge_from_saturates_instead_of_overflowing() {
258        let mut a = fc_from(&[(1, u64::MAX)]);
259        a.merge_from(&fc_from(&[(1, 1)]));
260        assert_eq!(a.lines.get(&1), Some(&u64::MAX));
261    }
262
263    #[test]
264    fn empty_span_yields_full_coverage() {
265        // If the AST says "function is at lines 10..=20" but LCOV has no
266        // executable lines in that range, it's a declarative function —
267        // not 0% covered, it's "nothing to cover".
268        let fc = fc_from(&[(5, 1), (25, 1)]);
269        assert_eq!(fc.coverage_in_span(10, 20), 100.0);
270    }
271
272    #[test]
273    fn all_executable_lines_hit_is_100_percent() {
274        let fc = fc_from(&[(10, 3), (11, 3), (12, 1)]);
275        assert_eq!(fc.coverage_in_span(10, 12), 100.0);
276    }
277
278    #[test]
279    fn half_hit_is_50_percent() {
280        let fc = fc_from(&[(10, 5), (11, 0), (12, 1), (13, 0)]);
281        assert_eq!(fc.coverage_in_span(10, 13), 50.0);
282    }
283
284    #[test]
285    fn span_is_inclusive_on_both_ends() {
286        let fc = fc_from(&[(5, 1), (10, 1), (15, 1)]);
287        // Only line 10 is inside [10..=10].
288        assert_eq!(fc.coverage_in_span(10, 10), 100.0);
289    }
290}