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 (
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 ¢er_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}