1use 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#[derive(Debug, Default, Clone)]
36pub struct FileCoverage {
37 pub lines: BTreeMap<u32, u64>,
39}
40
41impl FileCoverage {
42 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 #[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
82pub 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 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 *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 #[test]
164 fn parse_lcov_reads_correct_file_and_hit_counts() {
165 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 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 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 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", "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 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 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 assert_eq!(fc.coverage_in_span(10, 10), 100.0);
289 }
290}