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