diff --git a/src/index/graph.rs b/src/index/graph.rs index edf23c6..4fc1381 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -136,6 +136,38 @@ pub fn find_callers( Ok(rows.filter_map(|r| r.ok()).collect()) } +/// Find callers of a symbol across ALL projects (cross-project fallback). +/// +/// Used when project-scoped `find_callers` returns empty — the symbol may +/// exist in another indexed project. Returns results with the project root +/// path so the caller can display which project the match came from. +pub fn find_callers_cross_project( + conn: &Connection, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + let mut stmt = conn.prepare( + "SELECT DISTINCT cg.caller, cg.file, cg.line, p.root_path + FROM call_graph cg + JOIN projects p ON cg.project_id = p.id + WHERE cg.callee LIKE ?1 + LIMIT ?2", + )?; + + let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| { + Ok(CrossProjectCallerResult { + caller: row.get(0)?, + file: row.get(1)?, + line: row.get::<_, i64>(2)? as u32, + project_root: row.get(3)?, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + /// Find all callees of a symbol (what does this call?), scoped to a project. /// /// Returns symbols that are called by the given name. @@ -231,6 +263,15 @@ pub struct CallerResult { pub line: u32, } +/// A cross-project caller result (includes which project the caller belongs to). +#[derive(Debug, Clone, serde::Serialize)] +pub struct CrossProjectCallerResult { + pub caller: String, + pub file: String, + pub line: u32, + pub project_root: String, +} + /// A callee result entry. #[allow(dead_code)] #[derive(Debug, Clone, serde::Serialize)] @@ -583,6 +624,58 @@ mod tests { assert!(names.contains(&"handler")); } + #[test] + fn test_find_callers_cross_project() { + let conn = mem_conn(); + let project_a = + super::super::schema::get_or_create_project(&conn, "/tmp/project-a").unwrap(); + let project_b = + super::super::schema::get_or_create_project(&conn, "/tmp/project-b").unwrap(); + + // Store edges in project A + store_edges( + &conn, + &[CallEdge { + caller: "handler_a".to_string(), + callee: "shared_util".to_string(), + file: "handler.rs".to_string(), + line: 5, + }], + project_a, + ) + .unwrap(); + + // Store edges in project B + store_edges( + &conn, + &[CallEdge { + caller: "handler_b".to_string(), + callee: "shared_util".to_string(), + file: "lib.rs".to_string(), + line: 10, + }], + project_b, + ) + .unwrap(); + + // Scoped to project A: only finds handler_a + let scoped = find_callers(&conn, project_a, "shared_util", 10).unwrap(); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].caller, "handler_a"); + + // Scoped to project B: only finds handler_b + let scoped_b = find_callers(&conn, project_b, "shared_util", 10).unwrap(); + assert_eq!(scoped_b.len(), 1); + assert_eq!(scoped_b[0].caller, "handler_b"); + + // Cross-project: finds BOTH + let cross = find_callers_cross_project(&conn, "shared_util", 10).unwrap(); + assert_eq!(cross.len(), 2); + let roots: Vec<&str> = cross.iter().map(|c| c.project_root.as_str()).collect(); + assert!(roots.contains(&"/tmp/project-a")); + assert!(roots.contains(&"/tmp/project-b")); + } + #[test] fn test_find_callees() { let conn = mem_conn(); diff --git a/src/main.rs b/src/main.rs index d56ce74..d979982 100644 --- a/src/main.rs +++ b/src/main.rs @@ -769,8 +769,51 @@ async fn main() -> Result<()> { let project_id = index::ensure_project(&conn, &project_root)?; let callers = index::graph::find_callers(&conn, project_id, &symbol, limit)?; + // Cross-project fallback: if no callers in current project, + // search across all indexed projects. + let cross_project_results = if callers.is_empty() { + let cp = index::graph::find_callers_cross_project(&conn, &symbol, limit)?; + if !cp.is_empty() { + if !json { + eprintln!( + "{}", + format!( + "No callers in current project. Found {} in other project(s):", + cp.len() + ) + .yellow() + ); + } + Some(cp) + } else { + None + } + } else { + None + }; + if json { - println!("{}", serde_json::to_string_pretty(&callers)?); + if let Some(ref cp) = cross_project_results { + println!("{}", serde_json::to_string_pretty(cp)?); + } else { + println!("{}", serde_json::to_string_pretty(&callers)?); + } + } else if callers.is_empty() && cross_project_results.is_some() { + let cp = cross_project_results.as_ref().unwrap(); + println!( + "{}", + format!("Callers of '{symbol}' ({}):", cp.len()).cyan() + ); + println!("{}", "─".repeat(50).dimmed()); + for c in cp { + println!( + " {} {}:{} {}", + c.caller.white().bold(), + c.file.dimmed(), + c.line, + c.project_root.dimmed().italic(), + ); + } } else if callers.is_empty() { eprintln!("{}", format!("No callers found for '{symbol}'.").yellow()); } else {