use serde::{Deserialize, Serialize};
use super::contract::{ContractCheck, OutcomeContract};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckOverlap {
pub check: String,
pub paths: Vec<String>,
}
const PATHY_EXTENSIONS: [&str; 12] = [
".py", ".rs", ".ts", ".tsx", ".js", ".jsx", ".go", ".java", ".rb", ".swift", ".kt", ".c",
];
fn normalize_token(raw: &str) -> Option<String> {
let mut t = raw.trim();
t = t.trim_matches(|c| c == '"' || c == '\'');
if let Some((head, _)) = t.split_once("::") {
t = head;
}
if let Some((head, tail)) = t.rsplit_once(':') {
if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
t = head;
}
}
t = t.trim_start_matches("./");
let lower = t.to_ascii_lowercase();
let pathy = t.contains('/') || PATHY_EXTENSIONS.iter().any(|e| lower.ends_with(e));
t = t.trim_end_matches('/');
if t.is_empty() || t.starts_with('-') {
return None;
}
pathy.then(|| t.to_string())
}
pub fn referenced_paths(command: &str) -> Vec<String> {
let mut out: Vec<String> = command
.split(|c: char| c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')' | '&'))
.filter_map(normalize_token)
.collect();
out.sort();
out.dedup();
out
}
fn covers(referenced: &str, changed: &str) -> bool {
changed == referenced || changed.starts_with(&format!("{referenced}/"))
}
pub fn contract_overlap(contract: &OutcomeContract, changed_paths: &[String]) -> Vec<CheckOverlap> {
contract
.checks
.iter()
.filter_map(|check| {
let overlap = overlapping_paths(check, changed_paths);
(!overlap.is_empty()).then(|| CheckOverlap {
check: check.name.clone(),
paths: overlap,
})
})
.collect()
}
fn overlapping_paths(check: &ContractCheck, changed_paths: &[String]) -> Vec<String> {
let referenced = referenced_paths(&check.command);
let mut hits: Vec<String> = changed_paths
.iter()
.filter(|changed| referenced.iter().any(|r| covers(r, changed)))
.cloned()
.collect();
hits.sort();
hits.dedup();
hits
}
pub fn disclosure(overlap: &[CheckOverlap]) -> Option<String> {
if overlap.is_empty() {
return None;
}
let parts: Vec<String> = overlap
.iter()
.map(|o| format!("{} (run by check `{}`)", o.paths.join(", "), o.check))
.collect();
Some(format!(
"This session modified files that its own contract executes: {}. \
That is often legitimate — editing tests is frequently the task — but it \
means those checks are not an independent verdict on this change.",
parts.join("; ")
))
}
#[cfg(test)]
mod tests {
use super::*;
fn check(name: &str, command: &str) -> ContractCheck {
ContractCheck {
name: name.into(),
command: command.into(),
expect_exit_zero: true,
output_contains: None,
timeout_secs: 120,
}
}
fn contract(checks: Vec<ContractCheck>) -> OutcomeContract {
OutcomeContract {
description: "d".into(),
checks,
}
}
#[test]
fn extracts_paths_and_ignores_flags_and_bare_words() {
let paths = referenced_paths("python -m pytest -q tests/test_x.py --maxfail=1");
assert_eq!(paths, vec!["tests/test_x.py"]);
}
#[test]
fn strips_pytest_node_ids_quotes_and_line_suffixes() {
assert_eq!(
referenced_paths("pytest 'tests/test_x.py::test_a'"),
vec!["tests/test_x.py"]
);
assert_eq!(
referenced_paths("pytest \"tests/test_x.py\""),
vec!["tests/test_x.py"]
);
assert_eq!(referenced_paths("vet src/main.rs:42"), vec!["src/main.rs"]);
}
#[test]
fn a_bare_source_file_counts_even_without_a_separator() {
assert_eq!(referenced_paths("pytest test_x.py"), vec!["test_x.py"]);
assert!(referenced_paths("cargo test").is_empty());
}
#[test]
fn splits_on_shell_operators_so_chained_commands_are_covered() {
let paths = referenced_paths("cargo build && pytest tests/test_x.py | tee out.log");
assert!(paths.contains(&"tests/test_x.py".to_string()));
}
#[test]
fn a_diff_touching_a_contract_executed_file_is_flagged() {
let c = contract(vec![check("run_failing_test", "pytest tests/test_x.py")]);
let changed = vec!["tests/test_x.py".to_string(), "src/lib.rs".to_string()];
let overlap = contract_overlap(&c, &changed);
assert_eq!(
overlap,
vec![CheckOverlap {
check: "run_failing_test".into(),
paths: vec!["tests/test_x.py".into()],
}]
);
assert!(disclosure(&overlap).unwrap().contains("tests/test_x.py"));
}
#[test]
fn a_diff_touching_only_source_is_not_flagged() {
let c = contract(vec![check("run_failing_test", "pytest tests/test_x.py")]);
let changed = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
assert!(contract_overlap(&c, &changed).is_empty());
assert!(disclosure(&[]).is_none());
}
#[test]
fn a_directory_reference_covers_files_under_it() {
let c = contract(vec![check("suite", "pytest tests/")]);
let changed = vec!["tests/unit/test_x.py".to_string()];
assert_eq!(
contract_overlap(&c, &changed)[0].paths,
vec!["tests/unit/test_x.py"]
);
}
#[test]
fn a_prefix_that_is_not_a_path_segment_does_not_match() {
let c = contract(vec![check("suite", "pytest tests/")]);
let changed = vec!["tests_helpers/util.py".to_string()];
assert!(contract_overlap(&c, &changed).is_empty());
}
#[test]
fn several_checks_each_report_their_own_paths() {
let c = contract(vec![
check("unit", "pytest tests/test_x.py"),
check("lint", "ruff check src/"),
check("unrelated", "cargo build"),
]);
let changed = vec![
"tests/test_x.py".to_string(),
"src/app.py".to_string(),
"README.md".to_string(),
];
let overlap = contract_overlap(&c, &changed);
assert_eq!(
overlap.len(),
2,
"the `cargo build` check references no path"
);
assert_eq!(overlap[0].check, "unit");
assert_eq!(overlap[0].paths, vec!["tests/test_x.py"]);
assert_eq!(overlap[1].check, "lint");
assert_eq!(overlap[1].paths, vec!["src/app.py"]);
}
#[test]
fn disclosure_reads_as_a_fact_not_an_accusation() {
let overlap = vec![CheckOverlap {
check: "unit".into(),
paths: vec!["tests/test_x.py".into()],
}];
let text = disclosure(&overlap).unwrap();
assert!(text.contains("often legitimate"));
assert!(text.contains("not an independent verdict"));
}
}