use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::coverage::{self, FileCoverage};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Uncovered {
pub file: String,
pub line: u64,
}
pub fn check(root: &Path, base: &str, omit: &[String]) -> Result<Vec<Uncovered>> {
let mut changed = changed_lines(root, base)?;
changed.retain(|path, _| path.ends_with(".py"));
if changed.is_empty() {
return Ok(Vec::new());
}
let report = coverage::measure_patch_report(root, omit)?;
let files = relative_keys(report.files, root);
Ok(uncovered_changed_lines(&changed, &files))
}
const TS_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"];
pub fn check_typescript(root: &Path, base: &str, exclude: &[String]) -> Result<Vec<Uncovered>> {
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(Vec::new());
}
let uncovered = relative_keys(coverage::measure_patch_typescript(root, exclude)?, root);
Ok(uncovered_changed_lines_ts(&changed, &uncovered))
}
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()
}
pub fn uncovered_changed_lines(
changed: &BTreeMap<String, BTreeSet<u64>>,
files: &BTreeMap<String, FileCoverage>,
) -> Vec<Uncovered> {
let mut uncovered = Vec::new();
for (file, lines) in changed {
let Some(coverage) = files.get(file) else {
continue;
};
let missing: BTreeSet<u64> = coverage.missing_lines.iter().copied().collect();
let branch_sources: BTreeSet<u64> = coverage
.missing_branches
.iter()
.filter_map(|pair| pair.first().copied())
.filter_map(|src| u64::try_from(src).ok())
.collect();
for &line in lines {
if missing.contains(&line) || branch_sources.contains(&line) {
uncovered.push(Uncovered {
file: file.clone(),
line,
});
}
}
}
uncovered.sort();
uncovered
}
pub fn uncovered_changed_lines_ts(
changed: &BTreeMap<String, BTreeSet<u64>>,
uncovered: &BTreeMap<String, BTreeSet<u64>>,
) -> Vec<Uncovered> {
let mut out = Vec::new();
for (file, lines) in changed {
let Some(uncovered_lines) = uncovered.get(file) else {
continue;
};
for &line in lines {
if uncovered_lines.contains(&line) {
out.push(Uncovered {
file: file.clone(),
line,
});
}
}
}
out.sort();
out
}
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()
}
fn file_coverage(missing_lines: &[u64], missing_branches: &[[i64; 2]]) -> FileCoverage {
FileCoverage {
executed_lines: Vec::new(),
missing_lines: missing_lines.to_vec(),
excluded_lines: Vec::new(),
missing_branches: missing_branches.iter().map(|b| b.to_vec()).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])])
);
}
#[test]
fn a_missing_changed_line_is_uncovered() {
let out = uncovered_changed_lines(
&changed(&[("widget.py", &[5])]),
&BTreeMap::from([("widget.py".to_string(), file_coverage(&[5], &[]))]),
);
assert_eq!(
out,
vec![Uncovered {
file: "widget.py".to_string(),
line: 5
}]
);
}
#[test]
fn a_covered_changed_line_is_not_reported() {
let out = uncovered_changed_lines(
&changed(&[("widget.py", &[3])]),
&BTreeMap::from([("widget.py".to_string(), file_coverage(&[5], &[[4, 5]]))]),
);
assert!(out.is_empty());
}
#[test]
fn a_changed_branch_source_is_uncovered() {
let out = uncovered_changed_lines(
&changed(&[("widget.py", &[4])]),
&BTreeMap::from([("widget.py".to_string(), file_coverage(&[5], &[[4, 5]]))]),
);
assert_eq!(
out,
vec![Uncovered {
file: "widget.py".to_string(),
line: 4
}]
);
}
#[test]
fn a_negative_branch_dest_is_ignored() {
let out = uncovered_changed_lines(
&changed(&[("widget.py", &[6])]),
&BTreeMap::from([("widget.py".to_string(), file_coverage(&[], &[[6, -1]]))]),
);
assert_eq!(
out,
vec![Uncovered {
file: "widget.py".to_string(),
line: 6
}]
);
}
#[test]
fn a_changed_file_absent_from_coverage_is_skipped() {
let out = uncovered_changed_lines(
&changed(&[("widget_test.py", &[1, 2])]),
&BTreeMap::from([("widget.py".to_string(), file_coverage(&[5], &[]))]),
);
assert!(out.is_empty());
}
#[test]
fn reports_are_sorted_across_files_and_lines() {
let out = uncovered_changed_lines(
&changed(&[("z.py", &[2, 1]), ("a.py", &[9])]),
&BTreeMap::from([
("z.py".to_string(), file_coverage(&[1, 2], &[])),
("a.py".to_string(), file_coverage(&[9], &[])),
]),
);
assert_eq!(
out,
vec![
Uncovered {
file: "a.py".to_string(),
line: 9
},
Uncovered {
file: "z.py".to_string(),
line: 1
},
Uncovered {
file: "z.py".to_string(),
line: 2
},
]
);
}
#[test]
fn ts_a_changed_uncovered_line_is_reported() {
let out = uncovered_changed_lines_ts(
&changed(&[("widget.ts", &[4])]),
&changed(&[("widget.ts", &[3, 4, 5])]),
);
assert_eq!(
out,
vec![Uncovered {
file: "widget.ts".to_string(),
line: 4
}]
);
}
#[test]
fn ts_a_covered_changed_line_is_not_reported() {
let out = uncovered_changed_lines_ts(
&changed(&[("widget.ts", &[2])]),
&changed(&[("widget.ts", &[3, 4, 5])]),
);
assert!(out.is_empty());
}
#[test]
fn ts_a_changed_file_absent_from_coverage_is_skipped() {
let out = uncovered_changed_lines_ts(
&changed(&[("widget.test.ts", &[1, 2])]),
&changed(&[("widget.ts", &[5])]),
);
assert!(out.is_empty());
}
#[test]
fn ts_reports_are_sorted_across_files_and_lines() {
let out = uncovered_changed_lines_ts(
&changed(&[("z.ts", &[2, 1]), ("a.ts", &[9])]),
&changed(&[("z.ts", &[1, 2]), ("a.ts", &[9])]),
);
assert_eq!(
out,
vec![
Uncovered {
file: "a.ts".to_string(),
line: 9
},
Uncovered {
file: "z.ts".to_string(),
line: 1
},
Uncovered {
file: "z.ts".to_string(),
line: 2
},
]
);
}
}