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],
) -> Result<Outcome> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| path.ends_with(".py"));
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 evaluate_patch(
changed: &BTreeMap<String, BTreeSet<u64>>,
files: &BTreeMap<String, FileCoverage>,
thresholds: Thresholds,
) -> Outcome {
let mut covered: u64 = 0;
let mut total: u64 = 0;
for (file, lines) in changed {
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 thresholds.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;
}
}
}
}
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 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],
) -> Result<Outcome> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| TS_EXTENSIONS.iter().any(|ext| path.ends_with(ext)));
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],
) -> Result<Outcome> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| path.ends_with(".rs"));
if changed.is_empty() {
return Ok(Outcome::Pass);
}
let detail = relative_keys(coverage::measure_patch_rust_detail(root, ignore)?, 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 checks = [
("regions", pct(r_cov, r_tot), thresholds.regions),
("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()
}
#[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: 80,
lines: 80,
};
#[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: 70,
lines: 70,
};
assert_eq!(
evaluate_patch_rust(&changed(&[("w.rs", &[1, 2, 3, 4])]), &detail, floor_70),
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:?}"
);
}
}