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
167/// Strips `prefix_slash` (a trailing-slash directory prefix) from `path`, then
168/// trims any leading `./` or `/`.
169fn 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        // 2 of 3 executable lines covered.
198        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); // executable but uncovered
214        report.insert(f);
215        assert_eq!(report.hits("src/a.rs", 10), Some(0)); // uncovered
216        assert_eq!(report.hits("src/a.rs", 11), None); // not instrumented
217        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        // Two distinct source paths that normalise to the same repo path.
264        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}