use std::collections::{HashMap, HashSet, VecDeque};
use crate::store::calls::cross_project::CrossProjectContext;
use crate::store::helpers::StoreError;
use super::types::{ImpactResult, TestInfo, TransitiveCaller};
#[derive(Debug, Clone, serde::Serialize)]
pub struct CrossProjectHop {
pub name: String,
pub project: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CrossProjectTraceResult {
pub source: String,
pub target: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<Vec<CrossProjectHop>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<usize>,
pub found: bool,
}
pub fn analyze_impact_cross(
ctx: &mut CrossProjectContext,
name: &str,
depth: usize,
suggest_tests: bool,
include_types: bool,
) -> Result<ImpactResult, StoreError> {
let _span = tracing::info_span!(
"analyze_impact_cross",
target = name,
depth,
suggest_tests,
include_types,
projects = ctx.project_count()
)
.entered();
let mut visited: HashMap<String, (usize, String)> = HashMap::new(); let mut queue: VecDeque<(String, usize)> = VecDeque::new();
visited.insert(name.to_string(), (0, String::new()));
queue.push_back((name.to_string(), 0));
while let Some((current, d)) = queue.pop_front() {
if d >= depth {
continue;
}
let callers = ctx.get_callers_cross(¤t)?;
for caller in callers {
if !visited.contains_key(&caller.caller.name) {
if let Some((_, current_project)) = visited.get(¤t) {
if !current_project.is_empty() && *current_project != caller.project {
tracing::debug!(
from_project = %current_project,
to_project = %caller.project,
callee = %current,
caller = %caller.caller.name,
"Cross-boundary hop in impact BFS"
);
}
}
visited.insert(caller.caller.name.clone(), (d + 1, caller.project.clone()));
queue.push_back((caller.caller.name.clone(), d + 1));
}
}
}
let caller_count = visited
.iter()
.filter(|(n, (d, _))| *d > 0 && n.as_str() != name)
.count();
if caller_count > 0 {
tracing::warn!(
count = caller_count,
"Cross-project callers have empty file/line; resolve from CallGraph when edge metadata is available"
);
}
let mut transitive_callers: Vec<TransitiveCaller> = visited
.iter()
.filter(|(n, (d, _))| *d > 0 && n.as_str() != name)
.map(|(n, (d, _))| TransitiveCaller {
name: n.clone(),
file: std::path::PathBuf::new(),
line: 0,
depth: *d,
})
.collect();
transitive_callers.sort_by_key(|tc| tc.depth);
let callers = visited
.iter()
.filter(|(_, (d, _))| *d == 1)
.map(|(n, _)| super::types::CallerDetail {
name: n.clone(),
file: std::path::PathBuf::new(),
line: 0,
call_line: 0,
snippet: None,
})
.collect();
let tests = if suggest_tests {
find_affected_tests_cross(ctx, &visited)?
} else {
Vec::new()
};
if include_types {
tracing::warn!("--type-impact not supported in cross-project mode");
}
Ok(ImpactResult {
function_name: name.to_string(),
callers,
tests,
transitive_callers,
type_impacted: Vec::new(),
degraded: false,
})
}
fn find_affected_tests_cross(
ctx: &mut CrossProjectContext,
visited: &HashMap<String, (usize, String)>,
) -> Result<Vec<TestInfo>, StoreError> {
let _span = tracing::info_span!("find_affected_tests_cross").entered();
let test_chunks = ctx.find_test_chunks_cross()?;
let visited_names: HashSet<&str> = visited.keys().map(|s| s.as_str()).collect();
let tests: Vec<TestInfo> = test_chunks
.iter()
.filter(|tc| visited_names.contains(tc.chunk.name.as_str()))
.map(|tc| {
let depth = visited.get(&tc.chunk.name).map(|(d, _)| *d).unwrap_or(0);
TestInfo {
name: tc.chunk.name.clone(),
file: tc.chunk.file.clone(),
line: tc.chunk.line_start,
call_depth: depth,
}
})
.collect();
Ok(tests)
}
pub fn trace_cross(
ctx: &mut CrossProjectContext,
source: &str,
target: &str,
max_depth: usize,
) -> Result<Option<Vec<CrossProjectHop>>, StoreError> {
let _span = tracing::info_span!(
"trace_cross",
source,
target,
max_depth,
projects = ctx.project_count()
)
.entered();
if source == target {
return Ok(Some(vec![CrossProjectHop {
name: source.to_string(),
project: String::new(),
}]));
}
let mut visited: HashMap<String, (String, String)> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
visited.insert(source.to_string(), (String::new(), String::new()));
queue.push_back((source.to_string(), 0));
while let Some((current, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
let callees = ctx.get_callees_cross(¤t)?;
for callee in callees {
if callee.name == target {
if let Some((_, current_project)) = visited.get(¤t) {
if !current_project.is_empty() && *current_project != callee.project {
tracing::debug!(
from_project = %current_project,
to_project = %callee.project,
caller = %current,
callee = %callee.name,
"Cross-boundary hop in trace BFS"
);
}
}
let mut path = vec![CrossProjectHop {
name: callee.name.clone(),
project: callee.project.clone(),
}];
let mut node = current.clone();
loop {
let (pred, proj) = visited.get(&node).cloned().unwrap_or_default();
path.push(CrossProjectHop {
name: node.clone(),
project: proj,
});
if pred.is_empty() {
break;
}
node = pred;
}
path.reverse();
return Ok(Some(path));
}
if !visited.contains_key(&callee.name) {
if let Some((_, current_project)) = visited.get(¤t) {
if !current_project.is_empty() && *current_project != callee.project {
tracing::debug!(
from_project = %current_project,
to_project = %callee.project,
caller = %current,
callee = %callee.name,
"Cross-boundary hop in trace BFS"
);
}
}
visited.insert(
callee.name.clone(),
(current.clone(), callee.project.clone()),
);
queue.push_back((callee.name.clone(), depth + 1));
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::calls::cross_project::NamedStore;
use crate::Store;
fn make_named_store(name: &str, forward_edges: Vec<(&str, &str)>) -> NamedStore {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join(crate::INDEX_DB_FILENAME);
let model_info = crate::store::helpers::ModelInfo::default();
let store = Store::<crate::store::ReadOnly>::open_readonly_after_init(&db_path, |store| {
store.init(&model_info)?;
for (caller, callee) in &forward_edges {
store
.rt
.block_on(async {
sqlx::query(
"INSERT OR IGNORE INTO function_calls (file, caller_name, callee_name, caller_line, call_line)
VALUES ('test.rs', ?1, ?2, 1, 1)",
)
.bind(caller)
.bind(callee)
.execute(&store.pool)
.await
})?;
}
Ok(())
})
.unwrap();
let _keep = dir.into_path();
NamedStore {
name: name.to_string(),
store,
}
}
#[test]
fn test_cross_project_impact_cross_boundary() {
let store_a = make_named_store("proj_a", vec![("caller_a", "shared_fn")]);
let store_b = make_named_store("proj_b", vec![("caller_b", "shared_fn")]);
let mut ctx = CrossProjectContext::new(vec![store_a, store_b]);
let result = analyze_impact_cross(&mut ctx, "shared_fn", 3, false, false).unwrap();
assert_eq!(result.function_name, "shared_fn");
assert_eq!(
result.callers.len(),
2,
"Should find callers from both projects"
);
let caller_names: HashSet<&str> = result.callers.iter().map(|c| c.name.as_str()).collect();
assert!(caller_names.contains("caller_a"));
assert!(caller_names.contains("caller_b"));
}
#[test]
fn test_cross_project_impact_depth_limit() {
let store_a = make_named_store("proj_a", vec![("deep", "mid"), ("mid", "target")]);
let mut ctx = CrossProjectContext::new(vec![store_a]);
let result = analyze_impact_cross(&mut ctx, "target", 1, false, false).unwrap();
let caller_names: HashSet<&str> = result.callers.iter().map(|c| c.name.as_str()).collect();
assert!(caller_names.contains("mid"), "mid is at depth 1");
assert!(
!caller_names.contains("deep"),
"deep is at depth 2, beyond limit"
);
}
#[test]
fn test_cross_project_trace_found() {
let store_a = make_named_store("proj_a", vec![("source", "mid")]);
let store_b = make_named_store("proj_b", vec![("mid", "target")]);
let mut ctx = CrossProjectContext::new(vec![store_a, store_b]);
let result = trace_cross(&mut ctx, "source", "target", 10).unwrap();
assert!(result.is_some(), "Should find path across projects");
let path = result.unwrap();
assert_eq!(path.len(), 3);
let names: Vec<&str> = path.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, vec!["source", "mid", "target"]);
}
#[test]
fn test_cross_project_trace_no_path() {
let store_a = make_named_store("proj_a", vec![("source", "mid")]);
let store_b = make_named_store("proj_b", vec![("unrelated", "target")]);
let mut ctx = CrossProjectContext::new(vec![store_a, store_b]);
let result = trace_cross(&mut ctx, "source", "target", 10).unwrap();
assert!(
result.is_none(),
"No path should exist between disconnected functions"
);
}
}