1use std::collections::{BTreeMap, HashSet};
9
10use crate::config::Context;
11use crate::graph::code_graph;
12use crate::search::fts;
13use crate::visibility;
14
15pub fn graph_boost(ctx: &Context, conn: Option<&mut postgres::Client>, query: &str) -> Vec<String> {
22 if ctx.falkordb.is_none() {
23 return vec![];
24 }
25
26 let Some(conn) = conn else {
27 return vec![];
28 };
29 let mut resolved =
30 fts::search_symbols_exact_first_visible(conn, query, ctx, None, None, &[], 1).results;
31 let Some(symbol) = resolved.pop() else {
32 return vec![];
33 };
34 let graph_ctx = visibility::context_for_source_project(ctx, &symbol.project_id);
35
36 let callers = code_graph::find_caller_ids(&graph_ctx, &symbol.id, 10).unwrap_or_default();
37 let usages = code_graph::find_usage_ids(&graph_ctx, &symbol.id, 10).unwrap_or_default();
38
39 let mut ids = Vec::new();
40 let mut seen = HashSet::new();
41 for id in callers.into_iter().chain(usages) {
42 if !id.is_empty() && seen.insert(id.clone()) {
43 ids.push(id);
44 }
45 }
46 ids
47}
48
49pub fn graph_expand(
56 ctx: &Context,
57 conn: Option<&mut postgres::Client>,
58 seed_ids: &[String],
59) -> Vec<String> {
60 if seed_ids.is_empty() || ctx.falkordb.is_none() {
61 return vec![];
62 }
63
64 let mut by_project: BTreeMap<String, Vec<String>> = BTreeMap::new();
65 let Some(conn) = conn else {
66 return vec![];
67 };
68 if let Ok(symbols) = visibility::visible_symbols_by_ids(conn, ctx, seed_ids) {
69 for symbol in symbols {
70 by_project
71 .entry(symbol.project_id)
72 .or_default()
73 .push(symbol.id);
74 }
75 }
76
77 graph_expand_grouped(ctx, by_project, |graph_ctx, ids_for_project| {
78 let callees =
80 code_graph::find_callee_ids_batch(graph_ctx, ids_for_project, 30).unwrap_or_default();
81 let callers =
83 code_graph::find_caller_ids_batch(graph_ctx, ids_for_project, 30).unwrap_or_default();
84 (callees, callers)
85 })
86}
87
88fn graph_expand_grouped(
89 ctx: &Context,
90 by_project: BTreeMap<String, Vec<String>>,
91 mut graph_neighbors: impl FnMut(&Context, &[String]) -> (Vec<String>, Vec<String>),
92) -> Vec<String> {
93 let mut ids = Vec::new();
94 let mut seen = HashSet::new();
95 for (project_id, ids_for_project) in by_project {
96 let graph_ctx = visibility::context_for_source_project(ctx, &project_id);
97 let (callees, callers) = graph_neighbors(&graph_ctx, &ids_for_project);
98 for id in callees.into_iter().chain(callers) {
99 if id.is_empty() || !seen.insert(id.clone()) {
100 continue;
101 }
102 ids.push(id);
103 }
104 }
105 ids
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use std::path::PathBuf;
112
113 fn make_ctx_no_falkordb() -> Context {
114 Context {
115 database_url: "postgresql://localhost/nonexistent".to_string(),
116 project_root: PathBuf::from("/nonexistent"),
117 project_id: "test".to_string(),
118 quiet: true,
119 falkordb: None,
120 qdrant: None,
121 embedding: None,
122 code_vectors: crate::config::CodeVectorSettings::default(),
123 indexing: gobby_core::config::IndexingConfig::default(),
124 daemon_url: None,
125 index_scope: crate::config::ProjectIndexScope::Single,
126 }
127 }
128
129 fn make_ctx_with_overlay() -> Context {
130 Context {
131 database_url: "postgresql://localhost/nonexistent".to_string(),
132 project_root: PathBuf::from("/overlay"),
133 project_id: "overlay".to_string(),
134 quiet: true,
135 falkordb: Some(crate::config::FalkorConfig {
136 host: "127.0.0.1".to_string(),
137 port: 16379,
138 password: None,
139 graph_name: "g".to_string(),
140 }),
141 qdrant: None,
142 embedding: None,
143 code_vectors: crate::config::CodeVectorSettings::default(),
144 indexing: gobby_core::config::IndexingConfig::default(),
145 daemon_url: None,
146 index_scope: crate::config::ProjectIndexScope::Overlay {
147 overlay_project_id: "overlay".to_string(),
148 overlay_root: PathBuf::from("/overlay"),
149 parent_project_id: "parent".to_string(),
150 parent_root: PathBuf::from("/parent"),
151 },
152 }
153 }
154
155 #[test]
156 fn test_graph_boost_no_falkordb() {
157 let ctx = make_ctx_no_falkordb();
158 let result = graph_boost(&ctx, None, "some_function");
159 assert!(result.is_empty());
160 }
161
162 #[test]
163 fn test_graph_expand_no_falkordb() {
164 let ctx = make_ctx_no_falkordb();
165 let result = graph_expand(&ctx, None, &["some_function".to_string()]);
166 assert!(result.is_empty());
167 }
168
169 #[test]
170 fn test_graph_expand_empty_seeds() {
171 let ctx = make_ctx_no_falkordb();
172 let result = graph_expand(&ctx, None, &[]);
173 assert!(result.is_empty());
174 }
175
176 #[test]
177 fn graph_expand_grouped_expands_each_project_scope_and_dedupes() {
178 let ctx = make_ctx_with_overlay();
179 let by_project = BTreeMap::from([
180 (
181 "overlay".to_string(),
182 vec!["overlay-seed-1".to_string(), "overlay-seed-2".to_string()],
183 ),
184 ("parent".to_string(), vec!["parent-seed".to_string()]),
185 ]);
186 let mut calls = Vec::new();
187
188 let expanded = graph_expand_grouped(&ctx, by_project, |graph_ctx, ids| {
189 calls.push((graph_ctx.project_id.clone(), ids.to_vec()));
190 match graph_ctx.project_id.as_str() {
191 "overlay" => (
192 vec!["impl-a".to_string(), "shared".to_string()],
193 vec!["caller-a".to_string(), "shared".to_string()],
194 ),
195 "parent" => (
196 vec!["parent-impl".to_string(), "impl-a".to_string()],
197 vec!["".to_string(), "parent-caller".to_string()],
198 ),
199 other => panic!("unexpected project {other}"),
200 }
201 });
202
203 assert_eq!(
204 calls,
205 vec![
206 (
207 "overlay".to_string(),
208 vec!["overlay-seed-1".to_string(), "overlay-seed-2".to_string()]
209 ),
210 ("parent".to_string(), vec!["parent-seed".to_string()])
211 ]
212 );
213 assert_eq!(
214 expanded,
215 vec![
216 "impl-a".to_string(),
217 "shared".to_string(),
218 "caller-a".to_string(),
219 "parent-impl".to_string(),
220 "parent-caller".to_string()
221 ]
222 );
223 }
224}