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
154/// Strips `prefix_slash` (a trailing-slash directory prefix) from `path`, then
155/// trims any leading `./` or `/`.
156fn 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        // 2 of 3 executable lines covered.
185        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); // executable but uncovered
201        report.insert(f);
202        assert_eq!(report.hits("src/a.rs", 10), Some(0)); // uncovered
203        assert_eq!(report.hits("src/a.rs", 11), None); // not instrumented
204        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        // Two distinct source paths that normalise to the same repo path.
251        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}