use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::Serialize;
use crate::cache::ScanCache;
use crate::extract::{RefKind, SymKind, Vis};
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ImpactPath {
pub path: String,
pub distance: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ImpactDefinition {
pub path: String,
pub symbol: String,
pub line: u32,
pub end_line: u32,
pub kind: SymKind,
pub visibility: Vis,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ImpactEdge {
pub from: String,
pub from_symbol: Option<String>,
pub to: String,
pub to_symbol: String,
pub reference_line: u32,
pub kind: RefKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct HistoricalImpact {
pub commits_scanned: usize,
pub seed_commits: usize,
pub candidates: Vec<crate::gitfacts::HistoryCoChange>,
pub omitted: usize,
pub bulk_commits_skipped: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ImpactReport {
pub base: String,
pub depth: usize,
pub changed: Vec<String>,
pub changed_definitions: Vec<ImpactDefinition>,
pub direct_edges: Vec<ImpactEdge>,
pub callers: Vec<ImpactPath>,
pub dependencies: Vec<ImpactPath>,
pub tests: Vec<String>,
pub unresolved_refs: usize,
pub ambiguous_refs: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub history: Option<HistoricalImpact>,
}
pub fn analyze(
cache: &ScanCache,
base: impl Into<String>,
changed: impl IntoIterator<Item = String>,
depth: usize,
) -> ImpactReport {
let changed: BTreeSet<String> = changed.into_iter().collect();
let mut definitions_by_path: BTreeMap<String, Vec<ImpactDefinition>> = BTreeMap::new();
let mut definers: BTreeMap<String, Vec<ImpactDefinition>> = BTreeMap::new();
for (path, entry) in &cache.files {
let Some(lang) = entry.lang else { continue };
let Some(extraction) = cache.parses.get(&(lang, entry.hash)) else {
continue;
};
for definition in &extraction.defs {
let definition = ImpactDefinition {
path: path.clone(),
symbol: definition.name.clone(),
line: definition.line,
end_line: definition.end_line,
kind: definition.kind,
visibility: definition.vis,
};
definitions_by_path
.entry(path.clone())
.or_default()
.push(definition.clone());
if definition.visibility == Vis::Pub {
definers
.entry(definition.symbol.clone())
.or_default()
.push(definition);
}
}
}
let mut dependencies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut callers: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut direct_edges = Vec::new();
let mut unresolved_refs = 0usize;
let mut ambiguous_refs = 0usize;
for (path, entry) in &cache.files {
let Some(lang) = entry.lang else { continue };
let Some(extraction) = cache.parses.get(&(lang, entry.hash)) else {
continue;
};
for reference in &extraction.refs {
let Some(targets) = definers.get(&reference.name) else {
if changed.contains(path) {
unresolved_refs += 1;
}
continue;
};
let targets: Vec<&ImpactDefinition> = targets
.iter()
.filter(|target| target.path != *path)
.collect();
let target_paths: BTreeSet<&str> =
targets.iter().map(|target| target.path.as_str()).collect();
if changed.contains(path) && target_paths.len() > 1 {
ambiguous_refs += 1;
}
for target in targets {
dependencies
.entry(path.clone())
.or_default()
.insert(target.path.clone());
callers
.entry(target.path.clone())
.or_default()
.insert(path.clone());
if changed.contains(path) || changed.contains(&target.path) {
direct_edges.push(ImpactEdge {
from: path.clone(),
from_symbol: definitions_by_path
.get(path)
.and_then(|defs| enclosing_definition(defs, reference.line))
.map(|definition| definition.symbol.clone()),
to: target.path.clone(),
to_symbol: target.symbol.clone(),
reference_line: reference.line,
kind: reference.kind,
});
}
}
}
}
let mut changed_definitions: Vec<ImpactDefinition> = changed
.iter()
.flat_map(|path| definitions_by_path.get(path).into_iter().flatten().cloned())
.collect();
changed_definitions.sort();
direct_edges.sort();
direct_edges.dedup();
let caller_paths = reachable(&callers, &changed, depth);
let dependency_paths = reachable(&dependencies, &changed, depth);
let mut tests: BTreeSet<String> = changed
.iter()
.filter(|path| is_test_path(path))
.cloned()
.collect();
tests.extend(
caller_paths
.iter()
.filter(|entry| is_test_path(&entry.path))
.map(|entry| entry.path.clone()),
);
ImpactReport {
base: base.into(),
depth,
changed: changed.into_iter().collect(),
changed_definitions,
direct_edges,
callers: caller_paths,
dependencies: dependency_paths,
tests: tests.into_iter().collect(),
unresolved_refs,
ambiguous_refs,
history: None,
}
}
pub fn attach_history(report: &mut ImpactReport, facts: crate::gitfacts::HistoryFacts) {
report.history = Some(HistoricalImpact {
commits_scanned: facts.commits_scanned,
seed_commits: facts.seed_commits,
candidates: facts.candidates,
omitted: facts.omitted,
bulk_commits_skipped: facts.bulk_commits_skipped,
});
}
fn enclosing_definition(definitions: &[ImpactDefinition], line: u32) -> Option<&ImpactDefinition> {
definitions
.iter()
.filter(|definition| definition.line <= line && line <= definition.end_line)
.min_by_key(|definition| {
(
definition.end_line.saturating_sub(definition.line),
definition.line,
definition.symbol.as_str(),
)
})
}
fn reachable(
graph: &BTreeMap<String, BTreeSet<String>>,
starts: &BTreeSet<String>,
depth: usize,
) -> Vec<ImpactPath> {
if depth == 0 {
return Vec::new();
}
let mut distances: BTreeMap<String, usize> = BTreeMap::new();
let mut queue = VecDeque::new();
for start in starts {
distances.insert(start.clone(), 0);
queue.push_back(start.clone());
}
while let Some(current) = queue.pop_front() {
let Some(&distance) = distances.get(¤t) else {
continue;
};
if distance >= depth {
continue;
}
let Some(next_paths) = graph.get(¤t) else {
continue;
};
for next in next_paths {
let next_distance = distance + 1;
let is_shorter = distances
.get(next)
.is_none_or(|known| next_distance < *known);
if is_shorter {
distances.insert(next.clone(), next_distance);
queue.push_back(next.clone());
}
}
}
let mut out: Vec<ImpactPath> = distances
.into_iter()
.filter(|(path, distance)| !starts.contains(path) && *distance <= depth)
.map(|(path, distance)| ImpactPath { path, distance })
.collect();
out.sort_by(|left, right| {
left.distance
.cmp(&right.distance)
.then_with(|| left.path.cmp(&right.path))
});
out
}
fn is_test_path(path: &str) -> bool {
let basename = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
if basename.starts_with("test")
|| basename.contains("_test")
|| basename.contains(".test")
|| basename.contains("_spec")
|| basename.contains(".spec")
{
return true;
}
path.split('/').any(|part| {
matches!(
part.to_ascii_lowercase().as_str(),
"test" | "tests" | "spec" | "specs"
)
})
}
pub fn render(report: &ImpactReport) -> String {
let mut out = format!("radar impact {} depth={}\n", report.base, report.depth);
section(
&mut out,
"changed",
report.changed.iter().map(String::as_str),
);
section(
&mut out,
"changed definitions",
report.changed_definitions.iter().map(|definition| {
format!(
"{}#{}:{}",
definition.path, definition.symbol, definition.line
)
}),
);
section(
&mut out,
"direct edges",
report.direct_edges.iter().map(|edge| {
let from = edge.from_symbol.as_deref().map_or_else(
|| edge.from.clone(),
|symbol| format!("{}#{}", edge.from, symbol),
);
format!(
"{} -{:?} line={}-> {}#{}",
from, edge.kind, edge.reference_line, edge.to, edge.to_symbol
)
}),
);
section(
&mut out,
"callers / affected",
report
.callers
.iter()
.map(|entry| format!("{} distance={}", entry.path, entry.distance)),
);
section(
&mut out,
"dependencies",
report
.dependencies
.iter()
.map(|entry| format!("{} distance={}", entry.path, entry.distance)),
);
section(&mut out, "tests", report.tests.iter().map(String::as_str));
if let Some(history) = &report.history {
section(
&mut out,
"historical co-change candidates",
history.candidates.iter().map(|candidate| {
let commit = candidate.latest_commit.chars().take(8).collect::<String>();
format!(
"{} commits={} latest={}",
candidate.path, candidate.commits, commit
)
}),
);
out.push_str(&format!(
"history: scanned={} seeds={} bulk-skipped={} omitted={}\n",
history.commits_scanned,
history.seed_commits,
history.bulk_commits_skipped,
history.omitted
));
}
out.push_str(&format!(
"references: unresolved={} ambiguous={}\n",
report.unresolved_refs, report.ambiguous_refs
));
out
}
fn section<'a, I, T>(out: &mut String, name: &str, entries: I)
where
I: IntoIterator<Item = T>,
T: std::fmt::Display + 'a,
{
let entries: Vec<T> = entries.into_iter().collect();
out.push_str(&format!("{name}:\n"));
if entries.is_empty() {
out.push_str(" none\n");
} else {
for entry in entries {
out.push_str(&format!(" {entry}\n"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::FileEntry;
use crate::extract::{Extraction, RefKind, RefName, SymKind, Symbol};
use crate::lang::Lang;
fn file(cache: &mut ScanCache, path: &str, defs: &[&str], refs: &[&str]) {
let hash = *blake3::hash(path.as_bytes()).as_bytes();
cache.files.insert(
path.to_string(),
FileEntry {
mtime: (1, 0),
size: 1,
ino: 0,
hash,
lang: Some(Lang::Python),
},
);
cache.parses.insert(
(Lang::Python, hash),
Extraction {
defs: defs
.iter()
.enumerate()
.map(|(line, name)| Symbol {
line: line as u32 + 1,
end_line: line as u32 + 2,
name: (*name).to_string(),
kind: SymKind::Fn,
vis: Vis::Pub,
sig: format!("def {name}()"),
terms: Vec::new(),
})
.collect(),
refs: refs
.iter()
.enumerate()
.map(|(line, name)| RefName {
line: line as u32 + 10,
name: (*name).to_string(),
kind: RefKind::Call,
})
.collect(),
},
);
}
#[test]
fn walks_callers_dependencies_and_tests_deterministically() {
let mut cache = ScanCache::default();
file(&mut cache, "core.py", &["changed"], &["helper"]);
file(&mut cache, "util.py", &["helper"], &[]);
file(&mut cache, "api.py", &["api"], &["changed"]);
file(&mut cache, "tests/test_api.py", &[], &["api"]);
let report = analyze(&cache, "HEAD", ["core.py".to_string()], 2);
assert_eq!(report.changed, ["core.py"]);
assert_eq!(report.changed_definitions[0].symbol, "changed");
assert_eq!(report.direct_edges.len(), 2);
assert_eq!(report.direct_edges[0].from, "api.py");
assert_eq!(report.direct_edges[0].to_symbol, "changed");
assert_eq!(report.dependencies[0].path, "util.py");
assert_eq!(report.callers[0].path, "api.py");
assert_eq!(report.callers[0].distance, 1);
assert_eq!(report.tests, ["tests/test_api.py"]);
assert_eq!(report.unresolved_refs, 0);
let human = render(&report);
for expected in [
"radar impact HEAD depth=2",
"direct edges:\n api.py -Call line=10-> core.py#changed",
"callers / affected:\n api.py distance=1\n tests/test_api.py distance=2",
"dependencies:\n util.py distance=1",
"references: unresolved=0 ambiguous=0",
] {
assert!(human.contains(expected), "missing {expected:?}:\n{human}");
}
}
#[test]
fn ambiguity_is_reported_and_name_resolution_fans_out() {
let mut cache = ScanCache::default();
file(&mut cache, "changed.py", &["run"], &["shared"]);
file(&mut cache, "one.py", &["shared"], &[]);
file(&mut cache, "two.py", &["shared"], &[]);
let report = analyze(&cache, "HEAD", ["changed.py".to_string()], 1);
assert_eq!(report.ambiguous_refs, 1);
assert_eq!(
report
.dependencies
.iter()
.map(|entry| entry.path.as_str())
.collect::<Vec<_>>(),
["one.py", "two.py"]
);
assert!(render(&report).contains("callers / affected:\n none\n"));
}
}