1use std::collections::HashMap;
8
9use gobby_core::falkor::GraphClient;
10
11use crate::config::{Context, FalkorConfig};
12use crate::graph::typed_query;
13use crate::models::GraphResult;
14
15const MAX_GRAPH_LIMIT: usize = 100;
16
17pub type Row = gobby_core::falkor::Row;
19
20pub struct FalkorClient {
22 client: GraphClient,
23}
24
25impl FalkorClient {
26 pub fn from_config(config: &FalkorConfig) -> anyhow::Result<Self> {
27 let connection_config = config.connection_config();
28 let client = GraphClient::from_config(&connection_config, &config.graph_name)?;
29 Ok(Self { client })
30 }
31
32 pub fn query(
34 &mut self,
35 cypher: &str,
36 params: Option<HashMap<String, String>>,
37 ) -> anyhow::Result<Vec<Row>> {
38 self.client.query(cypher, params)
39 }
40
41 pub fn query_typed(&mut self, query: typed_query::TypedQuery) -> anyhow::Result<Vec<Row>> {
43 let typed_query::TypedQuery { cypher, params } = query;
44 self.query(&cypher, Some(params))
45 }
46
47 pub fn with_core_client<T>(
48 &mut self,
49 f: impl FnOnce(&mut GraphClient) -> anyhow::Result<T>,
50 ) -> anyhow::Result<T> {
51 f(&mut self.client)
52 }
53}
54
55pub fn cypher_string_literal(s: &str) -> String {
56 crate::graph::typed_query::cypher_string_literal(s)
57}
58
59pub fn id_list_literal(ids: &[String]) -> String {
60 typed_query::id_list_literal(ids)
61}
62
63pub fn clamp_offset(offset: usize) -> usize {
64 offset.min(MAX_GRAPH_LIMIT)
65}
66
67pub fn count_callers_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
68 crate::graph::code_graph::count_callers_query(project_id, symbol_id)
69}
70
71pub fn count_usages_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
72 crate::graph::code_graph::count_usages_query(project_id, symbol_id)
73}
74
75pub fn find_callers_query(
76 project_id: &str,
77 symbol_id: &str,
78 offset: usize,
79 limit: usize,
80) -> (String, HashMap<String, String>) {
81 crate::graph::code_graph::find_callers_query(project_id, symbol_id, offset, limit)
82}
83
84pub fn find_usages_query(
85 project_id: &str,
86 symbol_id: &str,
87 offset: usize,
88 limit: usize,
89) -> (String, HashMap<String, String>) {
90 crate::graph::code_graph::find_usages_query(project_id, symbol_id, offset, limit)
91}
92
93pub fn find_callers_batch_query(
94 project_id: &str,
95 symbol_ids: &[String],
96 limit: usize,
97) -> (String, HashMap<String, String>) {
98 crate::graph::code_graph::find_callers_batch_query(project_id, symbol_ids, limit)
99}
100
101pub fn find_callees_batch_query(
102 project_id: &str,
103 symbol_ids: &[String],
104 limit: usize,
105) -> (String, HashMap<String, String>) {
106 crate::graph::code_graph::find_callees_batch_query(project_id, symbol_ids, limit)
107}
108
109pub fn get_imports_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
110 crate::graph::code_graph::get_imports_query(project_id, file_path)
111}
112
113pub fn blast_radius_query(depth: usize, limit: usize) -> String {
114 crate::graph::code_graph::blast_radius_query(depth, limit)
115}
116
117pub fn with_falkor<T>(
118 ctx: &Context,
119 default: T,
120 f: impl FnOnce(&mut FalkorClient) -> anyhow::Result<T>,
121) -> anyhow::Result<T> {
122 let Some(config) = &ctx.falkordb else {
123 return Ok(default);
124 };
125
126 let mut client = match FalkorClient::from_config(config) {
127 Ok(client) => client,
128 Err(e) => {
129 if !ctx.quiet {
130 eprintln!("Warning: FalkorDB connection failed: {e}");
131 }
132 return Ok(default);
133 }
134 };
135
136 match f(&mut client) {
137 Ok(value) => Ok(value),
138 Err(e) => {
139 if !ctx.quiet {
140 eprintln!("Warning: FalkorDB query failed: {e}");
141 }
142 Ok(default)
143 }
144 }
145}
146
147pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
149 crate::graph::code_graph::count_callers(ctx, symbol_id)
150}
151
152pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
154 crate::graph::code_graph::count_usages(ctx, symbol_id)
155}
156
157pub fn find_callers(
159 ctx: &Context,
160 symbol_id: &str,
161 offset: usize,
162 limit: usize,
163) -> anyhow::Result<Vec<GraphResult>> {
164 crate::graph::code_graph::find_callers(ctx, symbol_id, offset, limit)
165}
166
167pub fn find_usages(
169 ctx: &Context,
170 symbol_id: &str,
171 offset: usize,
172 limit: usize,
173) -> anyhow::Result<Vec<GraphResult>> {
174 crate::graph::code_graph::find_usages(ctx, symbol_id, offset, limit)
175}
176
177pub fn find_callers_batch(
179 ctx: &Context,
180 symbol_ids: &[String],
181 limit: usize,
182) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
183 let mut grouped = HashMap::new();
184 for symbol_id in symbol_ids {
185 grouped.insert(
186 symbol_id.clone(),
187 crate::graph::code_graph::find_callers(ctx, symbol_id, 0, limit)?,
188 );
189 }
190 Ok(grouped)
191}
192
193pub fn find_callees_batch(
195 ctx: &Context,
196 symbol_ids: &[String],
197 limit: usize,
198) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
199 let mut grouped = HashMap::new();
200 for symbol_id in symbol_ids {
201 grouped.insert(
202 symbol_id.clone(),
203 crate::graph::code_graph::find_callees_batch(
204 ctx,
205 std::slice::from_ref(symbol_id),
206 limit,
207 )?,
208 );
209 }
210 Ok(grouped)
211}
212
213pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
215 crate::graph::code_graph::get_imports(ctx, file_path)
216}
217
218pub fn blast_radius(
220 ctx: &Context,
221 symbol_id: &str,
222 depth: usize,
223) -> anyhow::Result<Vec<GraphResult>> {
224 crate::graph::code_graph::blast_radius(ctx, symbol_id, depth)
225}
226
227#[cfg(test)]
228fn row_to_graph_result(row: &Row) -> GraphResult {
229 crate::graph::code_graph::row_to_graph_result(row)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use serde_json::json;
236
237 const CALL_TARGET_FRAGMENT: &str =
238 "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
239
240 fn assert_no_numeric_or_list_placeholders(query: &str) {
241 assert!(!query.contains("$offset"), "{query}");
242 assert!(!query.contains("$limit"), "{query}");
243 assert!(!query.contains("$ids"), "{query}");
244 }
245
246 #[test]
247 fn cypher_string_literal_escapes_single_quotes_and_backslashes() {
248 assert_eq!(
249 cypher_string_literal("module\\path'symbol"),
250 "'module\\\\path\\'symbol'"
251 );
252 }
253
254 #[test]
255 fn find_callers_query_interpolates_numeric_skip_and_limit() {
256 let (query, params) = find_callers_query("project-1", "symbol-1", 250, 0);
257
258 assert!(query.contains("SKIP 100 LIMIT 1"), "{query}");
259 assert_no_numeric_or_list_placeholders(&query);
260 assert_eq!(
261 params.get("project").map(String::as_str),
262 Some("'project-1'")
263 );
264 assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
265 }
266
267 #[test]
268 fn find_usages_query_clamps_numeric_skip_and_limit() {
269 let (query, params) = find_usages_query("project-1", "symbol-1", 250, 250);
270
271 assert!(query.contains("SKIP 100 LIMIT 100"), "{query}");
272 assert_no_numeric_or_list_placeholders(&query);
273 assert_eq!(
274 params.get("project").map(String::as_str),
275 Some("'project-1'")
276 );
277 assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
278 }
279
280 #[test]
281 fn batch_query_uses_one_interpolated_in_list() {
282 let (query, params) =
283 find_callers_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
284
285 assert_eq!(query.matches(" IN [").count(), 1, "{query}");
286 assert!(query.contains("target.id IN ['a', 'b\\'\\\\c']"), "{query}");
287 assert!(query.contains("LIMIT 100"), "{query}");
288 assert_no_numeric_or_list_placeholders(&query);
289 assert_eq!(
290 params.get("project").map(String::as_str),
291 Some("'project-1'")
292 );
293 }
294
295 #[test]
296 fn blast_radius_query_clamps_depth_and_interpolates_limit() {
297 let query = blast_radius_query(99, 250);
298
299 assert!(query.contains(CALL_TARGET_FRAGMENT), "{query}");
300 assert!(query.contains("[:CALLS*1..5]"), "{query}");
301 assert!(query.contains("LIMIT 100"), "{query}");
302 assert_no_numeric_or_list_placeholders(&query);
303 }
304
305 #[test]
306 fn row_to_graph_result_prefers_blast_radius_node_fields() {
307 let row = Row::from([
308 ("node_id".to_string(), json!("sym-1")),
309 ("node_name".to_string(), json!("foo")),
310 ("file_path".to_string(), json!("src/main.py")),
311 ("line".to_string(), json!(42)),
312 ("rel_type".to_string(), json!("call")),
313 ("distance".to_string(), json!(2)),
314 ]);
315
316 let result = row_to_graph_result(&row);
317
318 assert_eq!(result.id, "sym-1");
319 assert_eq!(result.name, "foo");
320 assert_eq!(result.file_path, "src/main.py");
321 assert_eq!(result.line, 42);
322 assert_eq!(result.relation.as_deref(), Some("call"));
323 assert_eq!(result.distance, Some(2));
324 }
325
326 #[test]
327 fn phase7_query_helpers_preserve_safe_literals_clamping_and_project_scope() {
328 let project_id = "project\n'one";
329 let symbol_id = "symbol\"\\'two";
330 let expected_project = cypher_string_literal(project_id);
331 let expected_symbol = cypher_string_literal(symbol_id);
332
333 let (callers, caller_params) = find_callers_query(project_id, symbol_id, 250, 0);
334 assert!(callers.contains(CALL_TARGET_FRAGMENT), "{callers}");
335 assert!(callers.contains("SKIP 100 LIMIT 1"), "{callers}");
336 assert_no_numeric_or_list_placeholders(&callers);
337 assert_eq!(caller_params.get("project"), Some(&expected_project));
338 assert_eq!(caller_params.get("id"), Some(&expected_symbol));
339
340 let (usages, usage_params) = find_usages_query(project_id, symbol_id, 250, 250);
341 assert!(usages.contains(CALL_TARGET_FRAGMENT), "{usages}");
342 assert!(usages.contains("SKIP 100 LIMIT 100"), "{usages}");
343 assert_no_numeric_or_list_placeholders(&usages);
344 assert_eq!(usage_params.get("project"), Some(&expected_project));
345 assert_eq!(usage_params.get("id"), Some(&expected_symbol));
346
347 let ids = ["a".to_string(), "b\n\"'\\c".to_string()];
348 let expected_ids = id_list_literal(&ids);
349 let (batch, batch_params) = find_callers_batch_query(project_id, &ids, 250);
350 assert!(
351 batch.contains(&format!("target.id IN [{expected_ids}]")),
352 "{batch}"
353 );
354 assert!(batch.contains("LIMIT 100"), "{batch}");
355 assert_no_numeric_or_list_placeholders(&batch);
356 assert_eq!(batch_params.get("project"), Some(&expected_project));
357
358 let blast = blast_radius_query(99, 250);
359 assert!(blast.contains(CALL_TARGET_FRAGMENT), "{blast}");
360 assert!(blast.contains("[:CALLS*1..5]"), "{blast}");
361 assert!(blast.contains("LIMIT 100"), "{blast}");
362 assert_no_numeric_or_list_placeholders(&blast);
363 }
364}