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
154fn normalize_path(path: &str, prefix_slash: &str) -> String {
157 let stripped = path.strip_prefix(prefix_slash).unwrap_or(path);
158 stripped
159 .trim_start_matches("./")
160 .trim_start_matches('/')
161 .to_string()
162}
163
164#[cfg(test)]
165#[allow(clippy::unwrap_used, clippy::expect_used)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn record_takes_max_hits() {
171 let mut f = FileCoverage::new("src/a.rs");
172 f.record(1, 0);
173 f.record(1, 3);
174 f.record(1, 1);
175 assert_eq!(f.lines.get(&1), Some(&3));
176 }
177
178 #[test]
179 fn percent_counts_only_executable_lines() {
180 let mut f = FileCoverage::new("src/a.rs");
181 f.record(1, 1);
182 f.record(2, 0);
183 f.record(3, 5);
184 assert_eq!(f.total_lines(), 3);
186 assert_eq!(f.covered_lines(), 2);
187 assert!((f.percent().unwrap() - 66.666_666).abs() < 1e-3);
188 }
189
190 #[test]
191 fn empty_file_has_no_percent() {
192 let f = FileCoverage::new("src/empty.rs");
193 assert_eq!(f.percent(), None);
194 }
195
196 #[test]
197 fn hits_distinguishes_uncovered_from_non_executable() {
198 let mut report = CoverageReport::new();
199 let mut f = FileCoverage::new("src/a.rs");
200 f.record(10, 0); report.insert(f);
202 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);
205 }
206
207 #[test]
208 fn insert_merges_duplicate_paths() {
209 let mut report = CoverageReport::new();
210 let mut a = FileCoverage::new("src/a.rs");
211 a.record(1, 0);
212 let mut b = FileCoverage::new("src/a.rs");
213 b.record(1, 2);
214 b.record(2, 1);
215 report.insert(a);
216 report.insert(b);
217 assert_eq!(report.files.len(), 1);
218 assert_eq!(report.hits("src/a.rs", 1), Some(2));
219 assert_eq!(report.hits("src/a.rs", 2), Some(1));
220 }
221
222 #[test]
223 fn project_percent_aggregates_files() {
224 let mut report = CoverageReport::new();
225 let mut a = FileCoverage::new("src/a.rs");
226 a.record(1, 1);
227 a.record(2, 1);
228 let mut b = FileCoverage::new("src/b.rs");
229 b.record(1, 0);
230 b.record(2, 0);
231 report.insert(a);
232 report.insert(b);
233 assert_eq!(report.total_lines(), 4);
234 assert_eq!(report.covered_lines(), 2);
235 assert_eq!(report.percent(), Some(50.0));
236 }
237
238 #[test]
239 fn strip_prefix_makes_paths_repo_relative() {
240 let mut report = CoverageReport::new();
241 let mut f = FileCoverage::new("/home/runner/work/omni-dev/omni-dev/src/a.rs");
242 f.record(1, 1);
243 report.insert(f);
244 report.strip_prefix(Path::new("/home/runner/work/omni-dev/omni-dev"));
245 assert!(report.files.contains_key("src/a.rs"));
246 }
247
248 #[test]
249 fn strip_prefix_merges_colliding_paths() {
250 let mut report = CoverageReport::new();
252 let mut a = FileCoverage::new("/root/src/a.rs");
253 a.record(1, 0);
254 let mut b = FileCoverage::new("/root/./src/a.rs");
255 b.record(2, 1);
256 report.insert(a);
257 report.insert(b);
258 assert_eq!(report.files.len(), 2);
259 report.strip_prefix(Path::new("/root"));
260 assert_eq!(report.files.len(), 1);
261 assert_eq!(report.hits("src/a.rs", 1), Some(0));
262 assert_eq!(report.hits("src/a.rs", 2), Some(1));
263 }
264
265 #[test]
266 fn strip_prefix_leaves_relative_paths() {
267 let mut report = CoverageReport::new();
268 let mut f = FileCoverage::new("./src/a.rs");
269 f.record(1, 1);
270 report.insert(f);
271 report.strip_prefix(Path::new("/some/other/root"));
272 assert!(report.files.contains_key("src/a.rs"));
273 }
274}