use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::coverage::{
self, FileCoverage, Outcome, RustThresholds, Thresholds, TypeScriptThresholds,
};
const TS_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"];
pub fn measure(
root: &Path,
base: &str,
thresholds: Thresholds,
omit: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<Outcome> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| path.ends_with(".py"));
lift_exempt_lines(&mut changed, exempt_lines);
if changed.is_empty() {
return Ok(Outcome::Pass);
}
let report = coverage::measure_patch_report(root, omit)?;
let files = relative_keys(report.files, root);
Ok(evaluate_patch(&changed, &files, thresholds))
}
fn lift_exempt_lines(
changed: &mut BTreeMap<String, BTreeSet<u64>>,
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
) {
for (file, exempt) in exempt_lines {
if let Some(lines) = changed.get_mut(file) {
lines.retain(|&line| !u32::try_from(line).is_ok_and(|line| exempt.contains(&line)));
}
}
}
fn evaluate_patch(
changed: &BTreeMap<String, BTreeSet<u64>>,
files: &BTreeMap<String, FileCoverage>,
thresholds: Thresholds,
) -> Outcome {
let (covered, total) = python_ratio(changed, files, thresholds.branch);
if total == 0 {
return Outcome::Pass;
}
let actual = 100.0 * covered as f64 / total as f64;
if actual + 1e-9 >= f64::from(thresholds.fail_under) {
Outcome::Pass
} else {
Outcome::Fail(format!(
"changed-line coverage {actual:.2}% is below the required {}%",
thresholds.fail_under
))
}
}
fn python_ratio(
selected: &BTreeMap<String, BTreeSet<u64>>,
files: &BTreeMap<String, FileCoverage>,
branch: bool,
) -> (u64, u64) {
let mut covered: u64 = 0;
let mut total: u64 = 0;
for (file, lines) in selected {
let Some(cov) = files.get(file) else {
continue;
};
let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
for &line in lines {
if executed.contains(&line) {
covered += 1;
total += 1;
} else if missing.contains(&line) {
total += 1;
}
}
if branch {
for arc in &cov.executed_branches {
if arc_source_in(arc, lines) {
covered += 1;
total += 1;
}
}
for arc in &cov.missing_branches {
if arc_source_in(arc, lines) {
total += 1;
}
}
}
}
(covered, total)
}
fn arc_source_in(arc: &[i64], lines: &BTreeSet<u64>) -> bool {
arc.first()
.and_then(|&src| u64::try_from(src).ok())
.is_some_and(|src| lines.contains(&src))
}
pub fn measure_typescript(
root: &Path,
base: &str,
thresholds: TypeScriptThresholds,
exclude: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<Outcome> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
lift_exempt_lines(&mut changed, exempt_lines);
if changed.is_empty() {
return Ok(Outcome::Pass);
}
let detail = relative_keys(
coverage::measure_patch_typescript_detail(root, exclude)?,
root,
);
Ok(evaluate_patch_typescript(&changed, &detail, thresholds))
}
fn evaluate_patch_typescript(
changed: &BTreeMap<String, BTreeSet<u64>>,
detail: &BTreeMap<String, coverage::TsPatchCoverage>,
thresholds: TypeScriptThresholds,
) -> Outcome {
let (mut s_cov, mut s_tot) = (0u64, 0u64);
let (mut l_cov, mut l_tot) = (0u64, 0u64);
let (mut b_cov, mut b_tot) = (0u64, 0u64);
let (mut f_cov, mut f_tot) = (0u64, 0u64);
for (file, lines) in changed {
let Some(cov) = detail.get(file) else {
continue;
};
for &(start, end, covered) in &cov.statements {
if (start..=end).any(|line| lines.contains(&line)) {
s_tot += 1;
if covered {
s_cov += 1;
}
}
}
for &line in lines {
let mut starts_here = false;
let mut covered_here = false;
for &(start, _end, covered) in &cov.statements {
if start == line {
starts_here = true;
covered_here |= covered;
}
}
if starts_here {
l_tot += 1;
if covered_here {
l_cov += 1;
}
}
}
for &(source_line, covered) in &cov.branch_arms {
if lines.contains(&source_line) {
b_tot += 1;
if covered {
b_cov += 1;
}
}
}
for &(decl_line, covered) in &cov.functions {
if lines.contains(&decl_line) {
f_tot += 1;
if covered {
f_cov += 1;
}
}
}
}
let pct = |covered: u64, total: u64| {
if total == 0 {
100.0
} else {
100.0 * covered as f64 / total as f64
}
};
let checks = [
("lines", pct(l_cov, l_tot), thresholds.lines),
("branches", pct(b_cov, b_tot), thresholds.branches),
("functions", pct(f_cov, f_tot), thresholds.functions),
("statements", pct(s_cov, s_tot), thresholds.statements),
];
let mut shortfalls = Vec::new();
for (name, actual, required) in checks {
if actual + 1e-9 < f64::from(required) {
shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
}
}
if shortfalls.is_empty() {
Outcome::Pass
} else {
Outcome::Fail(format!(
"coverage below thresholds: {}",
shortfalls.join(", ")
))
}
}
pub fn measure_rust(
root: &Path,
base: &str,
thresholds: RustThresholds,
ignore: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
features: &[String],
) -> Result<Outcome> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| path.ends_with(".rs"));
lift_exempt_lines(&mut changed, exempt_lines);
if changed.is_empty() {
return Ok(Outcome::Pass);
}
let detail = relative_keys(
coverage::measure_patch_rust_detail(root, ignore, features)?,
root,
);
Ok(evaluate_patch_rust(&changed, &detail, thresholds))
}
fn evaluate_patch_rust(
changed: &BTreeMap<String, BTreeSet<u64>>,
detail: &BTreeMap<String, coverage::RustPatchCoverage>,
thresholds: RustThresholds,
) -> Outcome {
let (mut r_cov, mut r_tot) = (0u64, 0u64);
let (mut l_cov, mut l_tot) = (0u64, 0u64);
for (file, lines) in changed {
let Some(cov) = detail.get(file) else {
continue;
};
for &(start, end, covered) in &cov.regions {
if (start..=end).any(|line| lines.contains(&line)) {
r_tot += 1;
if covered {
r_cov += 1;
}
}
}
for &line in lines {
let mut measured = false;
let mut covered_here = false;
for &(start, end, covered) in &cov.regions {
if start <= line && line <= end {
measured = true;
covered_here |= covered;
}
}
if measured {
l_tot += 1;
if covered_here {
l_cov += 1;
}
}
}
}
let pct = |covered: u64, total: u64| {
if total == 0 {
100.0
} else {
100.0 * covered as f64 / total as f64
}
};
let mut checks: Vec<(&str, f64, u8)> = Vec::new();
if let Some(regions) = thresholds.regions {
checks.push(("regions", pct(r_cov, r_tot), regions));
}
checks.push(("lines", pct(l_cov, l_tot), thresholds.lines));
let mut shortfalls = Vec::new();
for (name, actual, required) in checks {
if actual + 1e-9 < f64::from(required) {
shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
}
}
if shortfalls.is_empty() {
Outcome::Pass
} else {
Outcome::Fail(format!(
"coverage below thresholds: {}",
shortfalls.join(", ")
))
}
}
pub fn changed_lines(repo: &Path, base: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
let range = format!("{base}...HEAD");
let output = Command::new("git")
.current_dir(repo)
.args([
"diff",
"--no-color",
"--no-renames",
"--unified=0",
"--relative",
&range,
])
.output()
.with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
if !output.status.success() {
bail!(
"`git diff {range}` failed in `{}`: {}",
repo.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(parse_unified_diff(&String::from_utf8_lossy(&output.stdout)))
}
fn parse_unified_diff(diff: &str) -> BTreeMap<String, BTreeSet<u64>> {
let mut changed: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
let mut current: Option<String> = None;
let mut next_line: u64 = 0;
for line in diff.lines() {
if let Some(header) = line.strip_prefix("+++ ") {
current = new_side_path(header);
} else if line.starts_with("@@") {
if let Some(start) = hunk_new_start(line) {
next_line = start;
}
} else if line.starts_with('+') {
if let Some(file) = ¤t {
changed.entry(file.clone()).or_default().insert(next_line);
}
next_line += 1;
}
}
changed
}
fn new_side_path(header: &str) -> Option<String> {
let path = header
.split('\t')
.next()
.unwrap_or(header)
.trim_end_matches('\r');
if path == "/dev/null" {
return None;
}
let path = path.strip_prefix("b/").unwrap_or(path);
Some(path.replace('\\', "/"))
}
fn hunk_new_start(header: &str) -> Option<u64> {
let plus = header.split_whitespace().find(|t| t.starts_with('+'))?;
let digits = plus.trim_start_matches('+');
digits.split(',').next().unwrap_or(digits).parse().ok()
}
fn relative_keys<V>(files: BTreeMap<String, V>, root: &Path) -> BTreeMap<String, V> {
files
.into_iter()
.map(|(key, value)| {
let path = Path::new(&key);
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
(rel, value)
})
.collect()
}
pub fn measure_line_exempt(
root: &Path,
thresholds: Thresholds,
omit: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<Outcome> {
let report = coverage::measure_report(root, omit)?;
let files = relative_keys(report.files, root);
let detail: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = files
.iter()
.map(|(file, cov)| (file.clone(), python_measured_missed(cov, thresholds.branch)))
.collect();
let line_set = apply_line_exemptions(&detail, exempt_lines)?;
let (covered, total) = python_ratio(&line_set, &files, thresholds.branch);
Ok(floor_outcome(covered, total, thresholds.fail_under))
}
pub fn measure_line_exempt_typescript(
root: &Path,
thresholds: TypeScriptThresholds,
exclude: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<Outcome> {
let detail = relative_keys(
coverage::measure_patch_typescript_detail(root, exclude)?,
root,
);
let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
.iter()
.map(|(file, cov)| (file.clone(), ts_measured_missed(cov)))
.collect();
let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
Ok(evaluate_patch_typescript(&line_set, &detail, thresholds))
}
pub fn measure_line_exempt_rust(
root: &Path,
thresholds: RustThresholds,
ignore: &[String],
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
features: &[String],
) -> Result<Outcome> {
let detail = relative_keys(
coverage::measure_patch_rust_detail(root, ignore, features)?,
root,
);
let measured_missed: BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)> = detail
.iter()
.map(|(file, cov)| (file.clone(), rust_measured_missed(cov, thresholds)))
.collect();
let line_set = apply_line_exemptions(&measured_missed, exempt_lines)?;
Ok(evaluate_patch_rust(&line_set, &detail, thresholds))
}
fn floor_outcome(covered: u64, total: u64, fail_under: u8) -> Outcome {
if total == 0 {
return Outcome::Pass;
}
let actual = 100.0 * covered as f64 / total as f64;
if actual + 1e-9 >= f64::from(fail_under) {
Outcome::Pass
} else {
Outcome::Fail(format!(
"coverage {actual:.2}% is below the required {fail_under}%"
))
}
}
fn python_measured_missed(cov: &FileCoverage, branch: bool) -> (BTreeSet<u64>, BTreeSet<u64>) {
let executed: BTreeSet<u64> = cov.executed_lines.iter().copied().collect();
let missing: BTreeSet<u64> = cov.missing_lines.iter().copied().collect();
let measured: BTreeSet<u64> = executed.union(&missing).copied().collect();
let mut missed = missing;
if branch {
for arc in &cov.missing_branches {
if let Some(src) = arc.first().and_then(|&s| u64::try_from(s).ok()) {
if measured.contains(&src) {
missed.insert(src);
}
}
}
}
(measured, missed)
}
fn ts_measured_missed(cov: &coverage::TsPatchCoverage) -> (BTreeSet<u64>, BTreeSet<u64>) {
let mut measured = BTreeSet::new();
let mut missed = BTreeSet::new();
let units = cov
.statements
.iter()
.flat_map(|&(start, end, covered)| (start..=end).map(move |line| (line, covered)))
.chain(cov.branch_arms.iter().copied())
.chain(cov.functions.iter().copied());
for (line, covered) in units {
measured.insert(line);
if !covered {
missed.insert(line);
}
}
(measured, missed)
}
fn rust_measured_missed(
cov: &coverage::RustPatchCoverage,
thresholds: RustThresholds,
) -> (BTreeSet<u64>, BTreeSet<u64>) {
let mut measured = BTreeSet::new();
for &(start, end, _covered) in &cov.regions {
for line in start..=end {
measured.insert(line);
}
}
let mut missed = BTreeSet::new();
for &line in &measured {
let mut covered_here = false;
let mut uncovered_region = false;
for &(start, end, covered) in &cov.regions {
if start <= line && line <= end {
if covered {
covered_here = true;
} else {
uncovered_region = true;
}
}
}
let is_missed = if thresholds.regions.is_some() {
uncovered_region
} else {
!covered_here
};
if is_missed {
missed.insert(line);
}
}
(measured, missed)
}
fn apply_line_exemptions(
detail: &BTreeMap<String, (BTreeSet<u64>, BTreeSet<u64>)>,
exempt_lines: &BTreeMap<String, BTreeSet<u32>>,
) -> Result<BTreeMap<String, BTreeSet<u64>>> {
let mut over: Vec<String> = Vec::new();
for (file, lines) in exempt_lines {
let missed = detail.get(file).map(|(_, missed)| missed);
for &line in lines {
let failing = missed.is_some_and(|missed| missed.contains(&u64::from(line)));
if !failing {
over.push(format!("\n {file}:{line}"));
}
}
}
if !over.is_empty() {
bail!(
"a line-scoped coverage exemption may only list uncovered lines, but these are \
covered or carry no measured code:{}",
over.concat()
);
}
let mut line_set = BTreeMap::new();
for (file, (measured, _)) in detail {
let exempt = exempt_lines.get(file);
let kept: BTreeSet<u64> = measured
.iter()
.copied()
.filter(|&line| {
!exempt.is_some_and(|exempt| {
u32::try_from(line).is_ok_and(|line| exempt.contains(&line))
})
})
.collect();
line_set.insert(file.clone(), kept);
}
Ok(line_set)
}
#[cfg(test)]
mod tests {
use super::*;
fn changed(entries: &[(&str, &[u64])]) -> BTreeMap<String, BTreeSet<u64>> {
entries
.iter()
.map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
.collect()
}
#[test]
fn parses_added_lines_from_a_hunk() {
let diff = "diff --git a/widget.py b/widget.py\n\
index abc..def 100644\n\
--- a/widget.py\n\
+++ b/widget.py\n\
@@ -3,0 +4,2 @@ def f(x):\n\
+ if x == 99:\n\
+ return 7\n";
assert_eq!(parse_unified_diff(diff), changed(&[("widget.py", &[4, 5])]));
}
#[test]
fn parses_a_new_file_as_added_from_line_one() {
let diff = "diff --git a/lonely.py b/lonely.py\n\
new file mode 100644\n\
index 0000000..bbb\n\
--- /dev/null\n\
+++ b/lonely.py\n\
@@ -0,0 +1,2 @@\n\
+def lonely():\n\
+ return 41\n";
assert_eq!(parse_unified_diff(diff), changed(&[("lonely.py", &[1, 2])]));
}
#[test]
fn a_deletion_only_hunk_records_no_added_lines() {
let diff = "diff --git a/widget.py b/widget.py\n\
index abc..def 100644\n\
--- a/widget.py\n\
+++ b/widget.py\n\
@@ -4,2 +3,0 @@ def f(x):\n\
- dead = 1\n\
- return dead\n";
assert!(parse_unified_diff(diff).is_empty());
}
#[test]
fn a_deleted_file_yields_no_entry() {
let diff = "diff --git a/gone.py b/gone.py\n\
deleted file mode 100644\n\
index abc..0000000\n\
--- a/gone.py\n\
+++ /dev/null\n\
@@ -1,2 +0,0 @@\n\
-def gone():\n\
- return 0\n";
assert!(parse_unified_diff(diff).is_empty());
}
#[test]
fn parses_multiple_files_and_a_single_line_hunk() {
let diff = "diff --git a/a.py b/a.py\n\
--- a/a.py\n\
+++ b/a.py\n\
@@ -1,0 +2 @@ def a():\n\
+ x = 1\n\
diff --git a/pkg/b.py b/pkg/b.py\n\
--- a/pkg/b.py\n\
+++ b/pkg/b.py\n\
@@ -10,0 +11,1 @@\n\
+ y = 2\n";
assert_eq!(
parse_unified_diff(diff),
changed(&[("a.py", &[2]), ("pkg/b.py", &[11])])
);
}
fn cov(
executed: &[u64],
missing: &[u64],
executed_branches: &[[i64; 2]],
missing_branches: &[[i64; 2]],
) -> FileCoverage {
FileCoverage {
executed_lines: executed.to_vec(),
missing_lines: missing.to_vec(),
excluded_lines: Vec::new(),
executed_branches: executed_branches.iter().map(|b| b.to_vec()).collect(),
missing_branches: missing_branches.iter().map(|b| b.to_vec()).collect(),
}
}
const FLOOR_85: Thresholds = Thresholds {
fail_under: 85,
branch: true,
};
#[test]
fn patch_a_fully_covered_diff_passes() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[], &[], &[]))]);
assert_eq!(
evaluate_patch(&changed(&[("w.py", &[1, 2, 3])]), &files, FLOOR_85),
Outcome::Pass
);
}
#[test]
fn patch_below_floor_fails_and_names_the_percent() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
let out = evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, FLOOR_85);
assert!(
matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
"got: {out:?}"
);
}
#[test]
fn patch_the_same_diff_clears_a_lower_floor() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2, 3], &[4], &[], &[]))]);
let floor_70 = Thresholds {
fail_under: 70,
branch: true,
};
assert_eq!(
evaluate_patch(&changed(&[("w.py", &[1, 2, 3, 4])]), &files, floor_70),
Outcome::Pass
);
}
#[test]
fn patch_counts_branch_arcs_whose_source_is_a_changed_line() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
let out = evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, FLOOR_85);
assert!(
matches!(&out, Outcome::Fail(m) if m.contains("75.00%")),
"got: {out:?}"
);
}
#[test]
fn patch_branches_off_ignores_arcs() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[[2, 3]], &[[2, 4]]))]);
let no_branch = Thresholds {
fail_under: 85,
branch: false,
};
assert_eq!(
evaluate_patch(&changed(&[("w.py", &[1, 2])]), &files, no_branch),
Outcome::Pass
);
}
#[test]
fn patch_a_changed_file_absent_from_coverage_is_skipped() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1], &[], &[], &[]))]);
assert_eq!(
evaluate_patch(&changed(&[("w_test.py", &[1, 2])]), &files, FLOOR_85),
Outcome::Pass
);
}
#[test]
fn patch_a_diff_with_no_executable_changed_lines_passes() {
let files = BTreeMap::from([("w.py".to_string(), cov(&[1, 2], &[], &[], &[]))]);
assert_eq!(
evaluate_patch(&changed(&[("w.py", &[9, 10])]), &files, FLOOR_85),
Outcome::Pass
);
}
use coverage::TsPatchCoverage;
fn ts_detail(entries: &[(&str, TsPatchCoverage)]) -> BTreeMap<String, TsPatchCoverage> {
entries
.iter()
.map(|(path, cov)| (path.to_string(), cov.clone()))
.collect()
}
const TS_FLOOR_80: TypeScriptThresholds = TypeScriptThresholds {
lines: 80,
branches: 80,
functions: 80,
statements: 80,
};
#[test]
fn ts_patch_a_fully_covered_diff_passes() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(1, 1, true), (2, 2, true)],
branch_arms: vec![(2, true)],
functions: vec![(1, true)],
},
)]);
assert_eq!(
evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2])]), &detail, TS_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn ts_patch_below_floor_fails_and_names_the_metric() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
branch_arms: vec![],
functions: vec![],
},
)]);
let out =
evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, TS_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("statements 75.00% < 80%")
&& m.contains("lines 75.00% < 80%")
&& !m.contains("branches")
&& !m.contains("functions")),
"got: {out:?}"
);
}
#[test]
fn ts_patch_the_same_diff_clears_a_lower_floor() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
branch_arms: vec![],
functions: vec![],
},
)]);
let floor_70 = TypeScriptThresholds {
lines: 70,
branches: 70,
functions: 70,
statements: 70,
};
assert_eq!(
evaluate_patch_typescript(&changed(&[("w.ts", &[1, 2, 3, 4])]), &detail, floor_70),
Outcome::Pass
);
}
#[test]
fn ts_patch_an_untaken_branch_arm_on_a_changed_line_fails_branches() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(3, 3, true)],
branch_arms: vec![(3, true), (3, false)],
functions: vec![],
},
)]);
let out = evaluate_patch_typescript(&changed(&[("w.ts", &[3])]), &detail, TS_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("branches 50.00% < 80%")
&& !m.contains("lines")
&& !m.contains("statements")),
"got: {out:?}"
);
}
#[test]
fn ts_patch_an_uncovered_function_decl_on_a_changed_line_fails_functions() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![],
branch_arms: vec![],
functions: vec![(9, false)],
},
)]);
let out = evaluate_patch_typescript(&changed(&[("w.ts", &[9])]), &detail, TS_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m) if m.contains("functions 0.00% < 80%")),
"got: {out:?}"
);
}
#[test]
fn ts_patch_a_changed_file_absent_from_coverage_is_skipped() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(1, 1, true)],
branch_arms: vec![],
functions: vec![],
},
)]);
assert_eq!(
evaluate_patch_typescript(&changed(&[("w.test.ts", &[1, 2])]), &detail, TS_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn ts_patch_a_comment_only_diff_passes() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(1, 1, true), (2, 2, true)],
branch_arms: vec![(2, true)],
functions: vec![(1, true)],
},
)]);
assert_eq!(
evaluate_patch_typescript(&changed(&[("w.ts", &[9, 10])]), &detail, TS_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn ts_patch_an_empty_diff_passes() {
assert_eq!(
evaluate_patch_typescript(&changed(&[]), &BTreeMap::new(), TS_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn ts_patch_a_multiline_statement_counts_when_any_of_its_lines_changed() {
let detail = ts_detail(&[(
"w.ts",
TsPatchCoverage {
statements: vec![(3, 5, false)],
branch_arms: vec![],
functions: vec![],
},
)]);
let out = evaluate_patch_typescript(&changed(&[("w.ts", &[4])]), &detail, TS_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("statements 0.00% < 80%") && !m.contains("lines")),
"got: {out:?}"
);
}
use coverage::RustPatchCoverage;
fn rust_detail(entries: &[(&str, RustPatchCoverage)]) -> BTreeMap<String, RustPatchCoverage> {
entries
.iter()
.map(|(path, cov)| (path.to_string(), cov.clone()))
.collect()
}
const RUST_FLOOR_80: RustThresholds = RustThresholds {
regions: Some(80),
lines: 80,
functions: None,
branch: None,
};
#[test]
fn rust_patch_a_fully_covered_diff_passes() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(1, 1, true), (2, 2, true)],
},
)]);
assert_eq!(
evaluate_patch_rust(&changed(&[("w.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn rust_patch_below_floor_fails_and_names_the_metrics() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
},
)]);
let out = evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, RUST_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("regions 75.00% < 80%")
&& m.contains("lines 75.00% < 80%")),
"got: {out:?}"
);
}
#[test]
fn rust_patch_the_same_diff_clears_a_lower_floor() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(1, 1, true), (2, 2, true), (3, 3, true), (4, 4, false)],
},
)]);
let floor_70 = RustThresholds {
regions: Some(70),
lines: 70,
functions: None,
branch: None,
};
assert_eq!(
evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
Outcome::Pass
);
}
#[test]
fn rust_patch_skips_the_region_check_when_regions_is_opt_out() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(1, 4, true), (4, 4, false)],
},
)]);
let lines_only = RustThresholds {
regions: None,
lines: 100,
functions: None,
branch: None,
};
assert_eq!(
evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, lines_only),
Outcome::Pass
);
}
#[test]
fn rust_patch_an_uncovered_region_on_a_changed_line_fails_both_metrics() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(5, 5, false)],
},
)]);
let out = evaluate_patch_rust(&changed(&[("w.rs", &[5])]), &detail, RUST_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
"got: {out:?}"
);
}
#[test]
fn rust_patch_a_changed_file_absent_from_coverage_is_skipped() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(1, 1, true)],
},
)]);
assert_eq!(
evaluate_patch_rust(&changed(&[("other.rs", &[1, 2])]), &detail, RUST_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn rust_patch_a_comment_only_diff_passes() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(1, 1, true), (2, 2, true)],
},
)]);
assert_eq!(
evaluate_patch_rust(&changed(&[("w.rs", &[9, 10])]), &detail, RUST_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn rust_patch_an_empty_diff_passes() {
assert_eq!(
evaluate_patch_rust(&changed(&[]), &BTreeMap::new(), RUST_FLOOR_80),
Outcome::Pass
);
}
#[test]
fn rust_patch_a_multiline_region_counts_when_any_of_its_lines_changed() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(3, 5, false)],
},
)]);
let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("regions 0.00% < 80%") && m.contains("lines 0.00% < 80%")),
"got: {out:?}"
);
}
#[test]
fn rust_patch_a_line_covered_by_any_region_is_covered() {
let detail = rust_detail(&[(
"w.rs",
RustPatchCoverage {
regions: vec![(4, 4, false), (4, 6, true)],
},
)]);
let out = evaluate_patch_rust(&changed(&[("w.rs", &[4])]), &detail, RUST_FLOOR_80);
assert!(
matches!(&out, Outcome::Fail(m)
if m.contains("regions 50.00% < 80%") && !m.contains("lines")),
"got: {out:?}"
);
}
fn exempt(entries: &[(&str, &[u32])]) -> BTreeMap<String, BTreeSet<u32>> {
entries
.iter()
.map(|(path, lines)| (path.to_string(), lines.iter().copied().collect()))
.collect()
}
#[test]
fn python_measured_missed_reads_lines_and_branch_sources() {
let full = cov(&[1], &[2, 3, 4], &[], &[[2, 3], [2, 4]]);
let (measured, missed) = python_measured_missed(&full, true);
assert_eq!(measured, [1, 2, 3, 4].into_iter().collect());
assert_eq!(missed, [2, 3, 4].into_iter().collect());
let partial = cov(&[5], &[], &[], &[[5, 6]]);
let (_, missed_no_branch) = python_measured_missed(&partial, false);
assert!(missed_no_branch.is_empty());
let (_, missed_branch) = python_measured_missed(&partial, true);
assert_eq!(missed_branch, [5].into_iter().collect());
}
#[test]
fn ts_measured_missed_anchors_units_on_their_lines() {
let cov = coverage::TsPatchCoverage {
statements: vec![(1, 1, true), (3, 4, false)],
branch_arms: vec![(1, false)],
functions: vec![(6, false)],
};
let (measured, missed) = ts_measured_missed(&cov);
assert_eq!(measured, [1, 3, 4, 6].into_iter().collect());
assert_eq!(missed, [1, 3, 4, 6].into_iter().collect());
}
#[test]
fn rust_measured_missed_honors_the_enforced_metrics() {
let cov = coverage::RustPatchCoverage {
regions: vec![(1, 1, true), (5, 6, false)],
};
let with_regions = RustThresholds {
regions: Some(100),
lines: 100,
functions: None,
branch: None,
};
let (measured, missed) = rust_measured_missed(&cov, with_regions);
assert_eq!(measured, [1, 5, 6].into_iter().collect());
assert_eq!(missed, [5, 6].into_iter().collect());
let lines_only = RustThresholds {
regions: None,
lines: 100,
functions: None,
branch: None,
};
let (_, missed_lines) = rust_measured_missed(&cov, lines_only);
assert_eq!(missed_lines, [5, 6].into_iter().collect());
}
#[test]
fn apply_line_exemptions_drops_listed_misses_from_the_line_set() {
let detail = BTreeMap::from([(
"shim.py".to_string(),
(
[1u64, 2, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
[2u64, 3, 4].into_iter().collect::<BTreeSet<u64>>(),
),
)]);
let line_set = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[2, 3, 4])])).unwrap();
assert_eq!(line_set["shim.py"], [1].into_iter().collect());
}
#[test]
fn apply_line_exemptions_rejects_a_covered_listed_line() {
let detail = BTreeMap::from([(
"shim.py".to_string(),
(
[1u64, 2].into_iter().collect::<BTreeSet<u64>>(),
[2u64].into_iter().collect::<BTreeSet<u64>>(),
),
)]);
let err = apply_line_exemptions(&detail, &exempt(&[("shim.py", &[1, 2])])).unwrap_err();
assert!(
err.to_string().contains("uncovered lines") && err.to_string().contains("shim.py:1"),
"got: {err}"
);
}
#[test]
fn apply_line_exemptions_rejects_an_unmeasured_listed_line() {
let detail = BTreeMap::from([(
"w.py".to_string(),
(
[2u64].into_iter().collect::<BTreeSet<u64>>(),
[2u64].into_iter().collect::<BTreeSet<u64>>(),
),
)]);
let err = apply_line_exemptions(&detail, &exempt(&[("w.py", &[9])])).unwrap_err();
assert!(err.to_string().contains("w.py:9"), "got: {err}");
}
#[test]
fn floor_outcome_matches_the_whole_tree_message() {
assert_eq!(floor_outcome(7, 7, 100), Outcome::Pass);
let out = floor_outcome(7, 8, 100);
assert!(
matches!(&out, Outcome::Fail(m) if m == "coverage 87.50% is below the required 100%"),
"got: {out:?}"
);
assert_eq!(floor_outcome(0, 0, 100), Outcome::Pass);
}
#[test]
fn lift_exempt_lines_removes_exempt_lines_from_the_diff() {
let mut changed = changed(&[("shim.py", &[1, 2, 3, 4]), ("core.py", &[5])]);
lift_exempt_lines(
&mut changed,
&exempt(&[("shim.py", &[2, 3]), ("gone.py", &[9])]),
);
assert_eq!(changed["shim.py"], [1, 4].into_iter().collect());
assert_eq!(changed["core.py"], [5].into_iter().collect());
}
}