1use std::collections::HashMap;
8
9use falkordb::{
10 FalkorClientBuilder, FalkorConnectionInfo, FalkorValue, LazyResultSet, QueryResult, SyncGraph,
11};
12use serde_json::{Map, Number, Value};
13
14use crate::config::{Context, FalkorConfig};
15use crate::graph::typed_query;
16use crate::models::GraphResult;
17
18const CALL_TARGET_PREDICATE: &str =
19 "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
20const MAX_GRAPH_LIMIT: usize = 100;
21
22pub type Row = HashMap<String, Value>;
24
25pub struct FalkorClient {
27 graph: SyncGraph,
28}
29
30impl FalkorClient {
31 pub fn from_config(config: &FalkorConfig) -> anyhow::Result<Self> {
32 let password = config.password.as_deref().unwrap_or_default();
33 let url = format!(
34 "falkor://:{}@{}:{}",
35 urlencoding::encode(password),
36 config.host,
37 config.port
38 );
39 let conn_info: FalkorConnectionInfo = url.as_str().try_into()?;
40 let client = FalkorClientBuilder::new()
41 .with_connection_info(conn_info)
42 .build()?;
43 Ok(Self {
44 graph: client.select_graph(&config.graph_name),
45 })
46 }
47
48 pub fn query(
50 &mut self,
51 cypher: &str,
52 params: Option<HashMap<String, String>>,
53 ) -> anyhow::Result<Vec<Row>> {
54 match params {
55 Some(params) => {
56 let result = self.graph.query(cypher).with_params(¶ms).execute()?;
57 Ok(parse_falkor_result(result))
58 }
59 None => {
60 let result = self.graph.query(cypher).execute()?;
61 Ok(parse_falkor_result(result))
62 }
63 }
64 }
65
66 pub fn query_typed(&mut self, query: typed_query::TypedQuery) -> anyhow::Result<Vec<Row>> {
68 let typed_query::TypedQuery { cypher, params } = query;
69 self.query(&cypher, Some(params))
70 }
71}
72
73pub fn cypher_string_literal(s: &str) -> String {
74 crate::graph::typed_query::cypher_string_literal(s)
75}
76
77pub fn id_list_literal(ids: &[String]) -> String {
78 typed_query::id_list_literal(ids)
79}
80
81fn clamp_limit(limit: usize) -> usize {
82 limit.clamp(1, MAX_GRAPH_LIMIT)
83}
84
85pub fn clamp_offset(offset: usize) -> usize {
86 offset.min(MAX_GRAPH_LIMIT)
87}
88
89pub fn count_callers_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
90 (
91 format!(
92 "MATCH (caller:CodeSymbol {{project: $project}})-[:CALLS]->(target {{id: $id, project: $project}}) \
93 WHERE {CALL_TARGET_PREDICATE} \
94 RETURN count(caller) AS cnt"
95 ),
96 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
97 )
98}
99
100pub fn count_usages_query(project_id: &str, symbol_id: &str) -> (String, HashMap<String, String>) {
101 (
102 format!(
103 "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
104 WHERE {CALL_TARGET_PREDICATE} \
105 RETURN count(source) AS cnt"
106 ),
107 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
108 )
109}
110
111pub fn find_callers_query(
112 project_id: &str,
113 symbol_id: &str,
114 offset: usize,
115 limit: usize,
116) -> (String, HashMap<String, String>) {
117 let offset = clamp_offset(offset);
118 let limit = clamp_limit(limit);
119 (
120 format!(
121 "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
122 WHERE {CALL_TARGET_PREDICATE} \
123 RETURN caller.id AS caller_id, caller.name AS caller_name, \
124 r.file AS file, r.line AS line \
125 SKIP {offset} LIMIT {limit}"
126 ),
127 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
128 )
129}
130
131pub fn find_usages_query(
132 project_id: &str,
133 symbol_id: &str,
134 offset: usize,
135 limit: usize,
136) -> (String, HashMap<String, String>) {
137 let offset = clamp_offset(offset);
138 let limit = clamp_limit(limit);
139 (
140 format!(
141 "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
142 WHERE {CALL_TARGET_PREDICATE} \
143 RETURN source.id AS source_id, source.name AS source_name, \
144 'CALLS' AS rel_type, r.file AS file, r.line AS line \
145 SKIP {offset} LIMIT {limit}"
146 ),
147 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
148 )
149}
150
151pub fn find_callers_batch_query(
152 project_id: &str,
153 symbol_ids: &[String],
154 limit: usize,
155) -> (String, HashMap<String, String>) {
156 let limit = clamp_limit(limit);
157 let ids = id_list_literal(symbol_ids);
158 (
159 format!(
160 "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
161 WHERE ({CALL_TARGET_PREDICATE}) AND target.id IN [{ids}] \
162 RETURN caller.id AS caller_id, caller.name AS caller_name, \
163 r.file AS file, r.line AS line \
164 LIMIT {limit}"
165 ),
166 typed_query::string_params(&[("project", project_id)]),
167 )
168}
169
170pub fn find_callees_batch_query(
171 project_id: &str,
172 symbol_ids: &[String],
173 limit: usize,
174) -> (String, HashMap<String, String>) {
175 let limit = clamp_limit(limit);
176 let ids = id_list_literal(symbol_ids);
177 (
178 format!(
179 "MATCH (src:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
180 WHERE src.id IN [{ids}] AND ({CALL_TARGET_PREDICATE}) \
181 RETURN target.id AS callee_id, target.name AS callee_name, \
182 r.file AS file, r.line AS line \
183 LIMIT {limit}"
184 ),
185 typed_query::string_params(&[("project", project_id)]),
186 )
187}
188
189pub fn get_imports_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
190 let limit = clamp_limit(MAX_GRAPH_LIMIT);
191 (
192 format!(
193 "MATCH (f:CodeFile {{path: $path, project: $project}})-[:IMPORTS]->(m:CodeModule) \
194 RETURN m.name AS module_name \
195 LIMIT {limit}"
196 ),
197 typed_query::string_params(&[("project", project_id), ("path", file_path)]),
198 )
199}
200
201pub fn blast_radius_query(depth: usize, limit: usize) -> String {
202 let depth = depth.clamp(1, 5);
203 let limit = limit.clamp(1, MAX_GRAPH_LIMIT);
204 format!(
205 "MATCH (target {{id: $id, project: $project}}) \
206 WHERE {CALL_TARGET_PREDICATE} \
207 MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target) \
208 WITH affected, min(length(path)) AS distance \
209 OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
210 RETURN DISTINCT affected.id AS node_id, \
211 affected.name AS node_name, \
212 affected.kind AS kind, file.path AS file_path, \
213 affected.line_start AS line, \
214 distance, 'call' AS rel_type \
215 ORDER BY distance ASC, affected.name ASC \
216 LIMIT {limit}"
217 )
218}
219
220fn parse_falkor_result(result: QueryResult<LazyResultSet<'_>>) -> Vec<Row> {
221 parse_falkor_records(result.header, result.data)
222}
223
224fn parse_falkor_records<I>(headers: Vec<String>, records: I) -> Vec<Row>
225where
226 I: IntoIterator<Item = Vec<FalkorValue>>,
227{
228 records
229 .into_iter()
230 .map(|record| {
231 let mut row = HashMap::new();
232 for (i, field) in headers.iter().enumerate() {
233 let value = record.get(i).cloned().unwrap_or(FalkorValue::None);
234 row.insert(field.clone(), falkor_value_to_json(value));
235 }
236 row
237 })
238 .collect()
239}
240
241fn falkor_value_to_json(value: FalkorValue) -> Value {
242 match value {
243 FalkorValue::String(value) => Value::String(value),
244 FalkorValue::Bool(value) => Value::Bool(value),
245 FalkorValue::I64(value) => Value::Number(Number::from(value)),
246 FalkorValue::F64(value) => Number::from_f64(value)
247 .map(Value::Number)
248 .unwrap_or(Value::Null),
249 FalkorValue::Array(values) => Value::Array(
250 values
251 .into_iter()
252 .map(falkor_value_to_json)
253 .collect::<Vec<_>>(),
254 ),
255 FalkorValue::Map(values) => Value::Object(
256 values
257 .into_iter()
258 .map(|(key, value)| (key, falkor_value_to_json(value)))
259 .collect::<Map<_, _>>(),
260 ),
261 FalkorValue::None => Value::Null,
262 value => Value::String(format!("{value:?}")),
263 }
264}
265
266pub fn with_falkor<T>(
267 ctx: &Context,
268 default: T,
269 f: impl FnOnce(&mut FalkorClient) -> anyhow::Result<T>,
270) -> anyhow::Result<T> {
271 let Some(config) = &ctx.falkordb else {
272 return Ok(default);
273 };
274
275 let mut client = match FalkorClient::from_config(config) {
276 Ok(client) => client,
277 Err(e) => {
278 if !ctx.quiet {
279 eprintln!("Warning: FalkorDB connection failed: {e}");
280 }
281 return Ok(default);
282 }
283 };
284
285 match f(&mut client) {
286 Ok(value) => Ok(value),
287 Err(e) => {
288 if !ctx.quiet {
289 eprintln!("Warning: FalkorDB query failed: {e}");
290 }
291 Ok(default)
292 }
293 }
294}
295
296pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
298 crate::graph::code_graph::count_callers(ctx, symbol_id)
299}
300
301pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
303 crate::graph::code_graph::count_usages(ctx, symbol_id)
304}
305
306pub fn find_callers(
308 ctx: &Context,
309 symbol_id: &str,
310 offset: usize,
311 limit: usize,
312) -> anyhow::Result<Vec<GraphResult>> {
313 crate::graph::code_graph::find_callers(ctx, symbol_id, offset, limit)
314}
315
316pub fn find_usages(
318 ctx: &Context,
319 symbol_id: &str,
320 offset: usize,
321 limit: usize,
322) -> anyhow::Result<Vec<GraphResult>> {
323 crate::graph::code_graph::find_usages(ctx, symbol_id, offset, limit)
324}
325
326pub fn find_callers_batch(
328 ctx: &Context,
329 symbol_ids: &[String],
330 limit: usize,
331) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
332 let mut grouped = HashMap::new();
333 for symbol_id in symbol_ids {
334 grouped.insert(
335 symbol_id.clone(),
336 crate::graph::code_graph::find_callers(ctx, symbol_id, 0, limit)?,
337 );
338 }
339 Ok(grouped)
340}
341
342pub fn find_callees_batch(
344 ctx: &Context,
345 symbol_ids: &[String],
346 limit: usize,
347) -> anyhow::Result<HashMap<String, Vec<GraphResult>>> {
348 let mut grouped = HashMap::new();
349 for symbol_id in symbol_ids {
350 grouped.insert(
351 symbol_id.clone(),
352 crate::graph::code_graph::find_callees_batch(
353 ctx,
354 std::slice::from_ref(symbol_id),
355 limit,
356 )?,
357 );
358 }
359 Ok(grouped)
360}
361
362pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
364 crate::graph::code_graph::get_imports(ctx, file_path)
365}
366
367pub fn blast_radius(
369 ctx: &Context,
370 symbol_id: &str,
371 depth: usize,
372) -> anyhow::Result<Vec<GraphResult>> {
373 crate::graph::code_graph::blast_radius(ctx, symbol_id, depth)
374}
375
376#[cfg(test)]
377fn row_to_graph_result(row: &Row) -> GraphResult {
378 crate::graph::code_graph::row_to_graph_result(row)
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use falkordb::FalkorValue;
385 use serde_json::json;
386
387 fn assert_no_numeric_or_list_placeholders(query: &str) {
388 assert!(!query.contains("$offset"), "{query}");
389 assert!(!query.contains("$limit"), "{query}");
390 assert!(!query.contains("$ids"), "{query}");
391 }
392
393 #[test]
394 fn cypher_string_literal_escapes_single_quotes_and_backslashes() {
395 assert_eq!(
396 cypher_string_literal("module\\path'symbol"),
397 "'module\\\\path\\'symbol'"
398 );
399 }
400
401 #[test]
402 fn find_callers_query_interpolates_numeric_skip_and_limit() {
403 let (query, params) = find_callers_query("project-1", "symbol-1", 250, 0);
404
405 assert!(query.contains("SKIP 100 LIMIT 1"), "{query}");
406 assert_no_numeric_or_list_placeholders(&query);
407 assert_eq!(
408 params.get("project").map(String::as_str),
409 Some("'project-1'")
410 );
411 assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
412 }
413
414 #[test]
415 fn find_usages_query_clamps_numeric_skip_and_limit() {
416 let (query, params) = find_usages_query("project-1", "symbol-1", 250, 250);
417
418 assert!(query.contains("SKIP 100 LIMIT 100"), "{query}");
419 assert_no_numeric_or_list_placeholders(&query);
420 assert_eq!(
421 params.get("project").map(String::as_str),
422 Some("'project-1'")
423 );
424 assert_eq!(params.get("id").map(String::as_str), Some("'symbol-1'"));
425 }
426
427 #[test]
428 fn batch_query_uses_one_interpolated_in_list() {
429 let (query, params) =
430 find_callers_batch_query("project-1", &["a".to_string(), "b'\\c".to_string()], 250);
431
432 assert_eq!(query.matches(" IN [").count(), 1, "{query}");
433 assert!(query.contains("target.id IN ['a', 'b\\'\\\\c']"), "{query}");
434 assert!(query.contains("LIMIT 100"), "{query}");
435 assert_no_numeric_or_list_placeholders(&query);
436 assert_eq!(
437 params.get("project").map(String::as_str),
438 Some("'project-1'")
439 );
440 }
441
442 #[test]
443 fn blast_radius_query_clamps_depth_and_interpolates_limit() {
444 let query = blast_radius_query(99, 250);
445
446 assert!(query.contains(CALL_TARGET_PREDICATE), "{query}");
447 assert!(query.contains("[:CALLS*1..5]"), "{query}");
448 assert!(query.contains("LIMIT 100"), "{query}");
449 assert_no_numeric_or_list_placeholders(&query);
450 }
451
452 #[test]
453 fn convert_falkor_records_maps_headers_and_row_values() {
454 let headers = vec!["name".to_string(), "age".to_string(), "empty".to_string()];
455 let rows = vec![vec![
456 FalkorValue::String("Alice".to_string()),
457 FalkorValue::I64(30),
458 FalkorValue::None,
459 ]];
460
461 let parsed = parse_falkor_records(headers, rows);
462
463 assert_eq!(parsed.len(), 1);
464 assert_eq!(parsed[0].get("name"), Some(&json!("Alice")));
465 assert_eq!(parsed[0].get("age"), Some(&json!(30)));
466 assert_eq!(parsed[0].get("empty"), Some(&json!(null)));
467 }
468
469 #[test]
470 fn row_to_graph_result_prefers_blast_radius_node_fields() {
471 let row = Row::from([
472 ("node_id".to_string(), json!("sym-1")),
473 ("node_name".to_string(), json!("foo")),
474 ("file_path".to_string(), json!("src/main.py")),
475 ("line".to_string(), json!(42)),
476 ("rel_type".to_string(), json!("call")),
477 ("distance".to_string(), json!(2)),
478 ]);
479
480 let result = row_to_graph_result(&row);
481
482 assert_eq!(result.id, "sym-1");
483 assert_eq!(result.name, "foo");
484 assert_eq!(result.file_path, "src/main.py");
485 assert_eq!(result.line, 42);
486 assert_eq!(result.relation.as_deref(), Some("call"));
487 assert_eq!(result.distance, Some(2));
488 }
489
490 #[test]
491 fn falkor_client_wrapper_shape() {
492 let source = include_str!("falkor.rs");
493 assert!(source.contains("pub struct FalkorClient"));
494 assert!(source.contains("graph: SyncGraph"));
495 assert!(
496 source.contains("pub fn from_config(config: &FalkorConfig) -> anyhow::Result<Self>")
497 );
498 assert!(source.contains("pub fn with_falkor<T>"));
499 assert!(source.contains("FalkorClientBuilder, FalkorConnectionInfo, FalkorValue, LazyResultSet, QueryResult, SyncGraph"));
500 assert!(source.contains("client.select_graph(&config.graph_name)"));
501 }
502
503 #[test]
504 fn phase7_read_helpers_visible() {
505 let source = include_str!("falkor.rs");
506 for symbol in [
507 "pub fn count_callers(",
508 "pub fn count_usages(",
509 "pub fn find_callers(",
510 "pub fn find_usages(",
511 "pub fn find_callers_batch(",
512 "pub fn find_callees_batch(",
513 "pub fn get_imports(",
514 "pub fn blast_radius(",
515 "pub fn count_callers_query(",
516 "pub fn count_usages_query(",
517 "pub fn find_callers_query(",
518 "pub fn find_usages_query(",
519 "pub fn find_callers_batch_query(",
520 "pub fn find_callees_batch_query(",
521 "pub fn get_imports_query(",
522 "fn blast_radius_query(depth: usize, limit: usize)",
523 ] {
524 assert!(source.contains(symbol), "missing {symbol}");
525 }
526 }
527
528 #[test]
529 fn phase7_source_fragments_visible() {
530 let source = include_str!("falkor.rs");
531 for fragment in [
532 "urlencoding::encode(password)",
533 "falkor://:{}@{}:{}",
534 ".with_connection_info(conn_info)",
535 ".with_params(&",
536 "result.header",
537 "FalkorValue::None",
538 "let mut client =",
539 "ctx.falkordb",
540 ] {
541 assert!(source.contains(fragment), "missing {fragment}");
542 }
543 }
544
545 #[test]
546 fn phase7_query_surface_visible() {
547 let source = include_str!("falkor.rs");
548 assert!(source.contains("pub type Row = HashMap<String, Value>"));
549 assert!(source.contains("pub fn query("));
550 assert!(source.contains("cypher: &str"));
551 assert!(source.contains("params: Option<HashMap<String, String>>"));
552 assert!(source.contains("anyhow::Result<Vec<Row>>"));
553 assert!(source.contains("fn parse_falkor_result("));
554 }
555
556 #[test]
557 fn phase7_query_helpers_and_literal_fragments_visible() {
558 let source = include_str!("falkor.rs");
559 for fragment in [
560 "pub fn cypher_string_literal",
561 "pub fn id_list_literal",
562 "pub fn clamp_offset",
563 "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol",
564 "SKIP {offset} LIMIT {limit}",
565 "target.id IN [{ids}]",
566 ] {
567 assert!(source.contains(fragment), "missing {fragment}");
568 }
569
570 let queries = [
571 find_callers_query("project-1", "symbol-1", 5, 10).0,
572 find_usages_query("project-1", "symbol-1", 5, 10).0,
573 find_callers_batch_query("project-1", &["a".to_string()], 10).0,
574 find_callees_batch_query("project-1", &["a".to_string()], 10).0,
575 ];
576 for query in queries {
577 assert_no_numeric_or_list_placeholders(&query);
578 }
579 }
580
581 #[test]
582 fn phase7_cargo_and_lockfile_state() {
583 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
584 let cargo = std::fs::read_to_string(manifest_dir.join("Cargo.toml"))
585 .expect("read gobby-code Cargo.toml");
586 assert!(cargo.contains("name = \"gobby-code\""));
587 assert!(cargo.contains("name = \"gcode\""));
588 assert!(cargo.contains("path = \"src/main.rs\""));
589 assert!(cargo.contains("falkordb = \"0.2\""));
590 assert!(cargo.contains("urlencoding = \"2\""));
591 assert!(cargo.contains("base64"));
592 assert!(cargo.contains("reqwest"));
593
594 let lock = std::fs::read_to_string(manifest_dir.join("../../Cargo.lock"))
595 .expect("read workspace Cargo.lock");
596 assert!(lock.contains("name = \"falkordb\""));
597 assert!(lock.contains("name = \"urlencoding\""));
598 assert!(!lock.contains("name = \"neo4j\""));
599 assert!(!lock.contains("name = \"neo4rs\""));
600 }
601
602 #[test]
603 fn phase7_additional_query_fragments_visible() {
604 let source = include_str!("falkor.rs");
605 for fragment in [
606 "depth.clamp(1, 5)",
607 "limit.clamp(1, MAX_GRAPH_LIMIT)",
608 "offset.min(MAX_GRAPH_LIMIT)",
609 "src.id IN [{ids}]",
610 "LIMIT {limit}",
611 "fn blast_radius_query(depth: usize, limit: usize)",
612 ] {
613 assert!(source.contains(fragment), "missing {fragment}");
614 }
615 }
616
617 #[test]
618 fn read_helpers_delegate_to_code_graph() {
619 let source = include_str!("falkor.rs");
620 for fragment in [
621 "crate::graph::code_graph::count_callers(ctx, symbol_id)",
622 "crate::graph::code_graph::count_usages(ctx, symbol_id)",
623 "crate::graph::code_graph::find_callers(ctx, symbol_id, offset, limit)",
624 "crate::graph::code_graph::find_usages(ctx, symbol_id, offset, limit)",
625 "crate::graph::code_graph::find_callers(",
626 "crate::graph::code_graph::find_callees_batch(",
627 "crate::graph::code_graph::get_imports(ctx, file_path)",
628 "crate::graph::code_graph::blast_radius(ctx, symbol_id, depth)",
629 ] {
630 assert!(
631 source.contains(fragment),
632 "missing delegation fragment {fragment}"
633 );
634 }
635 }
636}