omni_dev/coverage/
model.rs1use 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
167fn normalize_path(path: &str, prefix_slash: &str) -> String {
170 let stripped = path.strip_prefix(prefix_slash).unwrap_or(path);
171 stripped
172 .trim_start_matches("./")
173 .trim_start_matches('/')
174 .to_string()
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used, clippy::expect_used)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn record_takes_max_hits() {
184 let mut f = FileCoverage::new("src/a.rs");
185 f.record(1, 0);
186 f.record(1, 3);
187 f.record(1, 1);
188 assert_eq!(f.lines.get(&1), Some(&3));
189 }
190
191 #[test]
192 fn percent_counts_only_executable_lines() {
193 let mut f = FileCoverage::new("src/a.rs");
194 f.record(1, 1);
195 f.record(2, 0);
196 f.record(3, 5);
197 assert_eq!(f.total_lines(), 3);
199 assert_eq!(f.covered_lines(), 2);
200 assert!((f.percent().unwrap() - 66.666_666).abs() < 1e-3);
201 }
202
203 #[test]
204 fn empty_file_has_no_percent() {
205 let f = FileCoverage::new("src/empty.rs");
206 assert_eq!(f.percent(), None);
207 }
208
209 #[test]
210 fn hits_distinguishes_uncovered_from_non_executable() {
211 let mut report = CoverageReport::new();
212 let mut f = FileCoverage::new("src/a.rs");
213 f.record(10, 0); report.insert(f);
215 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);
218 }
219
220 #[test]
221 fn insert_merges_duplicate_paths() {
222 let mut report = CoverageReport::new();
223 let mut a = FileCoverage::new("src/a.rs");
224 a.record(1, 0);
225 let mut b = FileCoverage::new("src/a.rs");
226 b.record(1, 2);
227 b.record(2, 1);
228 report.insert(a);
229 report.insert(b);
230 assert_eq!(report.files.len(), 1);
231 assert_eq!(report.hits("src/a.rs", 1), Some(2));
232 assert_eq!(report.hits("src/a.rs", 2), Some(1));
233 }
234
235 #[test]
236 fn project_percent_aggregates_files() {
237 let mut report = CoverageReport::new();
238 let mut a = FileCoverage::new("src/a.rs");
239 a.record(1, 1);
240 a.record(2, 1);
241 let mut b = FileCoverage::new("src/b.rs");
242 b.record(1, 0);
243 b.record(2, 0);
244 report.insert(a);
245 report.insert(b);
246 assert_eq!(report.total_lines(), 4);
247 assert_eq!(report.covered_lines(), 2);
248 assert_eq!(report.percent(), Some(50.0));
249 }
250
251 #[test]
252 fn strip_prefix_makes_paths_repo_relative() {
253 let mut report = CoverageReport::new();
254 let mut f = FileCoverage::new("/home/runner/work/omni-dev/omni-dev/src/a.rs");
255 f.record(1, 1);
256 report.insert(f);
257 report.strip_prefix(Path::new("/home/runner/work/omni-dev/omni-dev"));
258 assert!(report.files.contains_key("src/a.rs"));
259 }
260
261 #[test]
262 fn strip_prefix_merges_colliding_paths() {
263 let mut report = CoverageReport::new();
265 let mut a = FileCoverage::new("/root/src/a.rs");
266 a.record(1, 0);
267 let mut b = FileCoverage::new("/root/./src/a.rs");
268 b.record(2, 1);
269 report.insert(a);
270 report.insert(b);
271 assert_eq!(report.files.len(), 2);
272 report.strip_prefix(Path::new("/root"));
273 assert_eq!(report.files.len(), 1);
274 assert_eq!(report.hits("src/a.rs", 1), Some(0));
275 assert_eq!(report.hits("src/a.rs", 2), Some(1));
276 }
277
278 #[test]
279 fn strip_prefix_leaves_relative_paths() {
280 let mut report = CoverageReport::new();
281 let mut f = FileCoverage::new("./src/a.rs");
282 f.record(1, 1);
283 report.insert(f);
284 report.strip_prefix(Path::new("/some/other/root"));
285 assert!(report.files.contains_key("src/a.rs"));
286 }
287
288 #[test]
289 fn retain_paths_drops_non_matching_files() {
290 let mut report = CoverageReport::new();
291 for path in ["src/a.rs", "src/gpu/mlx.rs", "src/b.rs"] {
292 let mut f = FileCoverage::new(path);
293 f.record(1, 1);
294 report.insert(f);
295 }
296 report.retain_paths(|path| !path.contains("gpu/"));
297 assert!(report.files.contains_key("src/a.rs"));
298 assert!(report.files.contains_key("src/b.rs"));
299 assert!(!report.files.contains_key("src/gpu/mlx.rs"));
300 }
301}