1use anyhow::{Context, Result};
22use lcov::{Reader, Record};
23use std::collections::{BTreeMap, HashMap};
24use std::path::{Path, PathBuf};
25
26#[derive(Debug, Default, Clone)]
34pub struct FileCoverage {
35 pub lines: BTreeMap<u32, u64>,
37}
38
39impl FileCoverage {
40 #[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
64pub 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 *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 #[test]
123 fn parse_lcov_reads_correct_file_and_hit_counts() {
124 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 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 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 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", "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 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 assert_eq!(fc.coverage_in_span(10, 10), 100.0);
228 }
229}