Skip to main content

omni_dev/coverage/
model.rs

1//! The per-line coverage model that every parser produces.
2//!
3//! A [`CoverageReport`] is a map of repo-relative file paths to their
4//! [`FileCoverage`], where each file records the hit count of every
5//! *executable* line. Non-executable lines (blank, comment, declaration-only)
6//! are simply absent — [`CoverageReport::hits`] returns `None` for them, which
7//! callers use to exclude them from coverage denominators.
8
9use std::collections::BTreeMap;
10use std::path::Path;
11
12/// Per-line hit counts for a single source file.
13///
14/// Only executable lines are present in `lines`; a line absent from the map is
15/// not instrumented (blank, comment, etc.) and must not be counted towards
16/// coverage totals.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FileCoverage {
19    /// Repo-relative path of the source file.
20    pub path: String,
21    /// Map of 1-based line number to hit count.
22    pub lines: BTreeMap<u32, u64>,
23}
24
25impl FileCoverage {
26    /// Creates an empty file coverage for `path`.
27    pub fn new(path: impl Into<String>) -> Self {
28        Self {
29            path: path.into(),
30            lines: BTreeMap::new(),
31        }
32    }
33
34    /// Records `hits` for `line`. Repeated records for the same line take the
35    /// maximum, so a line covered by any region counts as covered.
36    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    /// Number of executable lines.
44    pub fn total_lines(&self) -> u64 {
45        self.lines.len() as u64
46    }
47
48    /// Number of executable lines hit at least once.
49    pub fn covered_lines(&self) -> u64 {
50        self.lines.values().filter(|&&h| h > 0).count() as u64
51    }
52
53    /// Line coverage percentage, or `None` when the file has no executable lines.
54    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/// A whole coverage report: repo-relative file path → [`FileCoverage`].
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct CoverageReport {
67    /// Per-file coverage, keyed by repo-relative path.
68    pub files: BTreeMap<String, FileCoverage>,
69}
70
71impl CoverageReport {
72    /// Creates an empty report.
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Inserts or merges a file's coverage into the report.
78    ///
79    /// If the path already exists, line hit counts are merged (taking the max
80    /// per line), which keeps the model robust against reports that split one
81    /// file across multiple records.
82    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    /// Hit count for `path`:`line`, or `None` when the line is not instrumented
96    /// (or the file is absent from the report).
97    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    /// Total executable lines across all files.
104    pub fn total_lines(&self) -> u64 {
105        self.files.values().map(FileCoverage::total_lines).sum()
106    }
107
108    /// Total covered lines across all files.
109    pub fn covered_lines(&self) -> u64 {
110        self.files.values().map(FileCoverage::covered_lines).sum()
111    }
112
113    /// Project-wide line coverage percentage, or `None` when there are no
114    /// executable lines.
115    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    /// Normalises every file path to be repo-relative.
125    ///
126    /// Coverage tools usually emit absolute paths (lcov `SF:`, llvm-cov
127    /// `filename`). Stripping `prefix` mirrors the CI `jq ltrimstr($ws)` step so
128    /// the paths line up with the repo-relative paths git diffs report. Paths
129    /// that do not start with `prefix` are left unchanged (already relative, or
130    /// outside the tree). Leading `./` and `/` are also trimmed.
131    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            // Merge in case two source paths normalise to the same repo path.
139            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    /// Drops every file whose path does not satisfy `keep`.
154    ///
155    /// Used to apply `--ignore-filename-regex`: because it is called *after*
156    /// [`strip_prefix`](Self::strip_prefix), the predicate sees repo-relative
157    /// paths — the same space git diffs report in — so head and baseline
158    /// reports are filtered identically before any delta is computed.
159    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    /// Drops every line for which `keep` returns `false`, then drops any file
167    /// left with no executable lines.
168    ///
169    /// The line-level twin of [`retain_paths`](Self::retain_paths), used to
170    /// apply `ignore` source markers. Like the path filter it runs *after*
171    /// [`strip_prefix`](Self::strip_prefix), so the predicate sees repo-relative
172    /// paths, and it is applied to head and baseline independently — each from
173    /// its own revision's source, since a region moves between revisions.
174    ///
175    /// Emptied files are removed rather than kept at zero lines: a
176    /// [`FileCoverage`] with no lines has `percent() == None`, which
177    /// `FileDelta::delta` reads as a fall to zero — so a fully-ignored file
178    /// would render as a *total loss of coverage*, the exact opposite of what
179    /// ignoring it means.
180    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
191/// Strips `prefix_slash` (a trailing-slash directory prefix) from `path`, then
192/// trims any leading `./` or `/`.
193fn 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        // 2 of 3 executable lines covered.
222        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); // executable but uncovered
238        report.insert(f);
239        assert_eq!(report.hits("src/a.rs", 10), Some(0)); // uncovered
240        assert_eq!(report.hits("src/a.rs", 11), None); // not instrumented
241        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        // Two distinct source paths that normalise to the same repo path.
288        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    /// A file whose every line is ignored must leave the report entirely. Kept
328    /// at zero lines its `percent()` is `None`, which `FileDelta::delta` reads
329    /// as a fall to zero — an ignored file would render as a total loss of
330    /// coverage.
331    #[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}