1use std::collections::BTreeMap;
10use std::path::Path;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FileCoverage {
19 pub path: String,
21 pub lines: BTreeMap<u32, u64>,
23}
24
25impl FileCoverage {
26 pub fn new(path: impl Into<String>) -> Self {
28 Self {
29 path: path.into(),
30 lines: BTreeMap::new(),
31 }
32 }
33
34 pub fn record(&mut self, line: u32, hits: u64) {
37 self.lines
38 .entry(line)
39 .and_modify(|h| *h = (*h).max(hits))
40 .or_insert(hits);
41 }
42
43 pub fn total_lines(&self) -> u64 {
45 self.lines.len() as u64
46 }
47
48 pub fn covered_lines(&self) -> u64 {
50 self.lines.values().filter(|&&h| h > 0).count() as u64
51 }
52
53 pub fn percent(&self) -> Option<f64> {
55 let total = self.total_lines();
56 if total == 0 {
57 None
58 } else {
59 Some(self.covered_lines() as f64 / total as f64 * 100.0)
60 }
61 }
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct CoverageReport {
67 pub files: BTreeMap<String, FileCoverage>,
69}
70
71impl CoverageReport {
72 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn insert(&mut self, file: FileCoverage) {
83 match self.files.get_mut(&file.path) {
84 Some(existing) => {
85 for (line, hits) in file.lines {
86 existing.record(line, hits);
87 }
88 }
89 None => {
90 self.files.insert(file.path.clone(), file);
91 }
92 }
93 }
94
95 pub fn merge(&mut self, other: Self) {
104 for file in other.files.into_values() {
105 self.insert(file);
106 }
107 }
108
109 pub fn hits(&self, path: &str, line: u32) -> Option<u64> {
112 self.files
113 .get(path)
114 .and_then(|f| f.lines.get(&line).copied())
115 }
116
117 pub fn total_lines(&self) -> u64 {
119 self.files.values().map(FileCoverage::total_lines).sum()
120 }
121
122 pub fn covered_lines(&self) -> u64 {
124 self.files.values().map(FileCoverage::covered_lines).sum()
125 }
126
127 pub fn percent(&self) -> Option<f64> {
130 let total = self.total_lines();
131 if total == 0 {
132 None
133 } else {
134 Some(self.covered_lines() as f64 / total as f64 * 100.0)
135 }
136 }
137
138 pub fn strip_prefix(&mut self, prefix: &Path) {
146 let prefix_str = prefix.to_string_lossy();
147 let prefix_slash = format!("{}/", prefix_str.trim_end_matches('/'));
148 let mut remapped: BTreeMap<String, FileCoverage> = BTreeMap::new();
149 for (_, mut file) in std::mem::take(&mut self.files) {
150 let normalized = normalize_path(&file.path, &prefix_slash);
151 file.path.clone_from(&normalized);
152 match remapped.get_mut(&normalized) {
154 Some(existing) => {
155 for (line, hits) in std::mem::take(&mut file.lines) {
156 existing.record(line, hits);
157 }
158 }
159 None => {
160 remapped.insert(normalized, file);
161 }
162 }
163 }
164 self.files = remapped;
165 }
166
167 pub fn retain_paths<F>(&mut self, keep: F)
174 where
175 F: Fn(&str) -> bool,
176 {
177 self.files.retain(|path, _| keep(path));
178 }
179
180 pub fn retain_lines<F>(&mut self, keep: F)
195 where
196 F: Fn(&str, u32) -> bool,
197 {
198 for (path, file) in &mut self.files {
199 file.lines.retain(|&line, _| keep(path, line));
200 }
201 self.files.retain(|_, file| file.total_lines() > 0);
202 }
203}
204
205fn normalize_path(path: &str, prefix_slash: &str) -> String {
208 let stripped = path.strip_prefix(prefix_slash).unwrap_or(path);
209 stripped
210 .trim_start_matches("./")
211 .trim_start_matches('/')
212 .to_string()
213}
214
215#[cfg(test)]
216#[allow(clippy::unwrap_used, clippy::expect_used)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn record_takes_max_hits() {
222 let mut f = FileCoverage::new("src/a.rs");
223 f.record(1, 0);
224 f.record(1, 3);
225 f.record(1, 1);
226 assert_eq!(f.lines.get(&1), Some(&3));
227 }
228
229 #[test]
230 fn percent_counts_only_executable_lines() {
231 let mut f = FileCoverage::new("src/a.rs");
232 f.record(1, 1);
233 f.record(2, 0);
234 f.record(3, 5);
235 assert_eq!(f.total_lines(), 3);
237 assert_eq!(f.covered_lines(), 2);
238 assert!((f.percent().unwrap() - 66.666_666).abs() < 1e-3);
239 }
240
241 #[test]
242 fn empty_file_has_no_percent() {
243 let f = FileCoverage::new("src/empty.rs");
244 assert_eq!(f.percent(), None);
245 }
246
247 #[test]
248 fn hits_distinguishes_uncovered_from_non_executable() {
249 let mut report = CoverageReport::new();
250 let mut f = FileCoverage::new("src/a.rs");
251 f.record(10, 0); report.insert(f);
253 assert_eq!(report.hits("src/a.rs", 10), Some(0)); assert_eq!(report.hits("src/a.rs", 11), None); assert_eq!(report.hits("src/missing.rs", 1), None);
256 }
257
258 #[test]
259 fn insert_merges_duplicate_paths() {
260 let mut report = CoverageReport::new();
261 let mut a = FileCoverage::new("src/a.rs");
262 a.record(1, 0);
263 let mut b = FileCoverage::new("src/a.rs");
264 b.record(1, 2);
265 b.record(2, 1);
266 report.insert(a);
267 report.insert(b);
268 assert_eq!(report.files.len(), 1);
269 assert_eq!(report.hits("src/a.rs", 1), Some(2));
270 assert_eq!(report.hits("src/a.rs", 2), Some(1));
271 }
272
273 #[test]
274 fn merge_unions_files_and_takes_the_max_per_line() {
275 let mut shard_a = CoverageReport::new();
276 let mut a = FileCoverage::new("src/shared.rs");
277 a.record(1, 3);
278 a.record(2, 0);
279 shard_a.insert(a);
280 let mut only_a = FileCoverage::new("src/only_a.rs");
281 only_a.record(1, 1);
282 shard_a.insert(only_a);
283
284 let mut shard_b = CoverageReport::new();
285 let mut b = FileCoverage::new("src/shared.rs");
286 b.record(1, 0);
287 b.record(2, 7);
288 b.record(3, 0);
289 shard_b.insert(b);
290 let mut only_b = FileCoverage::new("src/only_b.rs");
291 only_b.record(1, 0);
292 shard_b.insert(only_b);
293
294 shard_a.merge(shard_b);
295
296 assert_eq!(shard_a.files.len(), 3);
297 assert_eq!(shard_a.hits("src/shared.rs", 1), Some(3));
299 assert_eq!(shard_a.hits("src/shared.rs", 2), Some(7));
300 assert_eq!(shard_a.hits("src/shared.rs", 3), Some(0));
302 assert_eq!(shard_a.hits("src/only_a.rs", 1), Some(1));
303 assert_eq!(shard_a.hits("src/only_b.rs", 1), Some(0));
304 }
305
306 #[test]
307 fn merge_is_order_independent() {
308 let report = |lines: &[(u32, u64)]| {
309 let mut r = CoverageReport::new();
310 let mut f = FileCoverage::new("src/a.rs");
311 for &(line, hits) in lines {
312 f.record(line, hits);
313 }
314 r.insert(f);
315 r
316 };
317 let mut ab = report(&[(1, 1), (2, 0)]);
318 ab.merge(report(&[(1, 0), (2, 4)]));
319 let mut ba = report(&[(1, 0), (2, 4)]);
320 ba.merge(report(&[(1, 1), (2, 0)]));
321 assert_eq!(ab, ba);
322 }
323
324 #[test]
325 fn project_percent_aggregates_files() {
326 let mut report = CoverageReport::new();
327 let mut a = FileCoverage::new("src/a.rs");
328 a.record(1, 1);
329 a.record(2, 1);
330 let mut b = FileCoverage::new("src/b.rs");
331 b.record(1, 0);
332 b.record(2, 0);
333 report.insert(a);
334 report.insert(b);
335 assert_eq!(report.total_lines(), 4);
336 assert_eq!(report.covered_lines(), 2);
337 assert_eq!(report.percent(), Some(50.0));
338 }
339
340 #[test]
341 fn strip_prefix_makes_paths_repo_relative() {
342 let mut report = CoverageReport::new();
343 let mut f = FileCoverage::new("/home/runner/work/omni-dev/omni-dev/src/a.rs");
344 f.record(1, 1);
345 report.insert(f);
346 report.strip_prefix(Path::new("/home/runner/work/omni-dev/omni-dev"));
347 assert!(report.files.contains_key("src/a.rs"));
348 }
349
350 #[test]
351 fn strip_prefix_merges_colliding_paths() {
352 let mut report = CoverageReport::new();
354 let mut a = FileCoverage::new("/root/src/a.rs");
355 a.record(1, 0);
356 let mut b = FileCoverage::new("/root/./src/a.rs");
357 b.record(2, 1);
358 report.insert(a);
359 report.insert(b);
360 assert_eq!(report.files.len(), 2);
361 report.strip_prefix(Path::new("/root"));
362 assert_eq!(report.files.len(), 1);
363 assert_eq!(report.hits("src/a.rs", 1), Some(0));
364 assert_eq!(report.hits("src/a.rs", 2), Some(1));
365 }
366
367 #[test]
368 fn strip_prefix_leaves_relative_paths() {
369 let mut report = CoverageReport::new();
370 let mut f = FileCoverage::new("./src/a.rs");
371 f.record(1, 1);
372 report.insert(f);
373 report.strip_prefix(Path::new("/some/other/root"));
374 assert!(report.files.contains_key("src/a.rs"));
375 }
376
377 #[test]
378 fn retain_lines_drops_only_the_named_lines() {
379 let mut report = CoverageReport::new();
380 let mut f = FileCoverage::new("src/a.rs");
381 f.record(1, 1);
382 f.record(2, 0);
383 f.record(3, 5);
384 report.insert(f);
385 report.retain_lines(|path, line| !(path == "src/a.rs" && line == 2));
386 assert_eq!(report.hits("src/a.rs", 1), Some(1));
387 assert_eq!(report.hits("src/a.rs", 2), None);
388 assert_eq!(report.hits("src/a.rs", 3), Some(5));
389 assert_eq!(report.total_lines(), 2);
390 }
391
392 #[test]
397 fn retain_lines_removes_files_left_empty() {
398 let mut report = CoverageReport::new();
399 for path in ["src/a.rs", "src/gated.rs"] {
400 let mut f = FileCoverage::new(path);
401 f.record(1, 1);
402 report.insert(f);
403 }
404 report.retain_lines(|path, _| path != "src/gated.rs");
405 assert!(report.files.contains_key("src/a.rs"));
406 assert!(
407 !report.files.contains_key("src/gated.rs"),
408 "a file with no lines left must be dropped, not kept at 0%"
409 );
410 }
411
412 #[test]
413 fn retain_paths_drops_non_matching_files() {
414 let mut report = CoverageReport::new();
415 for path in ["src/a.rs", "src/gpu/mlx.rs", "src/b.rs"] {
416 let mut f = FileCoverage::new(path);
417 f.record(1, 1);
418 report.insert(f);
419 }
420 report.retain_paths(|path| !path.contains("gpu/"));
421 assert!(report.files.contains_key("src/a.rs"));
422 assert!(report.files.contains_key("src/b.rs"));
423 assert!(!report.files.contains_key("src/gpu/mlx.rs"));
424 }
425}