use std::fmt;
use std::path::Path;
use anyhow::{bail, Result};
use serde::Serialize;
use super::model::CoverageReport;
pub fn check_shard(
label: &str,
report: &CoverageReport,
prefix: Option<&Path>,
warnings: &mut Vec<PrefixMismatch>,
) -> Result<()> {
require_executable_lines(label, report)?;
if let Some(prefix) = prefix {
warnings.extend(prefix_mismatch(label, report, prefix));
}
Ok(())
}
pub fn require_executable_lines(label: &str, report: &CoverageReport) -> Result<()> {
if report.total_lines() == 0 {
bail!(
"coverage shard {label} has no executable lines; \
a failed or empty shard would silently lower the merged coverage"
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct PrefixMismatch {
pub shard: String,
pub strip_prefix: String,
pub absolute_paths: usize,
}
impl fmt::Display for PrefixMismatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"coverage shard {}: none of its {} absolute file path(s) is under \
`{}`, so it was probably measured under a different workspace root and its \
files will not line up with the other shards or the diff; pass --strip-prefix \
with that root, or run every shard under the same one",
self.shard, self.absolute_paths, self.strip_prefix
)
}
}
pub fn prefix_mismatch(
label: &str,
report: &CoverageReport,
prefix: &Path,
) -> Option<PrefixMismatch> {
let prefix = prefix.to_string_lossy();
let prefix = prefix.trim_end_matches('/');
let prefix_slash = format!("{prefix}/");
let mut absolute = 0_usize;
for path in report.files.keys() {
if path.starts_with(&prefix_slash) {
return None;
}
if path.starts_with('/') {
absolute += 1;
}
}
(absolute > 0).then(|| PrefixMismatch {
shard: label.to_owned(),
strip_prefix: prefix.to_owned(),
absolute_paths: absolute,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::model::FileCoverage;
fn report(files: &[(&str, &[(u32, u64)])]) -> CoverageReport {
let mut report = CoverageReport::new();
for (path, lines) in files {
let mut file = FileCoverage::new(*path);
for &(line, hits) in *lines {
file.record(line, hits);
}
report.insert(file);
}
report
}
#[test]
fn an_empty_report_is_rejected_by_name() {
let message = require_executable_lines("shard-2.lcov", &CoverageReport::new())
.unwrap_err()
.to_string();
assert!(message.contains("shard-2.lcov"), "{message}");
assert!(message.contains("no executable lines"), "{message}");
}
#[test]
fn a_report_whose_files_have_no_lines_is_rejected() {
let report = report(&[("src/a.rs", &[])]);
assert!(require_executable_lines("s", &report).is_err());
}
#[test]
fn a_report_with_executable_lines_passes() {
let report = report(&[("src/a.rs", &[(1, 0)])]);
require_executable_lines("s", &report).unwrap();
}
#[test]
fn check_shard_fails_an_empty_shard_and_notes_a_foreign_one() {
let mut warnings = Vec::new();
let prefix = Path::new("/home/runner/work/repo");
let empty = check_shard("s", &CoverageReport::new(), Some(prefix), &mut warnings);
assert!(empty.is_err());
assert!(warnings.is_empty());
let foreign = report(&[("/Users/dev/work/repo/src/a.rs", &[(1, 1)])]);
check_shard("s", &foreign, Some(prefix), &mut warnings).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
check_shard("s", &foreign, None, &mut warnings).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn a_shard_under_another_root_is_flagged() {
let report = report(&[
("/Users/dev/work/repo/src/a.rs", &[(1, 1)]),
("/Users/dev/work/repo/src/b.rs", &[(1, 1)]),
]);
let mismatch =
prefix_mismatch("shard-2.lcov", &report, Path::new("/home/runner/work/repo")).unwrap();
assert_eq!(
mismatch,
PrefixMismatch {
shard: "shard-2.lcov".to_owned(),
strip_prefix: "/home/runner/work/repo".to_owned(),
absolute_paths: 2,
}
);
let message = mismatch.to_string();
assert!(message.contains("shard-2.lcov"), "{message}");
assert!(message.contains("2 absolute file path(s)"), "{message}");
assert!(message.contains("`/home/runner/work/repo`"), "{message}");
assert!(message.contains("--strip-prefix"), "{message}");
}
#[test]
fn a_shard_with_some_in_tree_paths_is_not_flagged() {
let report = report(&[
("/home/runner/work/repo/src/a.rs", &[(1, 1)]),
("/rustc/abc123/library/core/src/option.rs", &[(1, 1)]),
]);
assert_eq!(
prefix_mismatch("s", &report, Path::new("/home/runner/work/repo")),
None
);
}
#[test]
fn relative_paths_are_not_flagged() {
let report = report(&[("src/a.rs", &[(1, 1)])]);
assert_eq!(
prefix_mismatch("s", &report, Path::new("/home/runner/work/repo")),
None
);
}
#[test]
fn a_prefix_that_is_only_a_string_prefix_does_not_match() {
let report = report(&[("/work/repo-two/src/a.rs", &[(1, 1)])]);
assert!(prefix_mismatch("s", &report, Path::new("/work/repo")).is_some());
}
#[test]
fn a_trailing_slash_on_the_prefix_is_ignored() {
let report = report(&[("/work/repo/src/a.rs", &[(1, 1)])]);
assert_eq!(
prefix_mismatch("s", &report, Path::new("/work/repo/")),
None
);
let mismatch = prefix_mismatch("s", &report, Path::new("/home/ci/")).unwrap();
assert_eq!(mismatch.strip_prefix, "/home/ci");
assert!(mismatch.to_string().contains("under `/home/ci`,"));
}
}