Skip to main content

gobby_code/
falkor.rs

1//! Compatibility facade for FalkorDB graph queries.
2//!
3//! The reusable projection/query implementation lives under
4//! `crate::graph::code_graph`; this module keeps the Phase 7 public surface
5//! available for downstream callers that still import `crate::falkor`.
6
7use 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
22/// Row from a FalkorDB query response.
23pub type Row = HashMap<String, Value>;
24
25/// Blocking FalkorDB graph client.
26pub 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    /// Execute a Cypher query and return parsed rows.
49    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(&params).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    /// Execute a typed query after its parameters have been rendered safely.
67    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
296/// Count callers of a symbol.
297pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
298    crate::graph::code_graph::count_callers(ctx, symbol_id)
299}
300
301/// Count incoming call usages of a symbol.
302pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
303    crate::graph::code_graph::count_usages(ctx, symbol_id)
304}
305
306/// Find symbols that call the given symbol id.
307pub 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
316/// Find incoming CALLS usages for a canonical, unresolved, or external target.
317pub 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
326/// Find symbols that call any of the given target ids.
327pub 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
342/// Find call targets reached by any of the given source ids.
343pub 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
362/// Get import graph for a file.
363pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
364    crate::graph::code_graph::get_imports(ctx, file_path)
365}
366
367/// Find transitive blast radius of changing a symbol.
368pub 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}