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 hits(&self, path: &str, line: u32) -> Option<u64> {
98 self.files
99 .get(path)
100 .and_then(|f| f.lines.get(&line).copied())
101 }
102
103 pub fn total_lines(&self) -> u64 {
105 self.files.values().map(FileCoverage::total_lines).sum()
106 }
107
108 pub fn covered_lines(&self) -> u64 {
110 self.files.values().map(FileCoverage::covered_lines).sum()
111 }
112
113 pub fn percent(&self) -> Option<f64> {
116 let total = self.total_lines();
117 if total == 0 {
118 None
119 } else {
120 Some(self.covered_lines() as f64 / total as f64 * 100.0)
121 }
122 }
123
124 pub fn strip_prefix(&mut self, prefix: &Path) {
132 let prefix_str = prefix.to_string_lossy();
133 let prefix_slash = format!("{}/", prefix_str.trim_end_matches('/'));
134 let mut remapped: BTreeMap<String, FileCoverage> = BTreeMap::new();
135 for (_, mut file) in std::mem::take(&mut self.files) {
136 let normalized = normalize_path(&file.path, &prefix_slash);
137 file.path.clone_from(&normalized);
138 match remapped.get_mut(&normalized) {
140 Some(existing) => {
141 for (line, hits) in std::mem::take(&mut file.lines) {
142 existing.record(line, hits);
143 }
144 }
145 None => {
146 remapped.insert(normalized, file);
147 }
148 }
149 }
150 self.files = remapped;
151 }
152
153 pub fn retain_paths<F>(&mut self, keep: F)
160 where
161 F: Fn(&str) -> bool,
162 {
163 self.files.retain(|path, _| keep(path));
164 }
165
166 pub fn retain_lines<F>(&mut self, keep: F)
181 where
182 F: Fn(&str, u32) -> bool,
183 {
184 for (path, file) in &mut self.files {
185 file.lines.retain(|&line, _| keep(path, line));
186 }
187 self.files.retain(|_, file| file.total_lines() > 0);
188 }
189}
190
191fn normalize_path(path: &str, prefix_slash: &str) -> String {
194 let stripped = path.strip_prefix(prefix_slash).unwrap_or(path);
195 stripped
196 .trim_start_matches("./")
197 .trim_start_matches('/')
198 .to_string()
199}
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used, clippy::expect_used)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn record_takes_max_hits() {
208 let mut f = FileCoverage::new("src/a.rs");
209 f.record(1, 0);
210 f.record(1, 3);
211 f.record(1, 1);
212 assert_eq!(f.lines.get(&1), Some(&3));
213 }
214
215 #[test]
216 fn percent_counts_only_executable_lines() {
217 let mut f = FileCoverage::new("src/a.rs");
218 f.record(1, 1);
219 f.record(2, 0);
220 f.record(3, 5);
221 assert_eq!(f.total_lines(), 3);
223 assert_eq!(f.covered_lines(), 2);
224 assert!((f.percent().unwrap() - 66.666_666).abs() < 1e-3);
225 }
226
227 #[test]
228 fn empty_file_has_no_percent() {
229 let f = FileCoverage::new("src/empty.rs");
230 assert_eq!(f.percent(), None);
231 }
232
233 #[test]
234 fn hits_distinguishes_uncovered_from_non_executable() {
235 let mut report = CoverageReport::new();
236 let mut f = FileCoverage::new("src/a.rs");
237 f.record(10, 0); report.insert(f);
239 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);
242 }
243
244 #[test]
245 fn insert_merges_duplicate_paths() {
246 let mut report = CoverageReport::new();
247 let mut a = FileCoverage::new("src/a.rs");
248 a.record(1, 0);
249 let mut b = FileCoverage::new("src/a.rs");
250 b.record(1, 2);
251 b.record(2, 1);
252 report.insert(a);
253 report.insert(b);
254 assert_eq!(report.files.len(), 1);
255 assert_eq!(report.hits("src/a.rs", 1), Some(2));
256 assert_eq!(report.hits("src/a.rs", 2), Some(1));
257 }
258
259 #[test]
260 fn project_percent_aggregates_files() {
261 let mut report = CoverageReport::new();
262 let mut a = FileCoverage::new("src/a.rs");
263 a.record(1, 1);
264 a.record(2, 1);
265 let mut b = FileCoverage::new("src/b.rs");
266 b.record(1, 0);
267 b.record(2, 0);
268 report.insert(a);
269 report.insert(b);
270 assert_eq!(report.total_lines(), 4);
271 assert_eq!(report.covered_lines(), 2);
272 assert_eq!(report.percent(), Some(50.0));
273 }
274
275 #[test]
276 fn strip_prefix_makes_paths_repo_relative() {
277 let mut report = CoverageReport::new();
278 let mut f = FileCoverage::new("/home/runner/work/omni-dev/omni-dev/src/a.rs");
279 f.record(1, 1);
280 report.insert(f);
281 report.strip_prefix(Path::new("/home/runner/work/omni-dev/omni-dev"));
282 assert!(report.files.contains_key("src/a.rs"));
283 }
284
285 #[test]
286 fn strip_prefix_merges_colliding_paths() {
287 let mut report = CoverageReport::new();
289 let mut a = FileCoverage::new("/root/src/a.rs");
290 a.record(1, 0);
291 let mut b = FileCoverage::new("/root/./src/a.rs");
292 b.record(2, 1);
293 report.insert(a);
294 report.insert(b);
295 assert_eq!(report.files.len(), 2);
296 report.strip_prefix(Path::new("/root"));
297 assert_eq!(report.files.len(), 1);
298 assert_eq!(report.hits("src/a.rs", 1), Some(0));
299 assert_eq!(report.hits("src/a.rs", 2), Some(1));
300 }
301
302 #[test]
303 fn strip_prefix_leaves_relative_paths() {
304 let mut report = CoverageReport::new();
305 let mut f = FileCoverage::new("./src/a.rs");
306 f.record(1, 1);
307 report.insert(f);
308 report.strip_prefix(Path::new("/some/other/root"));
309 assert!(report.files.contains_key("src/a.rs"));
310 }
311
312 #[test]
313 fn retain_lines_drops_only_the_named_lines() {
314 let mut report = CoverageReport::new();
315 let mut f = FileCoverage::new("src/a.rs");
316 f.record(1, 1);
317 f.record(2, 0);
318 f.record(3, 5);
319 report.insert(f);
320 report.retain_lines(|path, line| !(path == "src/a.rs" && line == 2));
321 assert_eq!(report.hits("src/a.rs", 1), Some(1));
322 assert_eq!(report.hits("src/a.rs", 2), None);
323 assert_eq!(report.hits("src/a.rs", 3), Some(5));
324 assert_eq!(report.total_lines(), 2);
325 }
326
327 #[test]
332 fn retain_lines_removes_files_left_empty() {
333 let mut report = CoverageReport::new();
334 for path in ["src/a.rs", "src/gated.rs"] {
335 let mut f = FileCoverage::new(path);
336 f.record(1, 1);
337 report.insert(f);
338 }
339 report.retain_lines(|path, _| path != "src/gated.rs");
340 assert!(report.files.contains_key("src/a.rs"));
341 assert!(
342 !report.files.contains_key("src/gated.rs"),
343 "a file with no lines left must be dropped, not kept at 0%"
344 );
345 }
346
347 #[test]
348 fn retain_paths_drops_non_matching_files() {
349 let mut report = CoverageReport::new();
350 for path in ["src/a.rs", "src/gpu/mlx.rs", "src/b.rs"] {
351 let mut f = FileCoverage::new(path);
352 f.record(1, 1);
353 report.insert(f);
354 }
355 report.retain_paths(|path| !path.contains("gpu/"));
356 assert!(report.files.contains_key("src/a.rs"));
357 assert!(report.files.contains_key("src/b.rs"));
358 assert!(!report.files.contains_key("src/gpu/mlx.rs"));
359 }
360}