From bf37720e96154773976fe244b018bd01157e58ee Mon Sep 17 00:00:00 2001 From: ajianaz Date: Mon, 27 Jul 2026 01:15:18 +0700 Subject: [PATCH 1/2] fix(callers): add cross-project fallback when symbol not found in current project `cora callers` returns empty when CWD does not match any indexed project root. This is because find_callers() queries are scoped by project_id, which is resolved from CWD via ensure_project(). When the scoped query returns empty, we now fall back to searching across all indexed projects using find_callers_cross_project(), which joins call_graph with the projects table to include project_root in results. Changes: - Add find_callers_cross_project() in graph.rs (queries all projects) - Add CrossProjectCallerResult struct with project_root field - CLI: fallback to cross-project search when scoped search is empty - CLI: display warning + project_root per result in cross-project mode - JSON: serialize CrossProjectCallerResult (includes project_root) - Add test_find_callers_cross_project unit test Fixes #381 --- src/index/graph.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 45 +++++++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/index/graph.rs b/src/index/graph.rs index edf23c6..4ec2069 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -136,6 +136,41 @@ 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 +266,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 +627,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 { From 0d048704d4a2f49d93c44e8c20645728df0f9c06 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Mon, 27 Jul 2026 01:21:04 +0700 Subject: [PATCH 2/2] style: rustfmt --- src/index/graph.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/index/graph.rs b/src/index/graph.rs index 4ec2069..4fc1381 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -156,17 +156,14 @@ pub fn find_callers_cross_project( 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)?, - }) - }, - )?; + 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()) }