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, Record};
23use std::collections::{BTreeMap, HashMap};
24use std::path::{Path, PathBuf};
25
26/// Per-file coverage, indexed by line number.
27///
28/// Only lines that appear in a `DA` record are tracked — these are the
29/// "executable" lines per LLVM's coverage mapping. Blank lines, comments,
30/// and purely declarative lines (use statements, struct definitions) do
31/// not appear here, and we treat them as "not applicable" rather than
32/// "uncovered".
33#[derive(Debug, Default, Clone)]
34pub struct FileCoverage {
35    /// Line number (1-indexed) → hit count.
36    pub lines: BTreeMap<u32, u64>,
37}
38
39impl FileCoverage {
40    /// Percentage of executable lines in `[start..=end]` that were hit at
41    /// least once.
42    ///
43    /// Returns 100.0 if no executable lines fall inside the span. A function
44    /// composed entirely of declarative code (`fn sig() -> Type;`, unreachable
45    /// macro expansions, etc.) genuinely has nothing to cover and should not
46    /// be penalized.
47    #[must_use]
48    pub fn coverage_in_span(
49        &self,
50        start: usize,
51        end: usize,
52    ) -> f64 {
53        let start = start as u32;
54        let end = end as u32;
55        let executable: Vec<_> = self.lines.range(start..=end).collect();
56        if executable.is_empty() {
57            return 100.0;
58        }
59        let covered = executable.iter().filter(|(_, hits)| **hits > 0).count();
60        (covered as f64 / executable.len() as f64) * 100.0
61    }
62}
63
64/// Parse an LCOV file into a map keyed by the source paths it declares.
65///
66/// **Path normalization is deliberately NOT done here.** Paths in LCOV may
67/// be absolute, relative to the CWD at the time coverage was generated, or
68/// relative to the workspace root. The caller is responsible for matching
69/// them against the paths [`crate::complexity`] produces — see
70/// [`crate::merge`].
71pub fn parse_lcov(path: &Path) -> Result<HashMap<PathBuf, FileCoverage>> {
72    let reader =
73        Reader::open_file(path).with_context(|| format!("opening LCOV file {}", path.display()))?;
74
75    let mut files: HashMap<PathBuf, FileCoverage> = HashMap::new();
76    let mut current_path: Option<PathBuf> = None;
77
78    for record in reader {
79        let record = record.with_context(|| format!("parsing record in {}", path.display()))?;
80        match record {
81            Record::SourceFile { path: sf_path } => {
82                current_path = Some(sf_path.clone());
83                files.entry(sf_path).or_default();
84            },
85            Record::LineData { line, count, .. } => {
86                if let Some(ref p) = current_path
87                    && let Some(fc) = files.get_mut(p)
88                {
89                    // LCOV files can legitimately repeat a line in
90                    // different branches; sum the hits.
91                    *fc.lines.entry(line).or_insert(0) += count;
92                }
93            },
94            Record::EndOfRecord => {
95                current_path = None;
96            },
97            _ => {},
98        }
99    }
100
101    Ok(files)
102}
103
104#[cfg(test)]
105#[expect(
106    clippy::float_cmp,
107    reason = "coverage % is computed from integer line counts; exact equality is the right comparison"
108)]
109mod tests {
110    use super::*;
111    use std::io::Write;
112    use std::path::Path;
113
114    fn write_lcov(content: &str) -> tempfile::NamedTempFile {
115        let mut f = tempfile::NamedTempFile::new().expect("tempfile");
116        f.write_all(content.as_bytes()).expect("write");
117        f
118    }
119
120    // --- parse_lcov tests (kill missed mutants) ---
121
122    #[test]
123    fn parse_lcov_reads_correct_file_and_hit_counts() {
124        // Kills: replace parse_lcov with dummy Ok(...), delete LineData arm, += with *=
125        let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,3\nDA:11,0\nend_of_record\n");
126        let result = parse_lcov(f.path()).expect("parse_lcov");
127
128        let cov = result
129            .get(Path::new("src/foo.rs"))
130            .expect("src/foo.rs must be in result");
131        assert_eq!(cov.lines[&10], 3, "line 10 should have 3 hits");
132        assert_eq!(cov.lines[&11], 0, "line 11 should have 0 hits");
133    }
134
135    #[test]
136    fn parse_lcov_accumulates_duplicate_line_entries() {
137        // LCOV can repeat a DA line for different branches on the same line.
138        // Hits must be summed, not overwritten. Kills: += with *=.
139        let f = write_lcov("TN:\nSF:src/foo.rs\nDA:10,2\nDA:10,3\nend_of_record\n");
140        let result = parse_lcov(f.path()).expect("parse_lcov");
141        assert_eq!(
142            result[Path::new("src/foo.rs")].lines[&10],
143            5,
144            "duplicate DA entries must be summed"
145        );
146    }
147
148    #[test]
149    fn parse_lcov_isolates_multiple_source_files() {
150        // Kills: delete EndOfRecord arm (if current_path leaks, lines can bleed).
151        // Lines from src/a.rs must never appear under src/b.rs.
152        let f = write_lcov(
153            "TN:\nSF:src/a.rs\nDA:1,1\nend_of_record\nSF:src/b.rs\nDA:2,4\nend_of_record\n",
154        );
155        let result = parse_lcov(f.path()).expect("parse_lcov");
156
157        let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
158        let b = result.get(Path::new("src/b.rs")).expect("b.rs in result");
159
160        assert_eq!(a.lines[&1], 1);
161        assert_eq!(b.lines[&2], 4);
162        assert!(
163            !b.lines.contains_key(&1),
164            "line 1 from a.rs must not bleed into b.rs"
165        );
166        assert!(
167            !a.lines.contains_key(&2),
168            "line 2 from b.rs must not bleed into a.rs"
169        );
170    }
171
172    #[test]
173    fn stray_da_after_end_of_record_is_not_attributed_to_previous_file() {
174        // Kills: delete match arm Record::EndOfRecord in parse_lcov.
175        // If EndOfRecord does not clear current_path, a stray DA line between
176        // records would be attributed to the previous file.
177        let f = write_lcov(concat!(
178            "TN:\n",
179            "SF:src/a.rs\n",
180            "DA:1,1\n",
181            "end_of_record\n",
182            "DA:99,99\n", // stray line — must be silently dropped
183            "SF:src/b.rs\n",
184            "DA:2,4\n",
185            "end_of_record\n",
186        ));
187        let result = parse_lcov(f.path()).expect("parse_lcov");
188
189        let a = result.get(Path::new("src/a.rs")).expect("a.rs in result");
190        assert!(
191            !a.lines.contains_key(&99),
192            "stray DA:99,99 must not bleed into src/a.rs (EndOfRecord not resetting current_path)"
193        );
194    }
195
196    fn fc_from(lines: &[(u32, u64)]) -> FileCoverage {
197        FileCoverage {
198            lines: lines.iter().copied().collect(),
199        }
200    }
201
202    #[test]
203    fn empty_span_yields_full_coverage() {
204        // If the AST says "function is at lines 10..=20" but LCOV has no
205        // executable lines in that range, it's a declarative function —
206        // not 0% covered, it's "nothing to cover".
207        let fc = fc_from(&[(5, 1), (25, 1)]);
208        assert_eq!(fc.coverage_in_span(10, 20), 100.0);
209    }
210
211    #[test]
212    fn all_executable_lines_hit_is_100_percent() {
213        let fc = fc_from(&[(10, 3), (11, 3), (12, 1)]);
214        assert_eq!(fc.coverage_in_span(10, 12), 100.0);
215    }
216
217    #[test]
218    fn half_hit_is_50_percent() {
219        let fc = fc_from(&[(10, 5), (11, 0), (12, 1), (13, 0)]);
220        assert_eq!(fc.coverage_in_span(10, 13), 50.0);
221    }
222
223    #[test]
224    fn span_is_inclusive_on_both_ends() {
225        let fc = fc_from(&[(5, 1), (10, 1), (15, 1)]);
226        // Only line 10 is inside [10..=10].
227        assert_eq!(fc.coverage_in_span(10, 10), 100.0);
228    }
229}