Skip to main content

omni_dev/coverage/
merge.rs

1//! Checks on the per-shard reports of a sharded coverage run.
2//!
3//! The union itself is [`CoverageReport::merge`]. What lives here is the part
4//! that keeps a merge honest: a shard that silently produced nothing, or was
5//! measured under a different workspace root, would otherwise lower the merged
6//! coverage — or simply not line up with the diff — without anyone noticing.
7
8use std::path::Path;
9
10use anyhow::{bail, Result};
11
12use super::model::CoverageReport;
13
14/// Fails when `report` has no executable lines.
15///
16/// A shard that ran instrumented tests lists every instrumented line, covered
17/// or not, so a shard with none means the run failed or its report was lost.
18/// Merging it in would not change the result — a union ignores an empty side —
19/// so the failure would never surface; this is where it has to.
20///
21/// `label` names the shard in the message (normally its path). Call this on the
22/// freshly parsed report, **before** path filtering: a shard whose files an
23/// `--ignore-filename-regex` happens to exclude is not broken.
24pub fn require_executable_lines(label: &str, report: &CoverageReport) -> Result<()> {
25    if report.total_lines() == 0 {
26        bail!(
27            "coverage shard {label} has no executable lines; \
28             a failed or empty shard would silently lower the merged coverage"
29        );
30    }
31    Ok(())
32}
33
34/// Describes a shard measured under a different workspace root, or `None` when
35/// it looks fine.
36///
37/// Report paths are made repo-relative by stripping one prefix, and a path
38/// outside it keeps its directory components as if they were relative. A shard
39/// from a runner with another workspace root therefore keys every file under a
40/// path that exists nowhere in the repository, and its coverage merges in as
41/// extra files rather than against the diff.
42///
43/// The signal is that the shard has absolute paths and **none** starts with
44/// `prefix`. A shard with some in-tree paths is left alone, since reports
45/// legitimately name a few out-of-tree files (the standard library, vendored
46/// sources). Call this on the freshly parsed report, **before** the prefix is
47/// stripped.
48pub fn prefix_mismatch(label: &str, report: &CoverageReport, prefix: &Path) -> Option<String> {
49    let prefix = prefix.to_string_lossy();
50    let prefix_slash = format!("{}/", prefix.trim_end_matches('/'));
51    let mut absolute = 0_usize;
52    for path in report.files.keys() {
53        if path.starts_with(&prefix_slash) {
54            return None;
55        }
56        if path.starts_with('/') {
57            absolute += 1;
58        }
59    }
60    (absolute > 0).then(|| {
61        format!(
62            "coverage shard {label}: none of its {absolute} absolute file path(s) is under \
63             `{}`, so it was probably measured under a different workspace root and its \
64             files will not line up with the diff; pass --strip-prefix with that root, or \
65             run every shard under the same one",
66            prefix.trim_end_matches('/')
67        )
68    })
69}
70
71#[cfg(test)]
72#[allow(clippy::unwrap_used, clippy::expect_used)]
73mod tests {
74    use super::*;
75    use crate::coverage::model::FileCoverage;
76
77    fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
78        let mut report = CoverageReport::new();
79        for (path, lines) in files {
80            let mut file = FileCoverage::new(*path);
81            for &(line, hits) in *lines {
82                file.record(line, hits);
83            }
84            report.insert(file);
85        }
86        report
87    }
88
89    #[test]
90    fn an_empty_report_is_rejected_by_name() {
91        let message = require_executable_lines("shard-2.lcov", &CoverageReport::new())
92            .unwrap_err()
93            .to_string();
94        assert!(message.contains("shard-2.lcov"), "{message}");
95        assert!(message.contains("no executable lines"), "{message}");
96    }
97
98    #[test]
99    fn a_report_whose_files_have_no_lines_is_rejected() {
100        // A parsed `SF:` block with no `DA:` records yields a file with no lines.
101        let report = report(&[("src/a.rs", &[])]);
102        assert!(require_executable_lines("s", &report).is_err());
103    }
104
105    #[test]
106    fn a_report_with_executable_lines_passes() {
107        let report = report(&[("src/a.rs", &[(1, 0)])]);
108        require_executable_lines("s", &report).unwrap();
109    }
110
111    #[test]
112    fn a_shard_under_another_root_is_flagged() {
113        let report = report(&[
114            ("/Users/dev/work/repo/src/a.rs", &[(1, 1)]),
115            ("/Users/dev/work/repo/src/b.rs", &[(1, 1)]),
116        ]);
117        let message =
118            prefix_mismatch("shard-2.lcov", &report, Path::new("/home/runner/work/repo")).unwrap();
119        assert!(message.contains("shard-2.lcov"), "{message}");
120        assert!(message.contains("2 absolute file path(s)"), "{message}");
121        assert!(message.contains("`/home/runner/work/repo`"), "{message}");
122        assert!(message.contains("--strip-prefix"), "{message}");
123    }
124
125    #[test]
126    fn a_shard_with_some_in_tree_paths_is_not_flagged() {
127        // The standard library and vendored sources are out of tree in every
128        // llvm-cov report; they must not make an ordinary shard look wrong.
129        let report = report(&[
130            ("/home/runner/work/repo/src/a.rs", &[(1, 1)]),
131            ("/rustc/abc123/library/core/src/option.rs", &[(1, 1)]),
132        ]);
133        assert_eq!(
134            prefix_mismatch("s", &report, Path::new("/home/runner/work/repo")),
135            None
136        );
137    }
138
139    #[test]
140    fn relative_paths_are_not_flagged() {
141        let report = report(&[("src/a.rs", &[(1, 1)])]);
142        assert_eq!(
143            prefix_mismatch("s", &report, Path::new("/home/runner/work/repo")),
144            None
145        );
146    }
147
148    #[test]
149    fn a_prefix_that_is_only_a_string_prefix_does_not_match() {
150        // `/work/repo-two/…` shares the string prefix `/work/repo` but is a
151        // different directory.
152        let report = report(&[("/work/repo-two/src/a.rs", &[(1, 1)])]);
153        assert!(prefix_mismatch("s", &report, Path::new("/work/repo")).is_some());
154    }
155
156    #[test]
157    fn a_trailing_slash_on_the_prefix_is_ignored() {
158        let report = report(&[("/work/repo/src/a.rs", &[(1, 1)])]);
159        assert_eq!(
160            prefix_mismatch("s", &report, Path::new("/work/repo/")),
161            None
162        );
163    }
164}