Skip to main content

gobby_code/graph/code_graph/
read.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::config::Context;
4use crate::graph::typed_query;
5use crate::models::GraphResult;
6use gobby_core::falkor::Row;
7
8use super::connection::with_optional_core_graph;
9use super::payload::{
10    GraphBlastRadiusTarget, GraphLink, GraphNode, GraphPayload, add_link_from_row,
11    add_node_from_row, add_prefixed_node_from_row, row_string_owned, row_to_projection_metadata,
12    row_usize,
13};
14
15const CALL_TARGET_PREDICATE: &str =
16    "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
17const NEIGHBOR_PREDICATE: &str =
18    "neighbor:CodeSymbol OR neighbor:UnresolvedCallee OR neighbor:ExternalSymbol";
19const TARGET_TYPE_CASE: &str = "CASE \
20     WHEN target:CodeSymbol THEN coalesce(target.kind, 'function') \
21     WHEN target:ExternalSymbol THEN 'external' \
22     ELSE 'unresolved' \
23     END";
24const NEIGHBOR_TYPE_CASE: &str = "CASE \
25     WHEN neighbor:CodeSymbol THEN coalesce(neighbor.kind, 'function') \
26     WHEN neighbor:ExternalSymbol THEN 'external' \
27     ELSE 'unresolved' \
28     END";
29const NODE_TYPE_CASE: &str = "CASE \
30     WHEN n:CodeFile THEN 'file' \
31     WHEN n:CodeModule THEN 'module' \
32     WHEN n:CodeSymbol THEN coalesce(n.kind, 'function') \
33     WHEN n:ExternalSymbol THEN 'external' \
34     ELSE 'unresolved' \
35     END";
36const LINK_METADATA_RETURN: &str = "r.provenance AS provenance, \
37     r.confidence AS confidence, \
38     r.source_system AS source_system, \
39     r.source_file_path AS metadata_source_file_path, \
40     r.source_line AS source_line, \
41     r.source_symbol_id AS source_symbol_id, \
42     r.matching_method AS matching_method";
43const MAX_GRAPH_LIMIT: usize = 100;
44
45pub(crate) fn row_to_graph_result(row: &Row) -> GraphResult {
46    GraphResult {
47        id: row
48            .get("caller_id")
49            .or_else(|| row.get("callee_id"))
50            .or_else(|| row.get("source_id"))
51            .or_else(|| row.get("node_id"))
52            .or_else(|| row.get("symbol_id"))
53            .or_else(|| row.get("id"))
54            .and_then(|v| v.as_str())
55            .unwrap_or("")
56            .to_string(),
57        name: row
58            .get("caller_name")
59            .or_else(|| row.get("callee_name"))
60            .or_else(|| row.get("source_name"))
61            .or_else(|| row.get("node_name"))
62            .or_else(|| row.get("symbol_name"))
63            .or_else(|| row.get("name"))
64            .or_else(|| row.get("module_name"))
65            .and_then(|v| v.as_str())
66            .unwrap_or("")
67            .to_string(),
68        file_path: row
69            .get("file")
70            .or_else(|| row.get("file_path"))
71            .and_then(|v| v.as_str())
72            .unwrap_or("")
73            .to_string(),
74        line: row
75            .get("line")
76            .and_then(|v| v.as_u64())
77            .and_then(|value| usize::try_from(value).ok())
78            .unwrap_or(0),
79        relation: row
80            .get("relation")
81            .or_else(|| row.get("rel_type"))
82            .and_then(|v| v.as_str())
83            .map(String::from),
84        distance: row
85            .get("distance")
86            .and_then(|v| v.as_u64())
87            .and_then(|d| usize::try_from(d).ok()),
88        metadata: row_to_projection_metadata(row),
89    }
90}
91fn clamp_limit(limit: usize) -> usize {
92    typed_query::clamp_limit(limit, MAX_GRAPH_LIMIT)
93}
94
95fn clamp_offset(offset: usize) -> usize {
96    typed_query::clamp_offset(offset, MAX_GRAPH_LIMIT)
97}
98
99pub(crate) fn count_callers_query(
100    project_id: &str,
101    symbol_id: &str,
102) -> (String, HashMap<String, String>) {
103    (
104        format!(
105            "MATCH (caller:CodeSymbol {{project: $project}})-[:CALLS]->(target {{id: $id, project: $project}}) \
106             WHERE {CALL_TARGET_PREDICATE} \
107             RETURN count(DISTINCT caller) AS cnt"
108        ),
109        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
110    )
111}
112
113pub(crate) fn count_usages_query(
114    project_id: &str,
115    symbol_id: &str,
116) -> (String, HashMap<String, String>) {
117    // Keep this separate from count_callers_query even though both currently
118    // count CALLS edges; callers is the direct-caller API, usages is the wider
119    // command surface that can grow to imports/references.
120    (
121        format!(
122            "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
123             WHERE {CALL_TARGET_PREDICATE} \
124             RETURN count(source) AS cnt"
125        ),
126        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
127    )
128}
129
130pub(crate) fn find_callers_query(
131    project_id: &str,
132    symbol_id: &str,
133    offset: usize,
134    limit: usize,
135) -> (String, HashMap<String, String>) {
136    let offset = clamp_offset(offset);
137    let limit = clamp_limit(limit);
138    (
139        format!(
140            "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
141             WHERE {CALL_TARGET_PREDICATE} \
142             RETURN DISTINCT caller.id AS caller_id, caller.name AS caller_name, \
143                    caller.file_path AS file, caller.line_start AS line \
144             ORDER BY caller.id \
145             SKIP {offset} LIMIT {limit}"
146        ),
147        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
148    )
149}
150
151pub(crate) fn find_usages_query(
152    project_id: &str,
153    symbol_id: &str,
154    offset: usize,
155    limit: usize,
156) -> (String, HashMap<String, String>) {
157    let offset = clamp_offset(offset);
158    let limit = clamp_limit(limit);
159    (
160        format!(
161            "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
162             WHERE {CALL_TARGET_PREDICATE} \
163             RETURN source.id AS source_id, source.name AS source_name, \
164                    'CALLS' AS rel_type, r.file AS file, r.line AS line \
165             ORDER BY source.id, r.line, r.file \
166             SKIP {offset} LIMIT {limit}"
167        ),
168        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
169    )
170}
171
172fn find_caller_ids_query(
173    project_id: &str,
174    symbol_id: &str,
175    limit: usize,
176) -> (String, HashMap<String, String>) {
177    let limit = clamp_limit(limit);
178    (
179        format!(
180            "MATCH (caller:CodeSymbol {{project: $project}})-[:CALLS]->(target {{id: $id, project: $project}}) \
181             WHERE {CALL_TARGET_PREDICATE} \
182             RETURN DISTINCT caller.id AS id \
183             ORDER BY caller.id \
184             LIMIT {limit}"
185        ),
186        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
187    )
188}
189
190fn find_usage_ids_query(
191    project_id: &str,
192    symbol_id: &str,
193    limit: usize,
194) -> (String, HashMap<String, String>) {
195    let limit = clamp_limit(limit);
196    (
197        format!(
198            "MATCH (source:CodeSymbol {{project: $project}})-[:CALLS]->(target {{id: $id, project: $project}}) \
199             WHERE {CALL_TARGET_PREDICATE} \
200             RETURN DISTINCT source.id AS id \
201             ORDER BY source.id \
202             LIMIT {limit}"
203        ),
204        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
205    )
206}
207
208pub(crate) fn find_callers_batch_query(
209    project_id: &str,
210    symbol_ids: &[String],
211    limit: usize,
212) -> (String, HashMap<String, String>) {
213    let limit = clamp_limit(limit);
214    let ids = typed_query::id_list_literal(symbol_ids);
215    (
216        format!(
217            "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
218			 WHERE ({CALL_TARGET_PREDICATE}) AND target.id IN [{ids}] \
219			 WITH caller, min(r.file) AS file, min(r.line) AS line \
220			 RETURN caller.id AS caller_id, caller.name AS caller_name, \
221			        file AS file, line AS line \
222			 ORDER BY caller.id \
223			 LIMIT {limit}"
224        ),
225        typed_query::string_params(&[("project", project_id)]),
226    )
227}
228
229fn find_caller_ids_batch_query(
230    project_id: &str,
231    symbol_ids: &[String],
232    limit: usize,
233) -> (String, HashMap<String, String>) {
234    let limit = clamp_limit(limit);
235    let ids = typed_query::id_list_literal(symbol_ids);
236    (
237        format!(
238            "MATCH (caller:CodeSymbol {{project: $project}})-[:CALLS]->(target {{project: $project}}) \
239             WHERE ({CALL_TARGET_PREDICATE}) AND target.id IN [{ids}] \
240             RETURN DISTINCT caller.id AS id \
241             ORDER BY caller.id \
242             LIMIT {limit}"
243        ),
244        typed_query::string_params(&[("project", project_id)]),
245    )
246}
247
248pub(crate) fn find_callees_batch_query(
249    project_id: &str,
250    symbol_ids: &[String],
251    limit: usize,
252) -> (String, HashMap<String, String>) {
253    let limit = clamp_limit(limit);
254    let ids = typed_query::id_list_literal(symbol_ids);
255    (
256        format!(
257            "MATCH (src:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
258			 WHERE src.id IN [{ids}] AND ({CALL_TARGET_PREDICATE}) \
259			 WITH target, min(r.file) AS file, min(r.line) AS line \
260			 RETURN target.id AS callee_id, target.name AS callee_name, \
261			        file AS file, line AS line \
262			 ORDER BY target.id \
263			 LIMIT {limit}"
264        ),
265        typed_query::string_params(&[("project", project_id)]),
266    )
267}
268
269fn find_callee_ids_batch_query(
270    project_id: &str,
271    symbol_ids: &[String],
272    limit: usize,
273) -> (String, HashMap<String, String>) {
274    let limit = clamp_limit(limit);
275    let ids = typed_query::id_list_literal(symbol_ids);
276    (
277        format!(
278            "MATCH (src:CodeSymbol {{project: $project}})-[:CALLS]->(target {{project: $project}}) \
279             WHERE src.id IN [{ids}] AND ({CALL_TARGET_PREDICATE}) \
280             RETURN DISTINCT target.id AS id \
281             ORDER BY target.id \
282             LIMIT {limit}"
283        ),
284        typed_query::string_params(&[("project", project_id)]),
285    )
286}
287
288pub(crate) fn get_imports_query(
289    project_id: &str,
290    file_path: &str,
291) -> (String, HashMap<String, String>) {
292    (
293        "MATCH (f:CodeFile {path: $path, project: $project})-[:IMPORTS]->(m:CodeModule) \
294         RETURN m.name AS id, m.name AS module_name"
295            .to_string(),
296        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
297    )
298}
299
300pub(crate) fn blast_radius_query(depth: usize, limit: usize) -> String {
301    let depth = depth.clamp(1, 5);
302    let limit = clamp_limit(limit);
303    format!(
304        "MATCH (target {{id: $id, project: $project}}) \
305         WHERE {CALL_TARGET_PREDICATE} \
306         MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target) \
307         WITH affected, min(length(path)) AS distance \
308         OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
309         RETURN DISTINCT affected.id AS node_id, \
310                affected.name AS node_name, \
311                affected.kind AS kind, file.path AS file_path, \
312                affected.line_start AS line, \
313                distance, 'call' AS rel_type \
314         ORDER BY distance ASC, affected.name ASC \
315         LIMIT {limit}"
316    )
317}
318
319fn project_overview_files_query(
320    project_id: &str,
321    limit: usize,
322) -> (String, HashMap<String, String>) {
323    let limit = clamp_limit(limit);
324    (
325        format!(
326            "MATCH (f:CodeFile {{project: $project}}) \
327             OPTIONAL MATCH (f)-[:DEFINES]->(s:CodeSymbol) \
328             WITH f, count(DISTINCT s) AS sym_count \
329             OPTIONAL MATCH (f)-[:IMPORTS]->(m:CodeModule) \
330             WITH f, sym_count, count(m) AS imp_count \
331             RETURN f.path AS id, f.path AS name, 'file' AS type, \
332                    f.path AS file_path, sym_count AS symbol_count \
333             ORDER BY imp_count DESC, sym_count DESC, f.path \
334             LIMIT {limit}"
335        ),
336        typed_query::string_params(&[("project", project_id)]),
337    )
338}
339
340fn project_overview_imports_query(
341    project_id: &str,
342    file_paths: &[String],
343    limit: usize,
344) -> (String, HashMap<String, String>) {
345    let limit = clamp_limit(limit);
346    let file_paths = typed_query::id_list_literal(file_paths);
347    (
348        format!(
349            "MATCH (f:CodeFile {{project: $project}})-[r:IMPORTS]->(m:CodeModule {{project: $project}}) \
350             WHERE f.path IN [{file_paths}] \
351             RETURN f.path AS source, m.name AS target, 'IMPORTS' AS type, {LINK_METADATA_RETURN} \
352             LIMIT {limit}"
353        ),
354        typed_query::string_params(&[("project", project_id)]),
355    )
356}
357
358fn project_overview_defines_query(
359    project_id: &str,
360    file_paths: &[String],
361    limit: usize,
362) -> (String, HashMap<String, String>) {
363    let limit = clamp_limit(limit);
364    let file_paths = typed_query::id_list_literal(file_paths);
365    (
366        format!(
367            "MATCH (f:CodeFile {{project: $project}})-[r:DEFINES]->(s:CodeSymbol {{project: $project}}) \
368             WHERE f.path IN [{file_paths}] \
369             RETURN f.path AS source, s.id AS target, 'DEFINES' AS type, \
370                    s.name AS symbol_name, s.kind AS symbol_kind, \
371                    s.file_path AS symbol_file_path, s.line_start AS line_start, \
372                    {LINK_METADATA_RETURN} \
373             LIMIT {limit}"
374        ),
375        typed_query::string_params(&[("project", project_id)]),
376    )
377}
378
379fn project_overview_calls_query(
380    project_id: &str,
381    file_paths: &[String],
382    limit: usize,
383) -> (String, HashMap<String, String>) {
384    let limit = clamp_limit(limit);
385    let file_paths = typed_query::id_list_literal(file_paths);
386    (
387        format!(
388            "MATCH (f:CodeFile {{project: $project}})-[:DEFINES]->(s:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
389             WHERE f.path IN [{file_paths}] AND ({CALL_TARGET_PREDICATE}) \
390             RETURN s.id AS source, target.id AS target, 'CALLS' AS type, \
391                    target.name AS target_name, {TARGET_TYPE_CASE} AS target_type, \
392                    target.kind AS target_kind, target.file_path AS target_file_path, \
393                    target.line_start AS target_line_start, r.line AS line, \
394                    {LINK_METADATA_RETURN} \
395             LIMIT {limit}"
396        ),
397        typed_query::string_params(&[("project", project_id)]),
398    )
399}
400
401fn file_symbols_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
402    (
403        format!(
404            "MATCH (:CodeFile {{path: $path, project: $project}})-[r:DEFINES]->(s:CodeSymbol {{project: $project}}) \
405             RETURN s.id AS id, s.name AS name, coalesce(s.kind, 'function') AS type, \
406                    s.kind AS kind, s.file_path AS file_path, \
407                    s.line_start AS line_start, s.signature AS signature, \
408                    {LINK_METADATA_RETURN}"
409        ),
410        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
411    )
412}
413
414pub(super) fn file_calls_query(
415    project_id: &str,
416    file_path: &str,
417) -> (String, HashMap<String, String>) {
418    (
419        format!(
420            "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
421             WHERE ({CALL_TARGET_PREDICATE}) \
422               AND (source.file_path = $path OR (target:CodeSymbol AND target.file_path = $path)) \
423             RETURN source.id AS source_id, source.name AS source_name, \
424                    coalesce(source.kind, 'function') AS source_type, \
425                    source.kind AS source_kind, source.file_path AS source_file_path, \
426                    source.line_start AS source_line_start, source.signature AS source_signature, \
427                    target.id AS target_id, target.name AS target_name, \
428                    {TARGET_TYPE_CASE} AS target_type, target.kind AS target_kind, \
429                    target.file_path AS target_file_path, \
430                    target.line_start AS target_line_start, target.signature AS target_signature, \
431                    source.id AS source, target.id AS target, 'CALLS' AS type, r.line AS line, \
432                    {LINK_METADATA_RETURN}"
433        ),
434        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
435    )
436}
437
438fn symbol_neighbors_query(
439    project_id: &str,
440    symbol_id: &str,
441    limit: usize,
442) -> (String, HashMap<String, String>) {
443    let limit = clamp_limit(limit);
444    (
445        format!(
446            "MATCH (center {{id: $id, project: $project}}) \
447             WHERE center:CodeSymbol OR center:UnresolvedCallee OR center:ExternalSymbol \
448             MATCH (center)-[r:CALLS]-(neighbor {{project: $project}}) \
449             WHERE {NEIGHBOR_PREDICATE} \
450             RETURN neighbor.id AS id, neighbor.name AS name, {NEIGHBOR_TYPE_CASE} AS type, \
451                    neighbor.kind AS kind, neighbor.file_path AS file_path, \
452                    neighbor.line_start AS line_start, neighbor.signature AS signature, \
453                    CASE WHEN startNode(r) = center THEN 'outgoing' ELSE 'incoming' END AS direction, \
454                    r.line AS line, {LINK_METADATA_RETURN} \
455             LIMIT {limit}"
456        ),
457        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
458    )
459}
460
461fn blast_radius_center_query(
462    project_id: &str,
463    symbol_id: &str,
464) -> (String, HashMap<String, String>) {
465    (
466        format!(
467            "MATCH (n {{id: $id, project: $project}}) \
468             WHERE n:CodeSymbol OR n:UnresolvedCallee OR n:ExternalSymbol \
469             RETURN n.id AS id, n.name AS name, {NODE_TYPE_CASE} AS type, \
470                    n.kind AS kind, n.file_path AS file_path \
471             LIMIT 1"
472        ),
473        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
474    )
475}
476
477fn blast_radius_file_call_query(
478    project_id: &str,
479    file_path: &str,
480    depth: usize,
481    limit: usize,
482) -> (String, HashMap<String, String>) {
483    let depth = depth.clamp(1, 5);
484    let limit = clamp_limit(limit);
485    (
486        format!(
487            "MATCH (tf:CodeFile {{path: $path, project: $project}})-[:DEFINES]->(target_sym:CodeSymbol {{project: $project}}) \
488             MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target_sym) \
489             WITH affected, min(length(path)) AS distance \
490             OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
491             RETURN DISTINCT affected.id AS node_id, \
492                    affected.name AS node_name, \
493                    affected.kind AS kind, file.path AS file_path, \
494                    affected.line_start AS line, distance, 'call' AS rel_type, \
495                    coalesce(affected.kind, 'function') AS node_type \
496             ORDER BY distance ASC, affected.name ASC \
497             LIMIT {limit}"
498        ),
499        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
500    )
501}
502
503pub(super) fn blast_radius_file_import_query(
504    project_id: &str,
505    file_path: &str,
506    depth: usize,
507    limit: usize,
508) -> (String, HashMap<String, String>) {
509    let depth = depth.clamp(1, 5);
510    let limit = clamp_limit(limit);
511    (
512        format!(
513            "MATCH (tf:CodeFile {{path: $path, project: $project}})-[:IMPORTS]->(m:CodeModule {{project: $project}}) \
514             MATCH path = (importer:CodeFile {{project: $project}})-[:IMPORTS*1..{depth}]-(m) \
515             WHERE importer.path <> $path \
516             WITH importer, min(length(path)) AS distance \
517             RETURN DISTINCT importer.path AS node_id, \
518                    importer.path AS node_name, NULL AS kind, importer.path AS file_path, \
519                    NULL AS line, distance, 'import' AS rel_type, 'file' AS node_type \
520             ORDER BY distance ASC \
521             LIMIT {limit}"
522        ),
523        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
524    )
525}
526
527pub(super) fn dedupe_limited_blast_rows(mut rows: Vec<Row>, limit: usize) -> Vec<Row> {
528    rows.sort_by(|left, right| {
529        row_usize(left, &["distance"])
530            .unwrap_or(usize::MAX)
531            .cmp(&row_usize(right, &["distance"]).unwrap_or(usize::MAX))
532            .then_with(|| {
533                row_string_owned(left, &["node_name"])
534                    .unwrap_or_default()
535                    .cmp(&row_string_owned(right, &["node_name"]).unwrap_or_default())
536            })
537            .then_with(|| {
538                row_string_owned(left, &["node_id"])
539                    .unwrap_or_default()
540                    .cmp(&row_string_owned(right, &["node_id"]).unwrap_or_default())
541            })
542    });
543
544    let mut seen = HashSet::new();
545    rows.retain(|row| {
546        let Some(node_id) = row_string_owned(row, &["node_id"]) else {
547            return false;
548        };
549        seen.insert(node_id)
550    });
551    rows.truncate(clamp_limit(limit));
552    rows
553}
554
555fn count_from_rows(rows: &[Row]) -> usize {
556    rows.first()
557        .and_then(|r| r.get("cnt"))
558        .and_then(|v| {
559            v.as_u64()
560                .or_else(|| v.as_i64().and_then(|value| value.try_into().ok()))
561        })
562        .and_then(|value| usize::try_from(value).ok())
563        .unwrap_or(0)
564}
565
566pub fn project_overview_graph(ctx: &Context, limit: usize) -> anyhow::Result<GraphPayload> {
567    with_optional_core_graph(ctx, GraphPayload::default, |client| {
568        let limit = clamp_limit(limit);
569        let link_limit = clamp_limit(limit.saturating_mul(4));
570        let max_nodes = limit.saturating_mul(8);
571
572        let (query, params) = project_overview_files_query(&ctx.project_id, limit);
573        let file_rows = client.query(&query, Some(params))?;
574        let mut payload = GraphPayload::default();
575        for row in &file_rows {
576            add_node_from_row(&mut payload, row, "file");
577        }
578
579        let file_paths = payload
580            .nodes()
581            .iter()
582            .filter(|node| node.node_type == "file")
583            .map(|node| node.id.clone())
584            .collect::<Vec<_>>();
585        if file_paths.is_empty() {
586            return Ok(payload);
587        }
588
589        let (query, params) =
590            project_overview_imports_query(&ctx.project_id, &file_paths, link_limit);
591        for row in client.query(&query, Some(params))? {
592            add_link_from_row(&mut payload, &row);
593            if let Some(module_id) = row_string_owned(&row, &["target"]) {
594                payload.push_node(GraphNode::new(module_id.clone(), module_id, "module"));
595            }
596            if payload.node_count() >= max_nodes {
597                break;
598            }
599        }
600
601        let (query, params) =
602            project_overview_defines_query(&ctx.project_id, &file_paths, link_limit);
603        for row in client.query(&query, Some(params))? {
604            add_link_from_row(&mut payload, &row);
605            if let Some(symbol_id) = row_string_owned(&row, &["target"]) {
606                let mut node = GraphNode::new(
607                    symbol_id.clone(),
608                    row_string_owned(&row, &["symbol_name"]).unwrap_or(symbol_id),
609                    row_string_owned(&row, &["symbol_kind"])
610                        .unwrap_or_else(|| "function".to_string()),
611                );
612                node.kind = row_string_owned(&row, &["symbol_kind"]);
613                node.file_path = row_string_owned(&row, &["symbol_file_path", "source"]);
614                node.line_start = row_usize(&row, &["line_start"]);
615                payload.push_node(node);
616            }
617            if payload.node_count() >= max_nodes {
618                break;
619            }
620        }
621
622        let (query, params) =
623            project_overview_calls_query(&ctx.project_id, &file_paths, link_limit);
624        for row in client.query(&query, Some(params))? {
625            add_link_from_row(&mut payload, &row);
626            if let Some(target_id) = row_string_owned(&row, &["target"]) {
627                let mut node = GraphNode::new(
628                    target_id.clone(),
629                    row_string_owned(&row, &["target_name"]).unwrap_or(target_id),
630                    row_string_owned(&row, &["target_type"])
631                        .unwrap_or_else(|| "unresolved".to_string()),
632                );
633                node.kind = row_string_owned(&row, &["target_kind"]);
634                node.file_path = row_string_owned(&row, &["target_file_path"]);
635                node.line_start = row_usize(&row, &["target_line_start"]);
636                payload.push_node(node);
637            }
638            if payload.node_count() >= max_nodes {
639                break;
640            }
641        }
642
643        Ok(payload)
644    })
645}
646
647pub fn file_graph(ctx: &Context, file_path: &str) -> anyhow::Result<GraphPayload> {
648    with_optional_core_graph(ctx, GraphPayload::default, |client| {
649        let mut payload = GraphPayload::default();
650        let mut file_node = GraphNode::new(file_path, file_path, "file");
651        file_node.file_path = Some(file_path.to_string());
652        payload.push_node(file_node);
653
654        let (query, params) = file_symbols_query(&ctx.project_id, file_path);
655        for row in client.query(&query, Some(params))? {
656            add_node_from_row(&mut payload, &row, "function");
657            if let Some(symbol_id) = row_string_owned(&row, &["id"]) {
658                let mut link = GraphLink::new(file_path, symbol_id, "DEFINES");
659                link.metadata = row_to_projection_metadata(&row);
660                payload.links.push(link);
661            }
662        }
663
664        let (query, params) = file_calls_query(&ctx.project_id, file_path);
665        for row in client.query(&query, Some(params))? {
666            add_prefixed_node_from_row(&mut payload, &row, "source", "function");
667            add_prefixed_node_from_row(&mut payload, &row, "target", "unresolved");
668            add_link_from_row(&mut payload, &row);
669        }
670
671        Ok(payload)
672    })
673}
674
675pub fn symbol_neighbors(
676    ctx: &Context,
677    symbol_id: &str,
678    limit: usize,
679) -> anyhow::Result<GraphPayload> {
680    with_optional_core_graph(ctx, GraphPayload::default, |client| {
681        let mut payload = GraphPayload::with_center(symbol_id.to_string());
682        let (query, params) = blast_radius_center_query(&ctx.project_id, symbol_id);
683        let center_rows = client.query(&query, Some(params))?;
684        let center_node = center_rows
685            .first()
686            .and_then(|row| GraphNode::from_row(row, "function"))
687            .unwrap_or_else(|| GraphNode::new(symbol_id, symbol_id, "function"));
688        payload.push_node(center_node);
689
690        let (query, params) = symbol_neighbors_query(&ctx.project_id, symbol_id, limit);
691        let rows = client.query(&query, Some(params))?;
692
693        for row in rows {
694            add_node_from_row(&mut payload, &row, "unresolved");
695            let Some(neighbor_id) = row_string_owned(&row, &["id"]) else {
696                continue;
697            };
698            let direction = row_string_owned(&row, &["direction"]).unwrap_or_default();
699            let mut link = if direction == "outgoing" {
700                GraphLink::new(symbol_id, neighbor_id, "CALLS")
701            } else {
702                GraphLink::new(neighbor_id, symbol_id, "CALLS")
703            };
704            link.line = row_usize(&row, &["line"]);
705            link.metadata = row_to_projection_metadata(&row);
706            payload.links.push(link);
707        }
708
709        Ok(payload)
710    })
711}
712
713pub fn blast_radius_graph(
714    ctx: &Context,
715    target: GraphBlastRadiusTarget,
716    depth: usize,
717    limit: usize,
718) -> anyhow::Result<GraphPayload> {
719    with_optional_core_graph(ctx, GraphPayload::default, |client| {
720        let (center_id, mut center_node, rows) = match target {
721            GraphBlastRadiusTarget::SymbolId(symbol_id) => {
722                let (query, params) = blast_radius_center_query(&ctx.project_id, &symbol_id);
723                let center_rows = client.query(&query, Some(params))?;
724                let center_node = center_rows
725                    .first()
726                    .and_then(|row| GraphNode::from_row(row, "function"))
727                    .unwrap_or_else(|| GraphNode::new(&symbol_id, &symbol_id, "function"));
728
729                let query = blast_radius_query(depth, limit);
730                let params =
731                    typed_query::string_params(&[("project", &ctx.project_id), ("id", &symbol_id)]);
732                (symbol_id, center_node, client.query(&query, Some(params))?)
733            }
734            GraphBlastRadiusTarget::FilePath(file_path) => {
735                let mut rows = vec![];
736                let (query, params) =
737                    blast_radius_file_call_query(&ctx.project_id, &file_path, depth, limit);
738                rows.extend(client.query(&query, Some(params))?);
739                let (query, params) =
740                    blast_radius_file_import_query(&ctx.project_id, &file_path, depth, limit);
741                rows.extend(client.query(&query, Some(params))?);
742                let rows = dedupe_limited_blast_rows(rows, limit);
743                let mut center_node = GraphNode::new(&file_path, &file_path, "file");
744                center_node.file_path = Some(file_path.clone());
745                (file_path.clone(), center_node, rows)
746            }
747        };
748
749        center_node.blast_distance = Some(0);
750        let mut payload = GraphPayload::with_center(center_id.clone());
751        payload.push_node(center_node);
752
753        for row in rows {
754            let Some(node_id) = row_string_owned(&row, &["node_id"]) else {
755                continue;
756            };
757            let mut node = GraphNode::new(
758                node_id.clone(),
759                row_string_owned(&row, &["node_name"]).unwrap_or_else(|| node_id.clone()),
760                row_string_owned(&row, &["node_type"]).unwrap_or_else(|| "function".to_string()),
761            );
762            node.kind = row_string_owned(&row, &["kind"]);
763            node.file_path = row_string_owned(&row, &["file_path"]);
764            node.line_start = row_usize(&row, &["line"]);
765            node.blast_distance = row_usize(&row, &["distance"]);
766            payload.push_node(node);
767
768            let relation =
769                row_string_owned(&row, &["rel_type"]).unwrap_or_else(|| "call".to_string());
770            let mut link = GraphLink::new(
771                node_id,
772                &center_id,
773                if relation == "call" {
774                    "CALLS"
775                } else {
776                    "IMPORTS"
777                },
778            );
779            link.distance = row_usize(&row, &["distance"]);
780            link.metadata = row_to_projection_metadata(&row);
781            payload.links.push(link);
782        }
783
784        Ok(payload)
785    })
786}
787
788pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
789    with_optional_core_graph(
790        ctx,
791        || 0,
792        |client| {
793            let (query, params) = count_callers_query(&ctx.project_id, symbol_id);
794            let rows = client.query(&query, Some(params))?;
795            Ok(count_from_rows(&rows))
796        },
797    )
798}
799
800pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
801    with_optional_core_graph(
802        ctx,
803        || 0,
804        |client| {
805            let (query, params) = count_usages_query(&ctx.project_id, symbol_id);
806            let rows = client.query(&query, Some(params))?;
807            Ok(count_from_rows(&rows))
808        },
809    )
810}
811
812pub fn find_callers(
813    ctx: &Context,
814    symbol_id: &str,
815    offset: usize,
816    limit: usize,
817) -> anyhow::Result<Vec<GraphResult>> {
818    with_optional_core_graph(ctx, Vec::new, |client| {
819        let (query, params) = find_callers_query(&ctx.project_id, symbol_id, offset, limit);
820        let rows = client.query(&query, Some(params))?;
821        Ok(rows.iter().map(row_to_graph_result).collect())
822    })
823}
824
825pub fn find_usages(
826    ctx: &Context,
827    symbol_id: &str,
828    offset: usize,
829    limit: usize,
830) -> anyhow::Result<Vec<GraphResult>> {
831    with_optional_core_graph(ctx, Vec::new, |client| {
832        let (query, params) = find_usages_query(&ctx.project_id, symbol_id, offset, limit);
833        let rows = client.query(&query, Some(params))?;
834        Ok(rows.iter().map(row_to_graph_result).collect())
835    })
836}
837
838pub fn find_caller_ids(
839    ctx: &Context,
840    symbol_id: &str,
841    limit: usize,
842) -> anyhow::Result<Vec<String>> {
843    with_optional_core_graph(ctx, Vec::new, |client| {
844        let (query, params) = find_caller_ids_query(&ctx.project_id, symbol_id, limit);
845        let rows = client.query(&query, Some(params))?;
846        Ok(rows
847            .iter()
848            .filter_map(|row| row_string_owned(row, &["id"]))
849            .collect())
850    })
851}
852
853pub fn find_usage_ids(ctx: &Context, symbol_id: &str, limit: usize) -> anyhow::Result<Vec<String>> {
854    with_optional_core_graph(ctx, Vec::new, |client| {
855        let (query, params) = find_usage_ids_query(&ctx.project_id, symbol_id, limit);
856        let rows = client.query(&query, Some(params))?;
857        Ok(rows
858            .iter()
859            .filter_map(|row| row_string_owned(row, &["id"]))
860            .collect())
861    })
862}
863
864pub fn find_callers_batch(
865    ctx: &Context,
866    symbol_ids: &[String],
867    limit: usize,
868) -> anyhow::Result<Vec<GraphResult>> {
869    if symbol_ids.is_empty() {
870        return Ok(vec![]);
871    }
872    with_optional_core_graph(ctx, Vec::new, |client| {
873        let (query, params) = find_callers_batch_query(&ctx.project_id, symbol_ids, limit);
874        let rows = client.query(&query, Some(params))?;
875        Ok(rows.iter().map(row_to_graph_result).collect())
876    })
877}
878
879pub fn find_caller_ids_batch(
880    ctx: &Context,
881    symbol_ids: &[String],
882    limit: usize,
883) -> anyhow::Result<Vec<String>> {
884    if symbol_ids.is_empty() {
885        return Ok(vec![]);
886    }
887    with_optional_core_graph(ctx, Vec::new, |client| {
888        let (query, params) = find_caller_ids_batch_query(&ctx.project_id, symbol_ids, limit);
889        let rows = client.query(&query, Some(params))?;
890        Ok(rows
891            .iter()
892            .filter_map(|row| row_string_owned(row, &["id"]))
893            .collect())
894    })
895}
896
897pub fn find_callees_batch(
898    ctx: &Context,
899    symbol_ids: &[String],
900    limit: usize,
901) -> anyhow::Result<Vec<GraphResult>> {
902    if symbol_ids.is_empty() {
903        return Ok(vec![]);
904    }
905    with_optional_core_graph(ctx, Vec::new, |client| {
906        let (query, params) = find_callees_batch_query(&ctx.project_id, symbol_ids, limit);
907        let rows = client.query(&query, Some(params))?;
908        Ok(rows.iter().map(row_to_graph_result).collect())
909    })
910}
911
912pub fn find_callee_ids_batch(
913    ctx: &Context,
914    symbol_ids: &[String],
915    limit: usize,
916) -> anyhow::Result<Vec<String>> {
917    if symbol_ids.is_empty() {
918        return Ok(vec![]);
919    }
920    with_optional_core_graph(ctx, Vec::new, |client| {
921        let (query, params) = find_callee_ids_batch_query(&ctx.project_id, symbol_ids, limit);
922        let rows = client.query(&query, Some(params))?;
923        Ok(rows
924            .iter()
925            .filter_map(|row| row_string_owned(row, &["id"]))
926            .collect())
927    })
928}
929
930pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
931    with_optional_core_graph(ctx, Vec::new, |client| {
932        let (query, params) = get_imports_query(&ctx.project_id, file_path);
933        let rows = client.query(&query, Some(params))?;
934        Ok(rows.iter().map(row_to_graph_result).collect())
935    })
936}
937
938pub fn blast_radius(
939    ctx: &Context,
940    symbol_id: &str,
941    depth: usize,
942) -> anyhow::Result<Vec<GraphResult>> {
943    with_optional_core_graph(ctx, Vec::new, |client| {
944        let query = blast_radius_query(depth, MAX_GRAPH_LIMIT);
945        let params = typed_query::string_params(&[("project", &ctx.project_id), ("id", symbol_id)]);
946        let rows = client.query(&query, Some(params))?;
947        Ok(rows.iter().map(row_to_graph_result).collect())
948    })
949}