use std::collections::{HashMap, HashSet, VecDeque};
use serde::{Deserialize, Serialize};
use crate::callgraph::types::{CallGraph, FunctionRef};
#[derive(Debug, Clone)]
pub struct ImpactConfig {
pub max_depth: usize,
pub language: Option<String>,
pub include_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub exclude_tests: bool,
pub include_call_sites: bool,
pub deduplicate_paths: bool,
}
impl Default for ImpactConfig {
fn default() -> Self {
Self {
max_depth: 0,
language: None,
include_patterns: Vec::new(),
exclude_patterns: Vec::new(),
exclude_tests: false,
include_call_sites: false,
deduplicate_paths: true, }
}
}
impl ImpactConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_depth(mut self, depth: usize) -> Self {
self.max_depth = depth;
self
}
#[allow(dead_code)]
pub fn with_language(mut self, lang: &str) -> Self {
self.language = Some(lang.to_string());
self
}
#[allow(dead_code)]
pub fn with_includes(mut self, patterns: &[&str]) -> Self {
self.include_patterns = patterns.iter().map(|s| (*s).to_string()).collect();
self
}
#[allow(dead_code)]
pub fn with_excludes(mut self, patterns: &[&str]) -> Self {
self.exclude_patterns = patterns.iter().map(|s| (*s).to_string()).collect();
self
}
#[allow(dead_code)]
pub fn exclude_tests(mut self) -> Self {
self.exclude_tests = true;
self
}
pub fn with_call_sites(mut self) -> Self {
self.include_call_sites = true;
self
}
#[allow(dead_code)]
pub fn with_deduplicate_paths(mut self, deduplicate: bool) -> Self {
self.deduplicate_paths = deduplicate;
self
}
#[allow(dead_code)]
pub fn explore_all_paths(mut self) -> Self {
self.deduplicate_paths = false;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImpactResult {
pub target: String,
pub target_file: Option<String>,
pub depth: usize,
pub callers: Vec<CallerInfo>,
pub total_affected: usize,
pub by_distance: HashMap<usize, usize>,
pub by_file: HashMap<String, usize>,
}
impl ImpactResult {
#[allow(dead_code)]
pub fn to_llm_context(&self) -> String {
let mut output = String::with_capacity(4096);
output.push_str(&format!("# Impact Analysis: {}\n\n", self.target));
if let Some(ref file) = self.target_file {
output.push_str(&format!("Target file: {}\n", file));
}
output.push_str(&format!(
"Total affected: {} functions at {} depth levels\n\n",
self.total_affected,
self.by_distance.len()
));
let mut by_distance: HashMap<usize, Vec<&CallerInfo>> = HashMap::new();
for caller in &self.callers {
by_distance.entry(caller.distance).or_default().push(caller);
}
let mut distances: Vec<_> = by_distance.keys().copied().collect();
distances.sort();
for distance in distances {
let callers = &by_distance[&distance];
output.push_str(&format!(
"## Distance {} ({} functions)\n\n",
distance,
callers.len()
));
let mut by_file: HashMap<&str, Vec<&CallerInfo>> = HashMap::new();
for caller in callers {
by_file.entry(&caller.file).or_default().push(caller);
}
let mut files: Vec<_> = by_file.keys().copied().collect();
files.sort();
for file in files {
let file_callers = &by_file[file];
output.push_str(&format!("### {}\n", file));
for caller in file_callers {
output.push_str(&format!("- {}", caller.name));
if !caller.call_sites.is_empty() {
let sites: Vec<_> =
caller.call_sites.iter().map(|s| s.to_string()).collect();
output.push_str(&format!(" (lines: {})", sites.join(", ")));
}
output.push('\n');
}
output.push('\n');
}
}
output.push_str("## Summary by File\n\n");
let mut file_counts: Vec<_> = self.by_file.iter().collect();
file_counts.sort_by(|a, b| b.1.cmp(a.1));
for (file, count) in file_counts.iter().take(10) {
output.push_str(&format!("- {}: {} functions\n", file, count));
}
if file_counts.len() > 10 {
output.push_str(&format!(
"- ... and {} more files\n",
file_counts.len() - 10
));
}
output
}
#[allow(dead_code)]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"target": self.target,
"target_file": self.target_file,
"depth": self.depth,
"total_affected": self.total_affected,
"callers": self.callers,
"by_distance": self.by_distance,
"by_file": self.by_file
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallerInfo {
pub file: String,
pub name: String,
pub qualified_name: Option<String>,
pub distance: usize,
pub call_sites: Vec<usize>,
}
pub fn analyze_impact(graph: &CallGraph, target: &str, config: ImpactConfig) -> ImpactResult {
let reverse_index = build_reverse_index(graph);
let name_index = build_name_index(&reverse_index);
let target_matches: HashSet<FunctionRef> = find_matching_targets_indexed(&name_index, target)
.into_iter()
.cloned()
.collect();
if target_matches.is_empty() {
return ImpactResult {
target: target.to_string(),
target_file: None,
depth: 0,
callers: Vec::new(),
total_affected: 0,
by_distance: HashMap::new(),
by_file: HashMap::new(),
};
}
let target_file = if target_matches.len() == 1 {
target_matches.iter().next().map(|f| f.file.clone())
} else {
None
};
let callers_map = if config.deduplicate_paths {
analyze_impact_bfs(&reverse_index, &target_matches, &config)
} else {
analyze_impact_dfs(&reverse_index, &target_matches, &config)
};
let mut callers: Vec<CallerInfo> = callers_map
.into_iter()
.map(|(func, (distance, call_sites))| CallerInfo {
file: func.file.clone(),
name: func.name.clone(),
qualified_name: func.qualified_name.clone(),
distance,
call_sites,
})
.collect();
callers.sort_by(|a, b| {
a.distance
.cmp(&b.distance)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.name.cmp(&b.name))
});
let total_affected = callers.len();
let max_distance = callers.iter().map(|c| c.distance).max().unwrap_or(0);
let mut by_distance: HashMap<usize, usize> = HashMap::new();
let mut by_file: HashMap<String, usize> = HashMap::new();
for caller in &callers {
*by_distance.entry(caller.distance).or_insert(0) += 1;
*by_file.entry(caller.file.clone()).or_insert(0) += 1;
}
ImpactResult {
target: target.to_string(),
target_file,
depth: max_distance,
callers,
total_affected,
by_distance,
by_file,
}
}
fn analyze_impact_bfs(
reverse_index: &ReverseIndex,
target_matches: &HashSet<FunctionRef>,
config: &ImpactConfig,
) -> HashMap<FunctionRef, (usize, Vec<usize>)> {
let mut visited: HashSet<FunctionRef> = HashSet::new();
let mut callers_map: HashMap<FunctionRef, (usize, Vec<usize>)> = HashMap::new();
let mut queue: VecDeque<(FunctionRef, usize)> = VecDeque::new();
for target_ref in target_matches {
if let Some(callers) = reverse_index.get(target_ref) {
for (caller, call_line) in callers {
if !visited.contains(caller) && should_include(caller, config) {
visited.insert(caller.clone());
let entry = callers_map.entry(caller.clone()).or_insert((1, Vec::new()));
if config.include_call_sites {
entry.1.push(*call_line);
}
queue.push_back((caller.clone(), 1));
}
}
}
}
let max_depth = config.max_depth;
while let Some((func, distance)) = queue.pop_front() {
if distance > max_depth {
continue;
}
if let Some(callers) = reverse_index.get(&func) {
for (caller, call_line) in callers {
if !visited.contains(caller) && should_include(caller, config) {
visited.insert(caller.clone());
let new_distance = distance + 1;
let entry = callers_map
.entry(caller.clone())
.or_insert((new_distance, Vec::new()));
if config.include_call_sites {
entry.1.push(*call_line);
}
queue.push_back((caller.clone(), new_distance));
}
}
}
}
callers_map
}
fn analyze_impact_dfs(
reverse_index: &ReverseIndex,
target_matches: &HashSet<FunctionRef>,
config: &ImpactConfig,
) -> HashMap<FunctionRef, (usize, Vec<usize>)> {
let mut all_callers: HashMap<FunctionRef, (usize, Vec<usize>)> = HashMap::new();
let max_depth = config.max_depth;
fn dfs_explore(
func: &FunctionRef,
distance: usize,
max_depth: usize,
visited: &mut HashSet<FunctionRef>,
reverse_index: &HashMap<FunctionRef, Vec<(FunctionRef, usize)>>,
all_callers: &mut HashMap<FunctionRef, (usize, Vec<usize>)>,
config: &ImpactConfig,
) {
if distance > max_depth || visited.contains(func) {
return;
}
visited.insert(func.clone());
if let Some(callers) = reverse_index.get(func) {
for (caller, call_line) in callers {
if !should_include(caller, config) {
continue;
}
let new_distance = distance + 1;
let entry = all_callers
.entry(caller.clone())
.or_insert((new_distance, Vec::new()));
entry.0 = entry.0.min(new_distance);
if config.include_call_sites && !entry.1.contains(call_line) {
entry.1.push(*call_line);
}
let mut branch_visited = visited.clone();
dfs_explore(
caller,
new_distance,
max_depth,
&mut branch_visited,
reverse_index,
all_callers,
config,
);
}
}
}
for target_ref in target_matches {
if let Some(callers) = reverse_index.get(target_ref) {
for (caller, call_line) in callers {
if !should_include(caller, config) {
continue;
}
let entry = all_callers.entry(caller.clone()).or_insert((1, Vec::new()));
entry.0 = entry.0.min(1);
if config.include_call_sites && !entry.1.contains(call_line) {
entry.1.push(*call_line);
}
let mut visited = HashSet::new();
visited.insert(target_ref.clone()); dfs_explore(
caller,
1,
max_depth,
&mut visited,
reverse_index,
&mut all_callers,
config,
);
}
}
}
all_callers
}
type ReverseIndex = HashMap<FunctionRef, Vec<(FunctionRef, usize)>>;
type NameIndex<'a> = HashMap<&'a str, Vec<&'a FunctionRef>>;
fn build_reverse_index(graph: &CallGraph) -> ReverseIndex {
let mut index: ReverseIndex = HashMap::new();
for edge in &graph.edges {
index
.entry(edge.callee.clone())
.or_default()
.push((edge.caller.clone(), edge.call_line));
}
index
}
fn build_name_index(reverse_index: &ReverseIndex) -> NameIndex<'_> {
let mut name_index: NameIndex<'_> = HashMap::new();
for func_ref in reverse_index.keys() {
name_index
.entry(func_ref.name.as_str())
.or_default()
.push(func_ref);
}
name_index
}
fn find_matching_targets_indexed<'a>(
name_index: &'a NameIndex<'a>,
target: &str,
) -> Vec<&'a FunctionRef> {
let mut matches = Vec::new();
if let Some(funcs) = name_index.get(target) {
matches.extend(funcs.iter().copied());
}
if target.contains('.') {
for (_name, funcs) in name_index.iter() {
for func in funcs.iter() {
if let Some(ref qn) = func.qualified_name {
if qn == target || qn.ends_with(&format!(".{}", target)) {
if func.name != target {
matches.push(*func);
}
}
}
}
}
} else {
for funcs in name_index.values() {
for func in funcs.iter() {
if func.name != target {
if let Some(ref qn) = func.qualified_name {
if qn.ends_with(&format!(".{}", target)) {
matches.push(*func);
}
}
}
}
}
}
matches
}
#[allow(dead_code)]
fn find_matching_targets(graph: &CallGraph, target: &str) -> Vec<FunctionRef> {
let all_funcs = graph.all_functions();
all_funcs
.iter()
.filter(|f| {
if f.name == target {
return true;
}
if let Some(ref qn) = f.qualified_name {
if qn == target || qn.ends_with(&format!(".{}", target)) {
return true;
}
}
false
})
.cloned()
.collect()
}
fn should_include(func: &FunctionRef, config: &ImpactConfig) -> bool {
if let Some(ref lang) = config.language {
if !matches_language(&func.file, lang) {
return false;
}
}
if config.exclude_tests && is_test_file(&func.file) {
return false;
}
if !config.include_patterns.is_empty() {
let matches_any = config
.include_patterns
.iter()
.any(|p| glob_match(p, &func.file));
if !matches_any {
return false;
}
}
for pattern in &config.exclude_patterns {
if glob_match(pattern, &func.file) {
return false;
}
}
true
}
fn matches_language(file: &str, lang: &str) -> bool {
let extensions: &[&str] = match lang {
"python" => &[".py", ".pyi"],
"typescript" => &[".ts", ".tsx"],
"javascript" => &[".js", ".jsx", ".mjs"],
"rust" => &[".rs"],
"go" => &[".go"],
"java" => &[".java"],
"c" => &[".c", ".h"],
"cpp" => &[".cpp", ".cc", ".cxx", ".hpp", ".hxx"],
"csharp" => &[".cs"],
"ruby" => &[".rb"],
"php" => &[".php"],
"swift" => &[".swift"],
"kotlin" => &[".kt", ".kts"],
"scala" => &[".scala", ".sc"],
_ => return true, };
extensions.iter().any(|ext| file.ends_with(ext))
}
fn is_test_file(file: &str) -> bool {
let path_lower = file.to_lowercase();
if path_lower.contains("/test/")
|| path_lower.contains("/tests/")
|| path_lower.contains("/__tests__/")
|| path_lower.contains("/spec/")
|| path_lower.contains("/specs/")
|| path_lower.starts_with("test/")
|| path_lower.starts_with("tests/")
|| path_lower.starts_with("__tests__/")
|| path_lower.starts_with("spec/")
|| path_lower.starts_with("specs/")
{
return true;
}
let filename = file
.rsplit(|c| c == '/' || c == '\\')
.next()
.unwrap_or(file);
let filename_lower = filename.to_lowercase();
if filename_lower.starts_with("test_") && filename_lower.ends_with(".py") {
return true;
}
if filename_lower.ends_with("_test.py") {
return true;
}
if filename.ends_with("Test.java") {
return true;
}
if filename.starts_with("Test")
&& filename.ends_with(".java")
&& filename.len() > 9
&& filename.chars().nth(4).map_or(false, |c| c.is_uppercase())
{
return true;
}
if filename_lower.starts_with("test_") && filename_lower.ends_with(".java") {
return true;
}
if filename.ends_with("Test.kt") {
return true;
}
if filename.starts_with("Test")
&& filename.ends_with(".kt")
&& filename.len() > 7
&& filename.chars().nth(4).map_or(false, |c| c.is_uppercase())
{
return true;
}
if filename_lower.starts_with("test_") && filename_lower.ends_with(".kt") {
return true;
}
if filename_lower.ends_with(".test.js")
|| filename_lower.ends_with(".test.ts")
|| filename_lower.ends_with(".test.jsx")
|| filename_lower.ends_with(".test.tsx")
|| filename_lower.ends_with(".spec.js")
|| filename_lower.ends_with(".spec.ts")
|| filename_lower.ends_with(".spec.jsx")
|| filename_lower.ends_with(".spec.tsx")
{
return true;
}
if filename_lower.ends_with("_test.go") {
return true;
}
if filename_lower.ends_with("_test.rs") || filename_lower == "tests.rs" {
return true;
}
if filename_lower.ends_with("_spec.rb") || filename_lower.ends_with("_test.rb") {
return true;
}
if filename.ends_with("Test.cs") || filename.ends_with("Tests.cs") {
return true;
}
if filename_lower.starts_with("test_") {
return true;
}
false
}
fn glob_match(pattern: &str, path: &str) -> bool {
let pattern = pattern.replace('\\', "/");
let path = path.replace('\\', "/");
glob_match_recursive(&pattern, &path)
}
fn glob_match_recursive(pattern: &str, path: &str) -> bool {
let mut pat_chars = pattern.chars().peekable();
let mut path_chars = path.chars().peekable();
while let Some(pc) = pat_chars.next() {
match pc {
'*' => {
if pat_chars.peek() == Some(&'*') {
pat_chars.next();
if pat_chars.peek() == Some(&'/') {
pat_chars.next();
}
let remaining_pattern: String = pat_chars.collect();
if remaining_pattern.is_empty() {
return true;
}
let remaining_path: String = path_chars.collect();
for i in 0..=remaining_path.len() {
if glob_match_recursive(&remaining_pattern, &remaining_path[i..]) {
return true;
}
}
return false;
} else {
let remaining_pattern: String = pat_chars.collect();
if remaining_pattern.is_empty() {
return !path_chars.any(|c| c == '/');
}
let remaining_path: String = path_chars.collect();
for i in 0..=remaining_path.len() {
if i > 0 && remaining_path.chars().nth(i - 1) == Some('/') {
break; }
if glob_match_recursive(&remaining_pattern, &remaining_path[i..]) {
return true;
}
}
return false;
}
}
'?' => {
match path_chars.next() {
Some(c) if c != '/' => continue,
_ => return false,
}
}
c => {
match path_chars.next() {
Some(pc) if pc == c => continue,
_ => return false,
}
}
}
}
path_chars.next().is_none()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::callgraph::types::CallEdge;
fn create_test_graph() -> CallGraph {
let mut graph = CallGraph::default();
let edges = vec![
CallEdge {
caller: FunctionRef {
file: "src/main.py".to_string(),
name: "main".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/process.py".to_string(),
name: "process".to_string(),
qualified_name: None,
},
call_line: 10,
},
CallEdge {
caller: FunctionRef {
file: "src/process.py".to_string(),
name: "process".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/validate.py".to_string(),
name: "validate".to_string(),
qualified_name: None,
},
call_line: 25,
},
CallEdge {
caller: FunctionRef {
file: "src/validate.py".to_string(),
name: "validate".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/helper.py".to_string(),
name: "helper".to_string(),
qualified_name: None,
},
call_line: 15,
},
CallEdge {
caller: FunctionRef {
file: "tests/test_helper.py".to_string(),
name: "test_helper".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/helper.py".to_string(),
name: "helper".to_string(),
qualified_name: None,
},
call_line: 8,
},
];
graph.edges = edges;
graph.build_indexes();
graph
}
#[test]
fn test_basic_impact_analysis() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(10);
let result = analyze_impact(&graph, "helper", config);
assert_eq!(result.target, "helper");
assert_eq!(result.total_affected, 4); }
#[test]
fn test_impact_with_depth_limit() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(1);
let result = analyze_impact(&graph, "helper", config);
assert_eq!(result.total_affected, 3);
assert!(result.callers.iter().all(|c| c.distance <= 2));
let distance_1_count = result.callers.iter().filter(|c| c.distance == 1).count();
let distance_2_count = result.callers.iter().filter(|c| c.distance == 2).count();
assert_eq!(distance_1_count, 2); assert_eq!(distance_2_count, 1); }
#[test]
fn test_impact_depth_zero_no_traversal() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(0);
let result = analyze_impact(&graph, "helper", config);
assert_eq!(result.total_affected, 2); assert!(
result.callers.iter().all(|c| c.distance == 1),
"All callers should be at distance 1 when max_depth=0"
);
let caller_names: Vec<&str> = result.callers.iter().map(|c| c.name.as_str()).collect();
assert!(
caller_names.contains(&"validate"),
"validate should be a direct caller"
);
assert!(
caller_names.contains(&"test_helper"),
"test_helper should be a direct caller"
);
assert!(
!caller_names.contains(&"process"),
"process should NOT be found with max_depth=0"
);
assert!(
!caller_names.contains(&"main"),
"main should NOT be found with max_depth=0"
);
}
#[test]
fn test_impact_exclude_tests() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(10).exclude_tests();
let result = analyze_impact(&graph, "helper", config);
assert_eq!(result.total_affected, 3);
assert!(!result.callers.iter().any(|c| c.name == "test_helper"));
}
#[test]
fn test_impact_language_filter() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(10).with_language("python");
let result = analyze_impact(&graph, "helper", config);
assert_eq!(result.total_affected, 4);
}
#[test]
fn test_impact_distance_tracking() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(10).exclude_tests();
let result = analyze_impact(&graph, "helper", config);
let validate = result
.callers
.iter()
.find(|c| c.name == "validate")
.unwrap();
assert_eq!(validate.distance, 1);
let process = result.callers.iter().find(|c| c.name == "process").unwrap();
assert_eq!(process.distance, 2);
let main_fn = result.callers.iter().find(|c| c.name == "main").unwrap();
assert_eq!(main_fn.distance, 3);
}
#[test]
fn test_impact_nonexistent_target() {
let graph = create_test_graph();
let config = ImpactConfig::new();
let result = analyze_impact(&graph, "nonexistent", config);
assert_eq!(result.total_affected, 0);
assert!(result.callers.is_empty());
}
#[test]
fn test_glob_match() {
assert!(glob_match("*.py", "test.py"));
assert!(!glob_match("*.py", "test.rs"));
assert!(glob_match("**/*.py", "src/lib/test.py"));
assert!(glob_match("**/test/**", "foo/test/bar.py"));
assert!(glob_match("test?.py", "test1.py"));
assert!(!glob_match("test?.py", "test12.py"));
}
#[test]
fn test_is_test_file() {
assert!(is_test_file("tests/test_main.py"));
assert!(is_test_file("src/__tests__/component.test.ts"));
assert!(is_test_file("test/helper_test.go"));
assert!(is_test_file("src/spec/model_spec.rb"));
assert!(is_test_file("specs/integration.rb"));
assert!(is_test_file("test_main.py"));
assert!(is_test_file("src/test_utils.py"));
assert!(is_test_file("helper_test.py"));
assert!(is_test_file("UserTest.java")); assert!(is_test_file("src/UserServiceTest.java"));
assert!(is_test_file("TestUser.java")); assert!(is_test_file("src/TestUserService.java"));
assert!(is_test_file("test_user.java")); assert!(is_test_file("Test.java")); assert!(!is_test_file("Contest.java"));
assert!(is_test_file("UserTest.kt"));
assert!(is_test_file("TestUser.kt"));
assert!(is_test_file("test_user.kt"));
assert!(is_test_file("Test.kt"));
assert!(is_test_file("component.test.js"));
assert!(is_test_file("component.test.ts"));
assert!(is_test_file("component.test.jsx"));
assert!(is_test_file("component.test.tsx"));
assert!(is_test_file("component.spec.js"));
assert!(is_test_file("component.spec.ts"));
assert!(is_test_file("main_test.go"));
assert!(is_test_file("handler_test.go"));
assert!(is_test_file("parser_test.rs"));
assert!(is_test_file("tests.rs"));
assert!(is_test_file("model_spec.rb"));
assert!(is_test_file("helper_test.rb"));
assert!(is_test_file("UserTest.cs"));
assert!(is_test_file("UserTests.cs"));
assert!(!is_test_file("src/main.py"));
assert!(!is_test_file("lib/process.rs"));
assert!(!is_test_file("src/TestingFramework.java")); assert!(!is_test_file("src/LatestNews.java")); }
#[test]
fn test_llm_context_output() {
let graph = create_test_graph();
let config = ImpactConfig::new().with_depth(10).exclude_tests();
let result = analyze_impact(&graph, "helper", config);
let context = result.to_llm_context();
assert!(context.contains("# Impact Analysis: helper"));
assert!(context.contains("Distance 1"));
assert!(context.contains("validate"));
assert!(context.contains("Distance 2"));
assert!(context.contains("process"));
}
#[test]
fn test_same_name_different_files_not_conflated() {
let mut graph = CallGraph::default();
let edges = vec![
CallEdge {
caller: FunctionRef {
file: "src/caller_a.py".to_string(),
name: "caller_a".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/a.py".to_string(),
name: "helper".to_string(),
qualified_name: None,
},
call_line: 10,
},
CallEdge {
caller: FunctionRef {
file: "src/caller_b.py".to_string(),
name: "caller_b".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/b.py".to_string(),
name: "helper".to_string(),
qualified_name: None,
},
call_line: 20,
},
CallEdge {
caller: FunctionRef {
file: "src/main_a.py".to_string(),
name: "main_a".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/caller_a.py".to_string(),
name: "caller_a".to_string(),
qualified_name: None,
},
call_line: 5,
},
CallEdge {
caller: FunctionRef {
file: "src/main_b.py".to_string(),
name: "main_b".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/caller_b.py".to_string(),
name: "caller_b".to_string(),
qualified_name: None,
},
call_line: 15,
},
];
graph.edges = edges;
graph.build_indexes();
let config = ImpactConfig::new().with_depth(10);
let result = analyze_impact(&graph, "helper", config);
assert_eq!(result.total_affected, 4);
let caller_a = result
.callers
.iter()
.find(|c| c.name == "caller_a")
.expect("caller_a should be found");
assert_eq!(caller_a.distance, 1);
assert_eq!(caller_a.file, "src/caller_a.py");
let caller_b = result
.callers
.iter()
.find(|c| c.name == "caller_b")
.expect("caller_b should be found");
assert_eq!(caller_b.distance, 1);
assert_eq!(caller_b.file, "src/caller_b.py");
let main_a = result
.callers
.iter()
.find(|c| c.name == "main_a")
.expect("main_a should be found");
assert_eq!(main_a.distance, 2);
let main_b = result
.callers
.iter()
.find(|c| c.name == "main_b")
.expect("main_b should be found");
assert_eq!(main_b.distance, 2);
}
#[test]
fn test_qualified_target_isolates_file() {
let mut graph = CallGraph::default();
let edges = vec![
CallEdge {
caller: FunctionRef {
file: "src/caller_a.py".to_string(),
name: "caller_a".to_string(),
qualified_name: Some("module_a.caller_a".to_string()),
},
callee: FunctionRef {
file: "src/a.py".to_string(),
name: "helper".to_string(),
qualified_name: Some("module_a.helper".to_string()),
},
call_line: 10,
},
CallEdge {
caller: FunctionRef {
file: "src/caller_b.py".to_string(),
name: "caller_b".to_string(),
qualified_name: Some("module_b.caller_b".to_string()),
},
callee: FunctionRef {
file: "src/b.py".to_string(),
name: "helper".to_string(),
qualified_name: Some("module_b.helper".to_string()),
},
call_line: 20,
},
];
graph.edges = edges;
graph.build_indexes();
let config = ImpactConfig::new().with_depth(10);
let result = analyze_impact(&graph, "module_a.helper", config.clone());
assert_eq!(result.total_affected, 1);
assert_eq!(result.callers[0].name, "caller_a");
assert_eq!(result.callers[0].file, "src/caller_a.py");
let result_b = analyze_impact(&graph, "module_b.helper", config);
assert_eq!(result_b.total_affected, 1);
assert_eq!(result_b.callers[0].name, "caller_b");
assert_eq!(result_b.callers[0].file, "src/caller_b.py");
}
fn create_diamond_graph() -> CallGraph {
let mut graph = CallGraph::default();
let edges = vec![
CallEdge {
caller: FunctionRef {
file: "src/b.py".to_string(),
name: "B".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/d.py".to_string(),
name: "D".to_string(),
qualified_name: None,
},
call_line: 10,
},
CallEdge {
caller: FunctionRef {
file: "src/c.py".to_string(),
name: "C".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/d.py".to_string(),
name: "D".to_string(),
qualified_name: None,
},
call_line: 20,
},
CallEdge {
caller: FunctionRef {
file: "src/a.py".to_string(),
name: "A".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/b.py".to_string(),
name: "B".to_string(),
qualified_name: None,
},
call_line: 5,
},
CallEdge {
caller: FunctionRef {
file: "src/a.py".to_string(),
name: "A".to_string(),
qualified_name: None,
},
callee: FunctionRef {
file: "src/c.py".to_string(),
name: "C".to_string(),
qualified_name: None,
},
call_line: 6,
},
];
graph.edges = edges;
graph.build_indexes();
graph
}
#[test]
fn test_diamond_graph_bfs_deduplication() {
let graph = create_diamond_graph();
let config = ImpactConfig::new().with_depth(10);
let result = analyze_impact(&graph, "D", config);
assert_eq!(result.total_affected, 3);
let b = result.callers.iter().find(|c| c.name == "B").unwrap();
assert_eq!(b.distance, 1);
let c = result.callers.iter().find(|c| c.name == "C").unwrap();
assert_eq!(c.distance, 1);
let a = result.callers.iter().find(|c| c.name == "A").unwrap();
assert_eq!(a.distance, 2);
let a_count = result.callers.iter().filter(|c| c.name == "A").count();
assert_eq!(a_count, 1);
}
#[test]
fn test_diamond_graph_dfs_all_paths() {
let graph = create_diamond_graph();
let config = ImpactConfig::new()
.with_depth(10)
.explore_all_paths();
let result = analyze_impact(&graph, "D", config);
assert_eq!(result.total_affected, 3);
let b = result.callers.iter().find(|c| c.name == "B").unwrap();
assert_eq!(b.distance, 1);
let c = result.callers.iter().find(|c| c.name == "C").unwrap();
assert_eq!(c.distance, 1);
let a = result.callers.iter().find(|c| c.name == "A").unwrap();
assert_eq!(a.distance, 2);
let a_count = result.callers.iter().filter(|c| c.name == "A").count();
assert_eq!(a_count, 1);
}
#[test]
fn test_diamond_graph_dfs_aggregates_call_sites() {
let graph = create_diamond_graph();
let config = ImpactConfig::new()
.with_depth(10)
.with_call_sites()
.explore_all_paths();
let result = analyze_impact(&graph, "D", config);
let a = result.callers.iter().find(|c| c.name == "A").unwrap();
assert_eq!(a.call_sites.len(), 2, "A should have 2 call sites (via B and C)");
assert!(a.call_sites.contains(&5), "Should include call site line 5 (A->B)");
assert!(a.call_sites.contains(&6), "Should include call site line 6 (A->C)");
}
#[test]
fn test_diamond_graph_bfs_call_sites() {
let graph = create_diamond_graph();
let config = ImpactConfig::new()
.with_depth(10)
.with_call_sites();
let result = analyze_impact(&graph, "D", config);
let a = result.callers.iter().find(|c| c.name == "A").unwrap();
assert!(
a.call_sites.len() >= 1,
"A should have at least 1 call site"
);
}
#[test]
fn test_explore_all_paths_builder() {
let config = ImpactConfig::new().explore_all_paths();
assert!(!config.deduplicate_paths);
let config2 = ImpactConfig::new().with_deduplicate_paths(false);
assert!(!config2.deduplicate_paths);
let config3 = ImpactConfig::new().with_deduplicate_paths(true);
assert!(config3.deduplicate_paths);
}
#[test]
fn test_default_config_backward_compatible() {
let config = ImpactConfig::default();
assert!(
config.deduplicate_paths,
"Default should use deduplication for backward compatibility"
);
let config2 = ImpactConfig::new();
assert!(
config2.deduplicate_paths,
"ImpactConfig::new() should also use deduplication"
);
}
}