use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use sinter_core::{Node, SymbolKind};
use sinter_resolve::qualified_of;
use sinter_store::EdgeFilter;
use crate::lookup::open_store;
#[derive(Serialize)]
pub struct ImpactReport {
pub rev_range: String,
pub working_tree_dirty: bool,
pub changed_symbols: Vec<SymbolRef>,
pub blast_radius: Vec<SymbolRef>,
pub affected_tests: Vec<SymbolRef>,
}
#[derive(Serialize, Clone)]
pub struct SymbolRef {
pub qualified: String,
pub kind: &'static str,
pub file: String,
}
fn symbol_ref(node: &Node) -> SymbolRef {
SymbolRef {
qualified: qualified_of(node.id.as_str()).to_string(),
kind: node.kind.as_str(),
file: node.file.clone(),
}
}
pub fn is_test(node: &Node) -> bool {
let f = &node.file;
f.ends_with("_test.go")
|| f.ends_with("_test.py")
|| f.ends_with("Tests.cs")
|| f.contains(".test.")
|| f.contains(".spec.")
|| f.starts_with("tests/")
|| f.contains("/tests/")
|| f.contains("/test/")
|| node.name.starts_with("test_")
|| node.name.starts_with("Test")
|| qualified_of(node.id.as_str()).split("::").any(|s| s == "tests")
}
pub fn compute(repo: &Path, rev_range: &str) -> Result<ImpactReport> {
compute_filtered(repo, rev_range, &EdgeFilter::default())
}
pub fn compute_filtered(repo: &Path, rev_range: &str, filter: &EdgeFilter) -> Result<ImpactReport> {
let repo = repo.canonicalize()?;
let store = open_store(&repo)?;
let working_tree_dirty = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&repo)
.output()
.is_ok_and(|s| s.status.success() && !s.stdout.is_empty());
let output = Command::new("git")
.args([
"-c",
"diff.noprefix=false",
"diff",
"--no-ext-diff",
"-U0",
"--no-color",
rev_range,
])
.current_dir(&repo)
.output()
.context("run git diff")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_lowercase().contains("not a git repository") {
bail!("not a git repository — impact needs git history");
}
bail!(
"git diff {rev_range} failed: {}",
stderr.lines().next().unwrap_or("").trim()
);
}
let mut hunks: BTreeMap<String, Vec<(usize, usize)>> = BTreeMap::new();
let mut current: Option<String> = None;
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
current = rest.strip_prefix("b/").map(str::to_string);
} else if let (Some(file), Some(rest)) = (¤t, line.strip_prefix("@@ ")) {
if let Some(plus) = rest.split_whitespace().find(|t| t.starts_with('+')) {
let mut parts = plus[1..].splitn(2, ',');
let start: usize = parts.next().unwrap_or("0").parse().unwrap_or(0);
let count: usize = parts.next().map_or(1, |c| c.parse().unwrap_or(1));
if start > 0 {
hunks
.entry(file.clone())
.or_default()
.push((start, count.max(1)));
}
}
}
}
let mut changed: Vec<Node> = Vec::new();
for (file, ranges) in &hunks {
let Some(facts) = store.facts(file)? else {
continue; };
let Ok(source) = std::fs::read_to_string(repo.join(file)) else {
continue;
};
let mut line_starts = vec![0u64];
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
line_starts.push(i as u64 + 1);
}
}
let byte_range = |line: usize, count: usize| -> (u64, u64) {
let start = line_starts
.get(line - 1)
.copied()
.unwrap_or(source.len() as u64);
let end = line_starts
.get(line - 1 + count)
.copied()
.unwrap_or(source.len() as u64);
(start, end)
};
for node in &facts.nodes {
if node.kind == SymbolKind::File {
continue;
}
let touched = ranges.iter().any(|&(l, c)| {
let (s, e) = byte_range(l, c);
node.span.start < e && s < node.span.end
});
if touched {
changed.push(node.clone());
}
}
}
let mut radius: BTreeMap<String, Node> = BTreeMap::new();
for node in &changed {
for reached in store.dependents(&node.id, filter, 25)? {
radius.insert(reached.node.id.as_str().to_string(), reached.node);
}
}
for node in &changed {
radius.remove(node.id.as_str());
}
let affected_tests: Vec<SymbolRef> = radius
.values()
.chain(changed.iter())
.filter(|n| is_test(n))
.map(symbol_ref)
.collect();
Ok(ImpactReport {
rev_range: rev_range.to_string(),
working_tree_dirty,
changed_symbols: changed.iter().map(symbol_ref).collect(),
blast_radius: radius.values().map(symbol_ref).collect(),
affected_tests,
})
}
pub fn run(
repo: &Path,
rev_range: &str,
manifest: Option<&Path>,
evidence: &[String],
certain: bool,
json: bool,
) -> Result<()> {
let filter = crate::lookup::edge_filter(evidence, certain)?;
let mut report = compute_filtered(repo, rev_range, &filter)?;
if let Some(manifest) = manifest {
let ws = crate::workspace::load(manifest)?;
let repo_canon = repo.canonicalize()?;
let member = ws
.members
.iter()
.find(|(_, path)| **path == repo_canon)
.map(|(name, _)| name.clone())
.ok_or_else(|| anyhow::anyhow!("--repo is not a member of this workspace"))?;
let changed_ids: Vec<sinter_core::NodeId> = {
let store = open_store(&repo_canon)?;
report
.changed_symbols
.iter()
.filter_map(|c| {
crate::lookup::unique_symbol(&store, &c.qualified)
.ok()
.map(|n| n.id)
})
.collect()
};
let mut cross: std::collections::BTreeMap<String, SymbolRef> =
std::collections::BTreeMap::new();
for node_id in &changed_ids {
for reached in crate::workspace::dependents(&ws, &member, node_id, &filter, 25)? {
if reached.member == member {
continue; }
let key = format!("{}:{}", reached.member, reached.node.id.as_str());
let mut sym = symbol_ref(&reached.node);
sym.file = format!("{}:{}", reached.member, sym.file);
if is_test(&reached.node) {
report.affected_tests.push(sym.clone());
}
cross.insert(key, sym);
}
}
report.blast_radius.extend(cross.into_values());
}
if json {
println!("{}", serde_json::to_string_pretty(&to_json(&report))?);
return Ok(());
}
println!(
"impact {}: {} changed symbols, {} in blast radius, {} tests affected",
report.rev_range,
report.changed_symbols.len(),
report.blast_radius.len(),
report.affected_tests.len()
);
if report.working_tree_dirty {
println!(
" note: working tree has uncommitted changes — spans match the current tree, so totals may include drift beyond {}",
report.rev_range
);
}
println!("changed:");
for s in &report.changed_symbols {
println!(" {} {} {}", s.kind, s.qualified, s.file);
}
println!("blast radius:");
for s in &report.blast_radius {
println!(" {} {} {}", s.kind, s.qualified, s.file);
}
println!("affected tests:");
for s in &report.affected_tests {
println!(" {} {} {}", s.kind, s.qualified, s.file);
}
Ok(())
}
pub fn to_json(report: &ImpactReport) -> serde_json::Value {
serde_json::to_value(report).expect("impact report serializes")
}
#[cfg(test)]
mod tests {
use super::is_test;
use sinter_core::{Node, NodeId, Span, SymbolKind};
fn node(id: &str, name: &str, file: &str) -> Node {
Node {
id: NodeId::new(id),
kind: SymbolKind::Function,
name: name.to_string(),
file: file.to_string(),
span: Span { start: 0, end: 1 },
signature: String::new(),
doc: None,
}
}
#[test]
fn detects_conventional_test_files_and_names() {
for (id, name, file) in [
("a_test.go#f", "f", "a_test.go"), ("a_test.py#f", "f", "a_test.py"), ("FooTests.cs#F", "F", "FooTests.cs"), ("app.test.ts#f", "f", "app.test.ts"), ("app.spec.js#f", "f", "app.spec.js"), ("tests/x.rs#f", "f", "tests/x.rs"), ("crate/tests/x.rs#f", "f", "crate/tests/x.rs"), ("pkg/test/x.py#f", "f", "pkg/test/x.py"), ("m.py#test_f", "test_f", "m.py"), ("M.go#TestF", "TestF", "M.go"), ("src/lib.rs#tests::works", "works", "src/lib.rs"), ] {
assert!(is_test(&node(id, name, file)), "{file} {name}");
}
}
#[test]
fn plain_symbols_are_not_tests() {
for (id, name, file) in [
("src/lib.rs#build", "build", "src/lib.rs"),
("src/attest.rs#attest", "attest", "src/attest.rs"),
("contest.py#run", "run", "contest.py"),
] {
assert!(!is_test(&node(id, name, file)), "{file} {name}");
}
}
}