use std::collections::HashSet;
use std::path::PathBuf;
use crate::Verdict;
use crate::aggregate::{LineTotals, aggregate};
use crate::attribute::{AttributionOutcome, attribute};
use crate::error::{CoverageGateError, UnknownPackageSelectorError};
use crate::lcov_cov::{CoverageReport, FileReport};
use crate::threshold::{Threshold, ThresholdSource};
use crate::workspace::{Member, Workspace};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Status {
Ok,
Fail,
NoData,
NoCoverableLines,
UnexpectedCoverableLines,
}
#[derive(Debug, Clone)]
pub(crate) struct PackageOutcome {
pub(crate) name: String,
pub(crate) threshold: Threshold,
pub(crate) totals: LineTotals,
pub(crate) status: Status,
pub(crate) diagnostics: Vec<LineDiagnostic>,
}
impl PackageOutcome {
pub(crate) fn percent(&self) -> Option<f64> {
self.totals.percent()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LineDiagnostic {
pub(crate) path: PathBuf,
pub(crate) lines: Vec<u32>,
}
#[derive(Debug, Clone)]
pub(crate) struct Report {
pub(crate) outcomes: Vec<PackageOutcome>,
pub(crate) unattributed: usize,
}
impl Report {
pub(crate) fn verdict(&self) -> Verdict {
let mut has_fail = false;
for o in &self.outcomes {
match o.status {
Status::NoData => return Verdict::ConfigError,
Status::Fail | Status::UnexpectedCoverableLines => has_fail = true,
Status::Ok | Status::NoCoverableLines => {}
}
}
if has_fail { Verdict::Fail } else { Verdict::Pass }
}
}
pub(crate) fn evaluate(report: &CoverageReport, workspace: &Workspace, gated_packages: &[String]) -> Result<Report, CoverageGateError> {
let gated = resolve_gated(workspace, gated_packages)?;
let AttributionOutcome { by_member, unattributed } = attribute(&report.files, &workspace.members);
let mut outcomes: Vec<PackageOutcome> = gated
.iter()
.map(|m| {
let attrib = by_member.get(m.name.as_str()).map_or(&[][..], Vec::as_slice);
let totals = aggregate(attrib);
let (threshold, status) = if m.expect_no_coverable_lines {
let threshold = Threshold {
min_lines_percent: 0.0,
source: ThresholdSource::Package,
};
(threshold, classify_no_coverable_lines(totals))
} else {
let threshold = Threshold::resolve(m, workspace);
let status = classify(totals, threshold);
(threshold, status)
};
let diagnostics = diagnostics(attrib, m, status);
PackageOutcome {
name: m.name.clone(),
threshold,
totals,
status,
diagnostics,
}
})
.collect();
outcomes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Report {
outcomes,
unattributed: unattributed.len(),
})
}
fn diagnostics(files: &[&FileReport], member: &Member, status: Status) -> Vec<LineDiagnostic> {
let mut diagnostics: Vec<LineDiagnostic> = files
.iter()
.filter_map(|file| {
let lines = match status {
Status::Fail => &file.uncovered_lines,
Status::UnexpectedCoverableLines => &file.coverable_lines,
Status::Ok | Status::NoData | Status::NoCoverableLines => return None,
};
if lines.is_empty() {
return None;
}
Some(LineDiagnostic {
path: file
.filename
.strip_prefix(&member.manifest_dir)
.unwrap_or(&file.filename)
.to_path_buf(),
lines: lines.clone(),
})
})
.collect();
diagnostics.sort_by(|a, b| a.path.cmp(&b.path));
diagnostics
}
pub(crate) fn resolve_gated<'w>(workspace: &'w Workspace, packages: &[String]) -> Result<Vec<&'w Member>, CoverageGateError> {
if packages.is_empty() {
return Ok(workspace.members.iter().collect());
}
let mut seen: HashSet<&str> = HashSet::new();
let mut out = Vec::with_capacity(packages.len());
for spec in packages {
let matches: Vec<&Member> = workspace.members.iter().filter(|m| glob_matches(spec, &m.name)).collect();
if matches.is_empty() {
return Err(UnknownPackageSelectorError::new(spec.clone()).into());
}
for m in matches {
if seen.insert(m.name.as_str()) {
out.push(m);
}
}
}
Ok(out)
}
fn glob_matches(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
glob_inner(&p, 0, &n, 0)
}
#[mutants::skip]
fn glob_inner(p: &[char], mut pi: usize, n: &[char], mut ni: usize) -> bool {
while pi < p.len() {
match p[pi] {
'*' => {
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
if pi == p.len() {
return true;
}
for k in ni..=n.len() {
if glob_inner(p, pi, n, k) {
return true;
}
}
return false;
}
'?' => {
if ni >= n.len() {
return false;
}
pi += 1;
ni += 1;
}
c => {
if ni >= n.len() || n[ni] != c {
return false;
}
pi += 1;
ni += 1;
}
}
}
ni == n.len()
}
fn classify(totals: LineTotals, threshold: Threshold) -> Status {
if threshold.min_lines_percent <= 0.0 {
return Status::Ok;
}
let Some(pct) = totals.percent() else {
return Status::NoData;
};
if pct >= threshold.min_lines_percent {
Status::Ok
} else {
Status::Fail
}
}
fn classify_no_coverable_lines(totals: LineTotals) -> Status {
if totals.count == 0 {
Status::NoCoverableLines
} else {
Status::UnexpectedCoverableLines
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn make_file(path: &str, count: u32, covered: u32) -> FileReport {
FileReport {
filename: PathBuf::from(path),
lines_total: count,
lines_covered: covered,
coverable_lines: (1..=count).collect(),
uncovered_lines: ((covered + 1)..=count).collect(),
}
}
fn make_member(name: &str, manifest_dir: &str, min_lines_percent: Option<f64>) -> Member {
Member {
name: name.to_owned(),
manifest_dir: PathBuf::from(manifest_dir),
min_lines_percent,
expect_no_coverable_lines: false,
}
}
fn make_member_expect_empty(name: &str, manifest_dir: &str) -> Member {
Member {
name: name.to_owned(),
manifest_dir: PathBuf::from(manifest_dir),
min_lines_percent: None,
expect_no_coverable_lines: true,
}
}
fn make_report(files: Vec<FileReport>) -> CoverageReport {
CoverageReport { files }
}
fn make_workspace(members: Vec<Member>, default: Option<f64>) -> Workspace {
Workspace {
members,
default_min_lines_percent: default,
}
}
#[test]
fn all_pass() {
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 100, 95),
make_file("/repo/crates/beta/src/lib.rs", 50, 50),
]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(90.0)),
make_member("beta", "/repo/crates/beta", Some(80.0)),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::Pass);
assert!(r.outcomes.iter().all(|o| o.status == Status::Ok));
}
#[test]
fn one_failure_produces_fail_verdict() {
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 100, 95),
make_file("/repo/crates/beta/src/lib.rs", 100, 60),
]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(90.0)),
make_member("beta", "/repo/crates/beta", Some(80.0)),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::Fail);
let beta = r.outcomes.iter().find(|o| o.name == "beta").unwrap();
assert_eq!(beta.status, Status::Fail);
assert!((beta.percent().unwrap() - 60.0).abs() < f64::EPSILON);
assert_eq!(beta.diagnostics[0].path, PathBuf::from("src/lib.rs"));
assert_eq!(beta.diagnostics[0].lines, (61..=100).collect::<Vec<_>>());
}
#[test]
fn unexpected_coverable_lines_report_every_instrumented_location() {
let report = make_report(vec![make_file("/repo/crates/alpha/src/lib.rs", 4, 2)]);
let ws = make_workspace(vec![make_member_expect_empty("alpha", "/repo/crates/alpha")], None);
let evaluated = evaluate(&report, &ws, &[]).expect("evaluate");
let alpha = &evaluated.outcomes[0];
assert_eq!(alpha.status, Status::UnexpectedCoverableLines);
assert_eq!(alpha.diagnostics[0].path, PathBuf::from("src/lib.rs"));
assert_eq!(alpha.diagnostics[0].lines, vec![1, 2, 3, 4]);
}
#[test]
fn diagnostics_omit_files_without_relevant_lines() {
let file = make_file("/repo/crates/alpha/src/lib.rs", 0, 0);
let member = make_member("alpha", "/repo/crates/alpha", Some(80.0));
assert!(diagnostics(&[&file], &member, Status::Fail).is_empty());
}
#[test]
fn no_data_dominates_fail() {
let report = make_report(vec![make_file("/repo/crates/alpha/src/lib.rs", 100, 60)]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(80.0)),
make_member("beta", "/repo/crates/beta", Some(80.0)),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::ConfigError);
let beta = r.outcomes.iter().find(|o| o.name == "beta").unwrap();
assert_eq!(beta.status, Status::NoData);
}
#[test]
fn package_flag_restricts_scope() {
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 100, 95),
make_file("/repo/crates/beta/src/lib.rs", 100, 50),
]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(90.0)),
make_member("beta", "/repo/crates/beta", Some(80.0)),
],
None,
);
let r = evaluate(&report, &ws, &["alpha".to_owned()]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::Pass);
assert_eq!(r.outcomes.len(), 1);
assert_eq!(r.outcomes[0].name, "alpha");
}
#[test]
fn package_flag_with_unknown_name_errors() {
let ws = make_workspace(vec![make_member("alpha", "/repo/crates/alpha", None)], None);
let report = make_report(Vec::new());
let err = evaluate(&report, &ws, &["typo".to_owned()]).expect_err("unknown package must error");
let rendered = err.to_string();
assert!(rendered.contains("typo"));
assert!(rendered.contains("--package"));
}
#[test]
fn glob_selector_matches_multiple_members() {
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", None),
make_member("alpha_macros", "/repo/crates/alpha_macros", None),
make_member("beta", "/repo/crates/beta", None),
],
None,
);
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 10, 10),
make_file("/repo/crates/alpha_macros/src/lib.rs", 10, 10),
]);
let r = evaluate(&report, &ws, &["alpha*".to_owned()]).expect("evaluate");
let names: Vec<_> = r.outcomes.iter().map(|o| o.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "alpha_macros"]);
}
#[test]
fn glob_matching_no_member_errors() {
let ws = make_workspace(vec![make_member("alpha", "/repo/crates/alpha", None)], None);
let report = make_report(Vec::new());
let err = evaluate(&report, &ws, &["beta*".to_owned()]).expect_err("no match must error");
assert!(err.to_string().contains("beta*"));
}
#[test]
fn overlapping_selectors_dedupe_members() {
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", None),
make_member("alpha_macros", "/repo/crates/alpha_macros", None),
],
None,
);
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 10, 10),
make_file("/repo/crates/alpha_macros/src/lib.rs", 10, 10),
]);
let r = evaluate(&report, &ws, &["alpha".to_owned(), "alpha*".to_owned()]).expect("evaluate");
let names: Vec<_> = r.outcomes.iter().map(|o| o.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "alpha_macros"]);
}
#[test]
fn glob_matcher_handles_wildcards() {
assert!(super::glob_matches("alpha*", "alpha"));
assert!(super::glob_matches("alpha*", "alpha_macros"));
assert!(super::glob_matches("*macros", "alpha_macros"));
assert!(super::glob_matches("*alpha*", "my_alpha_lib"));
assert!(super::glob_matches("a?pha", "alpha"));
assert!(!super::glob_matches("alpha", "alphax"));
assert!(!super::glob_matches("alpha*", "beta"));
assert!(!super::glob_matches("a?pha", "axxpha"));
assert!(super::glob_matches("a**b", "ab"));
assert!(super::glob_matches("a**b", "axyzb"));
assert!(!super::glob_matches("a*b", "ac"));
assert!(!super::glob_matches("a*b", "axyz"));
assert!(!super::glob_matches("a?", "a"));
assert!(!super::glob_matches("alpha?", "alpha"));
}
#[test]
fn unattributed_files_are_counted_and_dropped() {
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 100, 80),
make_file("/elsewhere/build-script.rs", 50, 0),
]);
let ws = make_workspace(vec![make_member("alpha", "/repo/crates/alpha", Some(70.0))], None);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.unattributed, 1);
let alpha = &r.outcomes[0];
assert_eq!(alpha.totals.count, 100);
assert_eq!(alpha.status, Status::Ok);
}
#[test]
fn threshold_source_propagated_through_outcome() {
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(50.0)),
make_member("beta", "/repo/crates/beta", None),
make_member("gamma", "/repo/crates/gamma", None),
],
Some(80.0),
);
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 10, 10),
make_file("/repo/crates/beta/src/lib.rs", 10, 10),
make_file("/repo/crates/gamma/src/lib.rs", 10, 10),
]);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
let by_name: std::collections::HashMap<_, _> = r.outcomes.iter().map(|o| (o.name.as_str(), o)).collect();
assert_eq!(by_name["alpha"].threshold.source, ThresholdSource::Package);
assert_eq!(by_name["beta"].threshold.source, ThresholdSource::Workspace);
assert_eq!(by_name["gamma"].threshold.source, ThresholdSource::Workspace);
}
#[test]
fn exact_threshold_match_passes() {
let totals = LineTotals { count: 100, covered: 82 };
let threshold = Threshold {
min_lines_percent: 82.0,
source: ThresholdSource::Default,
};
assert_eq!(classify(totals, threshold), Status::Ok);
}
#[test]
fn displayed_rounding_does_not_raise_measured_coverage() {
let totals = LineTotals {
count: 10_000,
covered: 8_195,
};
let threshold = Threshold {
min_lines_percent: 82.0,
source: ThresholdSource::Default,
};
assert_eq!(classify(totals, threshold), Status::Fail);
}
#[test]
fn full_coverage_threshold_requires_every_line() {
let totals = LineTotals {
count: 2_000,
covered: 1_999,
};
let threshold = Threshold {
min_lines_percent: 100.0,
source: ThresholdSource::Default,
};
assert_eq!(classify(totals, threshold), Status::Fail);
}
#[test]
fn displayed_rounding_does_not_lower_threshold() {
let totals = LineTotals { count: 100, covered: 82 };
let threshold = Threshold {
min_lines_percent: 82.04,
source: ThresholdSource::Default,
};
assert_eq!(classify(totals, threshold), Status::Fail);
}
#[test]
fn zero_threshold_opts_out_even_with_no_data() {
let totals = LineTotals { count: 0, covered: 0 };
let threshold = Threshold {
min_lines_percent: 0.0,
source: ThresholdSource::Package,
};
assert_eq!(classify(totals, threshold), Status::Ok);
}
#[test]
fn zero_threshold_opts_out_even_when_well_covered() {
let totals = LineTotals { count: 100, covered: 100 };
let threshold = Threshold {
min_lines_percent: 0.0,
source: ThresholdSource::Package,
};
assert_eq!(classify(totals, threshold), Status::Ok);
}
#[test]
fn tiny_non_zero_threshold_is_not_treated_as_opt_out() {
let totals = LineTotals { count: 0, covered: 0 };
let threshold = Threshold {
min_lines_percent: 1e-9,
source: ThresholdSource::Package,
};
assert_eq!(classify(totals, threshold), Status::NoData);
}
#[test]
fn classify_no_coverable_lines_passes_when_empty() {
assert_eq!(
classify_no_coverable_lines(LineTotals { count: 0, covered: 0 }),
Status::NoCoverableLines
);
}
#[test]
fn classify_no_coverable_lines_fails_when_lines_appear() {
assert_eq!(
classify_no_coverable_lines(LineTotals { count: 1, covered: 1 }),
Status::UnexpectedCoverableLines
);
assert_eq!(
classify_no_coverable_lines(LineTotals { count: 10, covered: 0 }),
Status::UnexpectedCoverableLines
);
}
#[test]
fn expect_no_coverable_lines_crate_with_no_data_passes() {
let report = make_report(vec![make_file("/repo/crates/alpha/src/lib.rs", 100, 95)]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(80.0)),
make_member_expect_empty("beta", "/repo/crates/beta"),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::Pass);
let beta = r.outcomes.iter().find(|o| o.name == "beta").unwrap();
assert_eq!(beta.status, Status::NoCoverableLines);
assert_eq!(beta.threshold.source, ThresholdSource::Package);
assert!((beta.threshold.min_lines_percent - 0.0).abs() < f64::EPSILON);
}
#[test]
fn expect_no_coverable_lines_crate_with_lines_fails() {
let report = make_report(vec![
make_file("/repo/crates/alpha/src/lib.rs", 100, 95),
make_file("/repo/crates/beta/src/lib.rs", 5, 0),
]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(80.0)),
make_member_expect_empty("beta", "/repo/crates/beta"),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::Fail);
let beta = r.outcomes.iter().find(|o| o.name == "beta").unwrap();
assert_eq!(beta.status, Status::UnexpectedCoverableLines);
}
#[test]
fn no_data_dominates_unexpected_coverable_lines() {
let report = make_report(vec![make_file("/repo/crates/beta/src/lib.rs", 5, 0)]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(80.0)),
make_member_expect_empty("beta", "/repo/crates/beta"),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::ConfigError);
}
#[test]
fn opt_out_crate_does_not_force_config_error_verdict() {
let report = make_report(vec![make_file("/repo/crates/alpha/src/lib.rs", 100, 95)]);
let ws = make_workspace(
vec![
make_member("alpha", "/repo/crates/alpha", Some(80.0)),
make_member("beta", "/repo/crates/beta", Some(0.0)),
],
None,
);
let r = evaluate(&report, &ws, &[]).expect("evaluate");
assert_eq!(r.verdict(), Verdict::Pass);
let beta = r.outcomes.iter().find(|o| o.name == "beta").unwrap();
assert_eq!(beta.status, Status::Ok);
}
}