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    /// Merges `other` into this report: the files are unioned and a line present
96    /// in both takes the larger hit count.
97    ///
98    /// This is the rule for combining the reports of a sharded coverage run. It
99    /// is a union, so a line only one report instrumented keeps that report's
100    /// count rather than being scored against the other. Hit counts are maxed,
101    /// not summed: coverage is a covered-or-not question and nothing in the
102    /// line-coverage output reads the count itself.
103    pub fn merge(&mut self, other: Self) {
104        for file in other.files.into_values() {
105            self.insert(file);
106        }
107    }
108
109    /// Hit count for `path`:`line`, or `None` when the line is not instrumented
110    /// (or the file is absent from the report).
111    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    /// Total executable lines across all files.
118    pub fn total_lines(&self) -> u64 {
119        self.files.values().map(FileCoverage::total_lines).sum()
120    }
121
122    /// Total covered lines across all files.
123    pub fn covered_lines(&self) -> u64 {
124        self.files.values().map(FileCoverage::covered_lines).sum()
125    }
126
127    /// Project-wide line coverage percentage, or `None` when there are no
128    /// executable lines.
129    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    /// Normalises every file path to be repo-relative.
139    ///
140    /// Coverage tools usually emit absolute paths (lcov `SF:`, llvm-cov
141    /// `filename`). Stripping `prefix` mirrors the CI `jq ltrimstr($ws)` step so
142    /// the paths line up with the repo-relative paths git diffs report. Paths
143    /// that do not start with `prefix` are left unchanged (already relative, or
144    /// outside the tree). Leading `./` and `/` are also trimmed.
145    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            // Merge in case two source paths normalise to the same repo path.
153            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    /// Drops every file whose path does not satisfy `keep`.
168    ///
169    /// Used to apply `--ignore-filename-regex`: because it is called *after*
170    /// [`strip_prefix`](Self::strip_prefix), the predicate sees repo-relative
171    /// paths — the same space git diffs report in — so head and baseline
172    /// reports are filtered identically before any delta is computed.
173    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    /// Drops every line for which `keep` returns `false`, then drops any file
181    /// left with no executable lines.
182    ///
183    /// The line-level twin of [`retain_paths`](Self::retain_paths), used to
184    /// apply `ignore` source markers. Like the path filter it runs *after*
185    /// [`strip_prefix`](Self::strip_prefix), so the predicate sees repo-relative
186    /// paths, and it is applied to head and baseline independently — each from
187    /// its own revision's source, since a region moves between revisions.
188    ///
189    /// Emptied files are removed rather than kept at zero lines: a
190    /// [`FileCoverage`] with no lines has `percent() == None`, which
191    /// `FileDelta::delta` reads as a fall to zero — so a fully-ignored file
192    /// would render as a *total loss of coverage*, the exact opposite of what
193    /// ignoring it means.
194    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
205/// Strips `prefix_slash` (a trailing-slash directory prefix) from `path`, then
206/// trims any leading `./` or `/`.
207fn 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        // 2 of 3 executable lines covered.
236        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); // executable but uncovered
252        report.insert(f);
253        assert_eq!(report.hits("src/a.rs", 10), Some(0)); // uncovered
254        assert_eq!(report.hits("src/a.rs", 11), None); // not instrumented
255        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        // Line 1 was hit only in shard A, line 2 only in shard B: both count as covered.
298        assert_eq!(shard_a.hits("src/shared.rs", 1), Some(3));
299        assert_eq!(shard_a.hits("src/shared.rs", 2), Some(7));
300        // A line only one shard instrumented keeps that shard's count.
301        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        // Two distinct source paths that normalise to the same repo path.
353        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    /// A file whose every line is ignored must leave the report entirely. Kept
393    /// at zero lines its `percent()` is `None`, which `FileDelta::delta` reads
394    /// as a fall to zero — an ignored file would render as a total loss of
395    /// coverage.
396    #[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}