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 {
68 offset.min(MAX_GRAPH_LIMIT)
69}
70
71pub fn count_callers_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
72 crate::graph::code_graph::count_callers_query(project_id, symbol_id)
73}
74
75pub fn count_usages_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
76 crate::graph::code_graph::count_usages_query(project_id, symbol_id)
77}
78
79pub fn find_callers_query(
80 project_id: &str,
81 symbol_id: &str,
82 offset: usize,
83 limit: usize,
84) -> (String, HashMap<String, String>) {
85 crate::graph::code_graph::find_callers_query(project_id, symbol_id, offset, limit)
86}
87
88pub fn find_usages_query(
89 project_id: &str,
90 symbol_id: &str,
91 offset: usize,
92 limit: usize,
93) -> (String, HashMap<String, String>) {
94 crate::graph::code_graph::find_usages_query(project_id, symbol_id, offset, limit)
95}
96
97pub fn find_callers_batch_query(
98 project_id: &str,
99 symbol_ids: &[String],
100 limit: usize,
101) -> (String, HashMap<String, String>) {
102 crate::graph::code_graph::find_callers_batch_query(project_id, symbol_ids, limit)
103}
104
105pub fn find_callees_batch_query(
106 project_id: &str,
107 symbol_ids: &[String],
108 limit: usize,
109) -> (String, HashMap<String, String>) {
110 crate::graph::code_graph::find_callees_batch_query(project_id, symbol_ids, limit)
111}
112
113pub fn get_imports_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
114 crate::graph::code_graph::get_imports_query(project_id, file_path)
115}
116
117pub fn blast_radius_query(depth: usize, limit: usize) -> String {
118 crate::graph::code_graph::blast_radius_query(depth, limit)
119}
120
121pub fn with_falkor<T>(
122 ctx: &Context,
123 default: T,
124 f: impl FnOnce(&mut FalkorClient) -> anyhow::Result<T>,
125) -> anyhow::Result<T> {
126 let Some(config) = &ctx.falkordb else {
127 return Ok(default);
128 };
129
130 let mut client = match FalkorClient::from_config(config) {
131 Ok(client) => client,
132 Err(e) => {
133 if !ctx.quiet {
134 eprintln!("Warning: FalkorDB connection failed: {e}");
135 }
136 return Ok(default);
137 }
138 };
139
140 match f(&mut client) {
141 Ok(value) => Ok(value),
142 Err(e) => {
143 if !ctx.quiet {
144 eprintln!("Warning: FalkorDB query failed: {e}");
145 }
146 Ok(default)
147 }
148 }
149}
150
151pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
153 crate::graph::code_graph::count_callers(ctx, symbol_id)
154}
155
156pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
158 crate::graph::code_graph::count_usages(ctx, symbol_id)
159}
160
161pub fn find_callers(
163 ctx: &Context,
164 symbol_id: &str,
165 offset: usize,
166 limit: usize,
167) -> anyhow::Result<Vec<GraphResult>> {
168 crate::graph::code_graph::find_callers(ctx, symbol_id, offset, limit)
169}
170
171pub fn find_usages(
173 ctx: &Context,
174 symbol_id: &str,
175 offset: usize,
176 limit: usize,
177) -> anyhow::Result<Vec<GraphResult>> {
178 crate::graph::code_graph::find_usages(ctx, symbol_id, offset, limit)
179}
180
181pub fn find_callers_batch(
183 ctx: &Context,
184 symbol_ids: &[String],
185 limit: usize,
186) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
187 let mut grouped = HashMap::new();
188 for symbol_id in symbol_ids {
189 grouped.insert(
190 symbol_id.clone(),
191 crate::graph::code_graph::find_callers(ctx, symbol_id, 0, limit)?,
192 );
193 }
194 Ok(grouped)
195}
196
197pub fn find_callees_batch(
199 ctx: &Context,
200 symbol_ids: &[String],
201 limit: usize,
202) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
203 let mut grouped = HashMap::new();
204 for symbol_id in symbol_ids {
205 grouped.insert(
206 symbol_id.clone(),
207 crate::graph::code_graph::find_callees_batch(
208 ctx,
209 std::slice::from_ref(symbol_id),
210 limit,
211 )?,
212 );
213 }
214 Ok(grouped)
215}
216
217pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
219 crate::graph::code_graph::get_imports(ctx, file_path)
220}
221
222pub fn blast_radius(
224 ctx: &Context,
225 symbol_id: &str,
226 depth: usize,
227) -> anyhow::Result<Vec<GraphResult>> {
228 crate::graph::code_graph::blast_radius(ctx, symbol_id, depth)
229}
230
231#[cfg(test)]
232fn row_to_graph_result(row: &Row) -> GraphResult {
233 crate::graph::code_graph::row_to_graph_result(row)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use serde_json::json;
240
241 const CALL_TARGET_FRAGMENT: &str =
242 "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
243
244 fn assert_no_numeric_or_list_placeholders(query: &str) {
245 assert!(!query.contains("$offset"), "{query}");
246 assert!(!query.contains("$limit"), "{query}");
247 assert!(!query.contains("$ids"), "{query}");
248 }
249
250 #[test]
251 fn cypher_string_literal_escapes_single_quotes_and_backslashes() {
252 assert_eq!(
253 cypher_string_literal("module\\path'symbol"),
254 "'module\\\\path\\'symbol'"
255 );
256 }
257
258 #[test]
259 fn find_callers_query_interpolates_numeric_skip_and_limit() {
260 let (query, params) = find_callers_query("project-1", "symbol-1", 250, 0);
261
262 assert!(query.contains("SKIP 100 LIMIT 1"), "{query}");
263 assert_no_numeric_or_list_placeholders(&query);
264 assert_eq!(
265 params.get("project").map(String::as_str),
266 Some("'project-1'")
267 );
268 assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
269 }
270
271 #[test]
272 fn find_usages_query_clamps_numeric_skip_and_limit() {
273 let (query, params) = find_usages_query("project-1", "symbol-1", 250, 250);
274
275 assert!(query.contains("SKIP 100 LIMIT 100"), "{query}");
276 assert_no_numeric_or_list_placeholders(&query);
277 assert_eq!(
278 params.get("project").map(String::as_str),
279 Some("'project-1'")
280 );
281 assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
282 }
283
284 #[test]
285 fn batch_query_uses_one_interpolated_in_list() {
286 let (query, params) =
287 find_callers_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
288
289 assert_eq!(query.matches(" IN [").count(), 1, "{query}");
290 assert!(query.contains("target.id IN ['a', 'b\\'\\\\c']"), "{query}");
291 assert!(
292 query.contains("WITH caller, min(r.file) AS file, min(r.line) AS line"),
293 "{query}"
294 );
295 assert!(query.contains("ORDER BY caller.id"), "{query}");
296 assert!(query.contains("LIMIT 100"), "{query}");
297 assert_no_numeric_or_list_placeholders(&query);
298 assert_eq!(
299 params.get("project").map(String::as_str),
300 Some("'project-1'")
301 );
302
303 let (callee_query, callee_params) =
304 find_callees_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
305
306 assert_eq!(callee_query.matches(" IN [").count(), 1, "{callee_query}");
307 assert!(
308 callee_query.contains("src.id IN ['a', 'b\\'\\\\c']"),
309 "{callee_query}"
310 );
311 assert!(
312 callee_query.contains("WITH target, min(r.file) AS file, min(r.line) AS line"),
313 "{callee_query}"
314 );
315 assert!(
316 callee_query.contains("ORDER BY target.id"),
317 "{callee_query}"
318 );
319 assert!(callee_query.contains("LIMIT 100"), "{callee_query}");
320 assert_no_numeric_or_list_placeholders(&callee_query);
321 assert_eq!(
322 callee_params.get("project").map(String::as_str),
323 Some("'project-1'")
324 );
325 }
326
327 #[test]
328 fn blast_radius_query_clamps_depth_and_interpolates_limit() {
329 let query = blast_radius_query(99, 250);
330
331 assert!(query.contains(CALL_TARGET_FRAGMENT), "{query}");
332 assert!(query.contains("[:CALLS*1..5]"), "{query}");
333 assert!(query.contains("LIMIT 100"), "{query}");
334 assert_no_numeric_or_list_placeholders(&query);
335 }
336
337 #[test]
338 fn row_to_graph_result_prefers_blast_radius_node_fields() {
339 let row = Row::from([
340 ("node_id".to_string(), json!("sym-1")),
341 ("node_name".to_string(), json!("foo")),
342 ("file_path".to_string(), json!("src/main.py")),
343 ("line".to_string(), json!(42)),
344 ("rel_type".to_string(), json!("call")),
345 ("distance".to_string(), json!(2)),
346 ]);
347
348 let result = row_to_graph_result(&row);
349
350 assert_eq!(result.id, "sym-1");
351 assert_eq!(result.name, "foo");
352 assert_eq!(result.file_path, "src/main.py");
353 assert_eq!(result.line, 42);
354 assert_eq!(result.relation.as_deref(), Some("call"));
355 assert_eq!(result.distance, Some(2));
356 }
357
358 #[test]
359 fn phase7_query_helpers_preserve_safe_literals_clamping_and_project_scope() {
360 let project_id = "project\n'one";
361 let symbol_id = "symbol\"\\'two";
362 let expected_project = cypher_string_literal(project_id);
363 let expected_symbol = cypher_string_literal(symbol_id);
364
365 let (callers, caller_params) = find_callers_query(project_id, symbol_id, 250, 0);
366 assert!(callers.contains(CALL_TARGET_FRAGMENT), "{callers}");
367 assert!(callers.contains("SKIP 100 LIMIT 1"), "{callers}");
368 assert_no_numeric_or_list_placeholders(&callers);
369 assert_eq!(caller_params.get("project"), Some(&expected_project));
370 assert_eq!(caller_params.get("id"), Some(&expected_symbol));
371
372 let (usages, usage_params) = find_usages_query(project_id, symbol_id, 250, 250);
373 assert!(usages.contains(CALL_TARGET_FRAGMENT), "{usages}");
374 assert!(usages.contains("SKIP 100 LIMIT 100"), "{usages}");
375 assert_no_numeric_or_list_placeholders(&usages);
376 assert_eq!(usage_params.get("project"), Some(&expected_project));
377 assert_eq!(usage_params.get("id"), Some(&expected_symbol));
378
379 let ids = ["a".to_string(), "b\n\"'\\c".to_string()];
380 let expected_ids = id_list_literal(&ids);
381 let (batch, batch_params) = find_callers_batch_query(project_id, &ids, 250);
382 assert!(
383 batch.contains(&format!("target.id IN [{expected_ids}]")),
384 "{batch}"
385 );
386 assert!(
387 batch.contains("WITH caller, min(r.file) AS file, min(r.line) AS line"),
388 "{batch}"
389 );
390 assert!(batch.contains("ORDER BY caller.id"), "{batch}");
391 assert!(batch.contains("LIMIT 100"), "{batch}");
392 assert_no_numeric_or_list_placeholders(&batch);
393 assert_eq!(batch_params.get("project"), Some(&expected_project));
394
395 let blast = blast_radius_query(99, 250);
396 assert!(blast.contains(CALL_TARGET_FRAGMENT), "{blast}");
397 assert!(blast.contains("[:CALLS*1..5]"), "{blast}");
398 assert!(blast.contains("LIMIT 100"), "{blast}");
399 assert_no_numeric_or_list_placeholders(&blast);
400 }
401}