1use std::collections::HashMap;
2use std::fmt;
3
4use anyhow::Context as _;
5use reqwest::StatusCode;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::config::Context;
10use crate::graph::typed_query::{self, TypedQuery, TypedValue};
11use crate::models::{
12 CallRelation, CallTargetKind, GraphResult, ImportRelation, ProjectionMetadata,
13 ProjectionProvenance, Symbol, make_external_symbol_id, make_unresolved_callee_id,
14};
15use gobby_core::degradation::ServiceState;
16use gobby_core::falkor::{GraphClient, Row};
17
18const CALL_TARGET_PREDICATE: &str =
19 "target:CodeSymbol OR target:UnresolvedCallee OR target:ExternalSymbol";
20const NEIGHBOR_PREDICATE: &str =
21 "neighbor:CodeSymbol OR neighbor:UnresolvedCallee OR neighbor:ExternalSymbol";
22const PROJECT_NODE_PREDICATE: &str =
23 "n:CodeFile OR n:CodeSymbol OR n:CodeModule OR n:UnresolvedCallee OR n:ExternalSymbol";
24const TARGET_TYPE_CASE: &str = "CASE \
25 WHEN target:CodeSymbol THEN coalesce(target.kind, 'function') \
26 WHEN target:ExternalSymbol THEN 'external' \
27 ELSE 'unresolved' \
28 END";
29const NEIGHBOR_TYPE_CASE: &str = "CASE \
30 WHEN neighbor:CodeSymbol THEN coalesce(neighbor.kind, 'function') \
31 WHEN neighbor:ExternalSymbol THEN 'external' \
32 ELSE 'unresolved' \
33 END";
34const NODE_TYPE_CASE: &str = "CASE \
35 WHEN n:CodeFile THEN 'file' \
36 WHEN n:CodeModule THEN 'module' \
37 WHEN n:CodeSymbol THEN coalesce(n.kind, 'function') \
38 WHEN n:ExternalSymbol THEN 'external' \
39 ELSE 'unresolved' \
40 END";
41const LINK_METADATA_RETURN: &str = "r.provenance AS provenance, \
42 r.confidence AS confidence, \
43 r.source_system AS source_system, \
44 r.source_file_path AS metadata_source_file_path, \
45 r.source_line AS source_line, \
46 r.source_symbol_id AS source_symbol_id, \
47 r.matching_method AS matching_method";
48const MAX_GRAPH_LIMIT: usize = 100;
49const EXTRACTED_PROVENANCE: &str = "EXTRACTED";
50const SOURCE_SYSTEM_GCODE: &str = crate::models::SOURCE_SYSTEM_GCODE;
51
52pub struct CodeGraph<'a> {
53 project_id: &'a str,
54 client: &'a mut GraphClient,
55}
56
57impl<'a> CodeGraph<'a> {
58 pub fn new(project_id: &'a str, client: &'a mut GraphClient) -> Self {
59 Self { project_id, client }
60 }
61
62 pub fn sync_file(
63 &mut self,
64 file_path: &str,
65 imports: &[ImportRelation],
66 definitions: &[Symbol],
67 calls: &[CallRelation],
68 ) -> anyhow::Result<usize> {
69 self.ensure_file_node(file_path, definitions.len())?;
70 let current_symbol_ids = definitions
71 .iter()
72 .map(|symbol| symbol.id.clone())
73 .collect::<Vec<_>>();
74 self.delete_file_graph(file_path, ¤t_symbol_ids)?;
75
76 let mut relationship_count = 0;
77 relationship_count += self.add_imports(file_path, imports)?;
78 relationship_count += self.add_definitions(file_path, definitions)?;
79 relationship_count += self.add_calls(file_path, calls)?;
80 self.cleanup_orphans()?;
81 Ok(relationship_count)
82 }
83
84 pub fn ensure_file_node(&mut self, file_path: &str, symbol_count: usize) -> anyhow::Result<()> {
85 execute_write_query(
86 self.client,
87 ensure_file_node_query(self.project_id, file_path, symbol_count)?,
88 )
89 }
90
91 pub fn add_imports(
92 &mut self,
93 file_path: &str,
94 imports: &[ImportRelation],
95 ) -> anyhow::Result<usize> {
96 let mut written = 0;
97 for import in imports {
98 if import.module_name.is_empty() {
99 continue;
100 }
101 let source_file = if import.file_path.is_empty() {
102 file_path
103 } else {
104 &import.file_path
105 };
106 execute_write_query(
107 self.client,
108 add_import_query(self.project_id, source_file, &import.module_name)?,
109 )?;
110 written += 1;
111 }
112 Ok(written)
113 }
114
115 pub fn add_definitions(
116 &mut self,
117 file_path: &str,
118 definitions: &[Symbol],
119 ) -> anyhow::Result<usize> {
120 let mut written = 0;
121 for symbol in definitions {
122 if symbol.id.is_empty() || symbol.name.is_empty() {
123 continue;
124 }
125 execute_write_query(
126 self.client,
127 add_definition_query(self.project_id, file_path, symbol)?,
128 )?;
129 written += 1;
130 }
131 Ok(written)
132 }
133
134 pub fn add_calls(&mut self, file_path: &str, calls: &[CallRelation]) -> anyhow::Result<usize> {
135 let mut written = 0;
136 for call in calls {
137 if let Some(query) = add_call_query(self.project_id, file_path, call)? {
138 execute_write_query(self.client, query)?;
139 written += 1;
140 }
141 }
142 Ok(written)
143 }
144
145 pub fn delete_file_graph(
146 &mut self,
147 file_path: &str,
148 current_symbol_ids: &[String],
149 ) -> anyhow::Result<()> {
150 for query in delete_file_graph_queries(self.project_id, file_path, current_symbol_ids)? {
151 execute_write_query(self.client, query)?;
152 }
153 Ok(())
154 }
155
156 pub fn delete_file_node(&mut self, file_path: &str) -> anyhow::Result<()> {
157 execute_write_query(
158 self.client,
159 delete_file_node_query(self.project_id, file_path)?,
160 )
161 }
162
163 pub fn delete_file_projection(&mut self, file_path: &str) -> anyhow::Result<()> {
164 self.delete_file_graph(file_path, &[])?;
165 self.delete_file_node(file_path)?;
166 self.cleanup_orphans()
167 }
168
169 pub fn cleanup_orphans(&mut self) -> anyhow::Result<()> {
170 for query in cleanup_orphans_queries(self.project_id)? {
171 execute_write_query(self.client, query)?;
172 }
173 Ok(())
174 }
175
176 pub fn clear_project(&mut self) -> anyhow::Result<()> {
177 execute_write_query(self.client, clear_project_query(self.project_id)?)
178 }
179}
180
181pub fn sync_file_graph(
182 ctx: &Context,
183 file_path: &str,
184 imports: &[ImportRelation],
185 definitions: &[Symbol],
186 calls: &[CallRelation],
187) -> anyhow::Result<usize> {
188 with_required_core_graph(ctx, |client| {
189 CodeGraph::new(&ctx.project_id, client).sync_file(file_path, imports, definitions, calls)
190 })
191}
192
193pub fn delete_file_graph(
194 ctx: &Context,
195 file_path: &str,
196 current_symbol_ids: &[String],
197) -> anyhow::Result<()> {
198 with_required_core_graph(ctx, |client| {
199 CodeGraph::new(&ctx.project_id, client).delete_file_graph(file_path, current_symbol_ids)
200 })
201}
202
203pub fn delete_file_projection(ctx: &Context, file_path: &str) -> anyhow::Result<()> {
204 with_required_core_graph(ctx, |client| {
205 CodeGraph::new(&ctx.project_id, client).delete_file_projection(file_path)
206 })
207}
208
209pub fn cleanup_orphans(ctx: &Context) -> anyhow::Result<()> {
210 with_required_core_graph(ctx, |client| {
211 CodeGraph::new(&ctx.project_id, client).cleanup_orphans()
212 })
213}
214
215pub fn clear_project(ctx: &Context) -> anyhow::Result<()> {
216 with_required_core_graph(ctx, |client| {
217 CodeGraph::new(&ctx.project_id, client).clear_project()
218 })
219}
220
221pub fn clear_all_code_index(config: &crate::config::FalkorConfig) -> anyhow::Result<()> {
222 let connection_config = config.connection_config();
223 match gobby_core::falkor::with_graph(
224 Some(&connection_config),
225 &config.graph_name,
226 None,
227 |client| execute_write_query(client, clear_all_code_index_query()?).map(Some),
228 ) {
229 Ok((Some(()), ServiceState::Available)) => Ok(()),
230 Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
231 Ok((_, ServiceState::Unreachable { message })) => {
232 Err(GraphReadError::Unreachable { message }.into())
233 }
234 Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
235 message: "graph clear returned no value".to_string(),
236 }
237 .into()),
238 Err(error) => Err(GraphReadError::QueryFailed {
239 message: error.to_string(),
240 }
241 .into()),
242 }
243}
244
245fn execute_write_query(client: &mut GraphClient, query: TypedQuery) -> anyhow::Result<()> {
246 let TypedQuery { cypher, params } = query;
247 client.query(&cypher, Some(params))?;
248 Ok(())
249}
250
251fn typed_query<I, K>(cypher: impl Into<String>, params: I) -> anyhow::Result<TypedQuery>
252where
253 I: IntoIterator<Item = (K, TypedValue)>,
254 K: Into<String>,
255{
256 Ok(TypedQuery::with_params(cypher, params)?)
257}
258
259fn usize_value(value: usize) -> TypedValue {
260 TypedValue::Integer(value.min(i64::MAX as usize) as i64)
261}
262
263fn optional_string_value(value: Option<&str>) -> TypedValue {
264 value
265 .filter(|value| !value.is_empty())
266 .map(|value| TypedValue::String(value.to_string()))
267 .unwrap_or(TypedValue::Null)
268}
269
270fn base_metadata_params(file_path: &str) -> Vec<(&'static str, TypedValue)> {
271 vec![
272 (
273 "provenance",
274 TypedValue::String(EXTRACTED_PROVENANCE.to_string()),
275 ),
276 ("confidence", TypedValue::Float(1.0)),
277 (
278 "source_system",
279 TypedValue::String(SOURCE_SYSTEM_GCODE.to_string()),
280 ),
281 (
282 "source_file_path",
283 TypedValue::String(file_path.to_string()),
284 ),
285 ]
286}
287
288fn extracted_edge_params(
289 file_path: &str,
290 source_line: usize,
291 source_symbol_id: Option<&str>,
292) -> Vec<(&'static str, TypedValue)> {
293 let mut params = base_metadata_params(file_path);
294 params.push(("source_line", usize_value(source_line)));
295 params.push(("source_symbol_id", optional_string_value(source_symbol_id)));
296 params
297}
298
299pub(crate) fn ensure_file_node_query(
300 project_id: &str,
301 file_path: &str,
302 symbol_count: usize,
303) -> anyhow::Result<TypedQuery> {
304 typed_query(
305 "MERGE (f:CodeFile {path: $file_path, project: $project})
306 SET f.updated_at = timestamp(), f.symbol_count = $symbol_count",
307 [
308 ("project", TypedValue::String(project_id.to_string())),
309 ("file_path", TypedValue::String(file_path.to_string())),
310 ("symbol_count", usize_value(symbol_count)),
311 ],
312 )
313}
314
315pub(crate) fn add_import_query(
316 project_id: &str,
317 source_file: &str,
318 target_module: &str,
319) -> anyhow::Result<TypedQuery> {
320 let mut params = vec![
321 ("project", TypedValue::String(project_id.to_string())),
322 ("source_file", TypedValue::String(source_file.to_string())),
323 (
324 "target_module",
325 TypedValue::String(target_module.to_string()),
326 ),
327 ];
328 params.extend(base_metadata_params(source_file));
329 typed_query(
330 "MERGE (f:CodeFile {path: $source_file, project: $project})
331 MERGE (m:CodeModule {name: $target_module, project: $project})
332 MERGE (f)-[r:IMPORTS]->(m)
333 SET r.provenance = $provenance,
334 r.confidence = $confidence,
335 r.source_system = $source_system,
336 r.source_file_path = $source_file_path",
337 params,
338 )
339}
340
341pub(crate) fn add_definition_query(
342 project_id: &str,
343 file_path: &str,
344 symbol: &Symbol,
345) -> anyhow::Result<TypedQuery> {
346 let mut params = vec![
347 ("project", TypedValue::String(project_id.to_string())),
348 ("file_path", TypedValue::String(file_path.to_string())),
349 ("symbol_id", TypedValue::String(symbol.id.clone())),
350 ("name", TypedValue::String(symbol.name.clone())),
351 (
352 "qualified_name",
353 TypedValue::String(symbol.qualified_name.clone()),
354 ),
355 ("kind", TypedValue::String(symbol.kind.clone())),
356 ("language", TypedValue::String(symbol.language.clone())),
357 ("line_start", usize_value(symbol.line_start)),
358 ("line_end", usize_value(symbol.line_end)),
359 ];
360 params.extend(extracted_edge_params(
361 file_path,
362 symbol.line_start,
363 Some(&symbol.id),
364 ));
365 typed_query(
366 "MERGE (f:CodeFile {path: $file_path, project: $project})
367 MERGE (s:CodeSymbol {id: $symbol_id, project: $project})
368 SET s.name = $name,
369 s.qualified_name = $qualified_name,
370 s.kind = $kind,
371 s.language = $language,
372 s.file_path = $file_path,
373 s.line_start = $line_start,
374 s.line_end = $line_end,
375 s.updated_at = timestamp()
376 MERGE (f)-[r:DEFINES]->(s)
377 SET r.provenance = $provenance,
378 r.confidence = $confidence,
379 r.source_system = $source_system,
380 r.source_file_path = $source_file_path,
381 r.source_line = $source_line,
382 r.source_symbol_id = $source_symbol_id",
383 params,
384 )
385}
386
387enum GraphCallTarget {
388 Symbol { id: String },
389 External { id: String, module: String },
390 Unresolved { id: String },
391}
392
393impl GraphCallTarget {
394 fn from_call(project_id: &str, call: &CallRelation) -> Option<Self> {
395 if let Some(id) = call.callee_symbol_id.as_deref().filter(|id| !id.is_empty()) {
396 return Some(Self::Symbol { id: id.to_string() });
397 }
398 if call.callee_name.is_empty() {
399 return None;
400 }
401 if call.callee_target_kind == CallTargetKind::External {
402 let module = call.callee_external_module.clone().unwrap_or_default();
403 return Some(Self::External {
404 id: make_external_symbol_id(project_id, &call.callee_name, Some(&module)),
405 module,
406 });
407 }
408 Some(Self::Unresolved {
409 id: make_unresolved_callee_id(project_id, &call.callee_name),
410 })
411 }
412}
413
414pub fn call_target_id(project_id: &str, call: &CallRelation) -> Option<String> {
415 match GraphCallTarget::from_call(project_id, call)? {
416 GraphCallTarget::Symbol { id }
417 | GraphCallTarget::External { id, .. }
418 | GraphCallTarget::Unresolved { id } => Some(id),
419 }
420}
421
422pub(crate) fn add_call_query(
423 project_id: &str,
424 default_file_path: &str,
425 call: &CallRelation,
426) -> anyhow::Result<Option<TypedQuery>> {
427 if call.caller_symbol_id.is_empty() {
428 return Ok(None);
429 }
430 let Some(target) = GraphCallTarget::from_call(project_id, call) else {
431 return Ok(None);
432 };
433 let file_path = if call.file_path.is_empty() {
434 default_file_path
435 } else {
436 &call.file_path
437 };
438 let target_id = match &target {
439 GraphCallTarget::Symbol { id }
440 | GraphCallTarget::External { id, .. }
441 | GraphCallTarget::Unresolved { id } => id,
442 };
443 let mut params = vec![
444 ("project", TypedValue::String(project_id.to_string())),
445 (
446 "caller_id",
447 TypedValue::String(call.caller_symbol_id.clone()),
448 ),
449 ("target_id", TypedValue::String(target_id.clone())),
450 ("callee_name", TypedValue::String(call.callee_name.clone())),
451 ("file_path", TypedValue::String(file_path.to_string())),
452 ("line", usize_value(call.line)),
453 ];
454 params.extend(extracted_edge_params(
455 file_path,
456 call.line,
457 Some(&call.caller_symbol_id),
458 ));
459
460 let cypher = match target {
461 GraphCallTarget::Symbol { .. } => {
462 "MERGE (caller:CodeSymbol {id: $caller_id, project: $project})
463 MERGE (callee:CodeSymbol {id: $target_id, project: $project})
464 ON CREATE SET callee.name = $callee_name, callee.updated_at = timestamp()
465 MERGE (caller)-[r:CALLS {file: $file_path, line: $line}]->(callee)
466 SET r.provenance = $provenance,
467 r.confidence = $confidence,
468 r.source_system = $source_system,
469 r.source_file_path = $source_file_path,
470 r.source_line = $source_line,
471 r.source_symbol_id = $source_symbol_id"
472 .to_string()
473 }
474 GraphCallTarget::External { module, .. } => {
475 params.push(("callee_module", TypedValue::String(module)));
476 "MERGE (caller:CodeSymbol {id: $caller_id, project: $project})
477 MERGE (callee:ExternalSymbol {id: $target_id, project: $project})
478 SET callee.name = $callee_name,
479 callee.external_module = $callee_module,
480 callee.module = $callee_module,
481 callee.updated_at = timestamp()
482 MERGE (caller)-[r:CALLS {file: $file_path, line: $line}]->(callee)
483 SET r.provenance = $provenance,
484 r.confidence = $confidence,
485 r.source_system = $source_system,
486 r.source_file_path = $source_file_path,
487 r.source_line = $source_line,
488 r.source_symbol_id = $source_symbol_id"
489 .to_string()
490 }
491 GraphCallTarget::Unresolved { .. } => {
492 "MERGE (caller:CodeSymbol {id: $caller_id, project: $project})
493 MERGE (callee:UnresolvedCallee {id: $target_id, project: $project})
494 SET callee.name = $callee_name,
495 callee.updated_at = timestamp()
496 MERGE (caller)-[r:CALLS {file: $file_path, line: $line}]->(callee)
497 SET r.provenance = $provenance,
498 r.confidence = $confidence,
499 r.source_system = $source_system,
500 r.source_file_path = $source_file_path,
501 r.source_line = $source_line,
502 r.source_symbol_id = $source_symbol_id"
503 .to_string()
504 }
505 };
506
507 Ok(Some(typed_query(cypher, params)?))
508}
509
510pub(crate) fn delete_file_graph_queries(
511 project_id: &str,
512 file_path: &str,
513 current_symbol_ids: &[String],
514) -> anyhow::Result<Vec<TypedQuery>> {
515 let base_params = || {
516 [
517 ("project", TypedValue::String(project_id.to_string())),
518 ("file_path", TypedValue::String(file_path.to_string())),
519 ]
520 };
521 let mut queries = vec![
522 typed_query(
523 "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:IMPORTS]->(:CodeModule)
524 DELETE r",
525 base_params(),
526 )?,
527 typed_query(
528 "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:DEFINES]->(:CodeSymbol)
529 DELETE r",
530 base_params(),
531 )?,
532 typed_query(
533 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})-[r:CALLS]->()
534 DELETE r",
535 base_params(),
536 )?,
537 ];
538
539 if current_symbol_ids.is_empty() {
540 queries.push(typed_query(
541 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
542 DETACH DELETE s",
543 base_params(),
544 )?);
545 } else {
546 let mut params = vec![
547 ("project", TypedValue::String(project_id.to_string())),
548 ("file_path", TypedValue::String(file_path.to_string())),
549 (
550 "symbol_ids",
551 TypedValue::List(
552 current_symbol_ids
553 .iter()
554 .map(|id| TypedValue::String(id.clone()))
555 .collect(),
556 ),
557 ),
558 ];
559 queries.push(typed_query(
560 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
561 WHERE NOT s.id IN $symbol_ids
562 DETACH DELETE s",
563 params.drain(..),
564 )?);
565 }
566
567 Ok(queries)
568}
569
570pub(crate) fn delete_file_node_query(
571 project_id: &str,
572 file_path: &str,
573) -> anyhow::Result<TypedQuery> {
574 typed_query(
575 "MATCH (f:CodeFile {path: $file_path, project: $project})
576 DETACH DELETE f",
577 [
578 ("project", TypedValue::String(project_id.to_string())),
579 ("file_path", TypedValue::String(file_path.to_string())),
580 ],
581 )
582}
583
584pub(crate) fn cleanup_orphans_queries(project_id: &str) -> anyhow::Result<Vec<TypedQuery>> {
585 let project_param = || [("project", TypedValue::String(project_id.to_string()))];
586 Ok(vec![
587 typed_query(
588 "MATCH (m:CodeModule {project: $project})
589 WHERE NOT (m)<-[:IMPORTS]-()
590 DETACH DELETE m",
591 project_param(),
592 )?,
593 typed_query(
594 "MATCH (n {project: $project})
595 WHERE (n:UnresolvedCallee OR n:ExternalSymbol)
596 AND NOT ()-[:CALLS]->(n)
597 DETACH DELETE n",
598 project_param(),
599 )?,
600 typed_query(
601 "MATCH (s:CodeSymbol {project: $project})
602 WHERE s.file_path IS NULL
603 AND NOT ()-[:DEFINES]->(s)
604 AND NOT ()-[:CALLS]->(s)
605 AND NOT (s)-[:CALLS]->()
606 DETACH DELETE s",
607 project_param(),
608 )?,
609 ])
610}
611
612pub(crate) fn clear_project_query(project_id: &str) -> anyhow::Result<TypedQuery> {
613 typed_query(
614 format!(
615 "MATCH (n {{project: $project}})
616 WHERE {PROJECT_NODE_PREDICATE}
617 DETACH DELETE n"
618 ),
619 [("project", TypedValue::String(project_id.to_string()))],
620 )
621}
622
623pub(crate) fn clear_all_code_index_query() -> anyhow::Result<TypedQuery> {
624 typed_query(
625 format!(
626 "MATCH (n)
627 WHERE {PROJECT_NODE_PREDICATE}
628 DETACH DELETE n"
629 ),
630 Vec::<(&str, TypedValue)>::new(),
631 )
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
635#[serde(rename_all = "snake_case")]
636pub enum GraphLifecycleAction {
637 Clear,
638 Rebuild,
639}
640
641impl GraphLifecycleAction {
642 pub fn cli_command(self) -> &'static str {
643 match self {
644 Self::Clear => "gcode graph clear",
645 Self::Rebuild => "gcode graph rebuild",
646 }
647 }
648
649 pub fn endpoint_path(self) -> &'static str {
650 match self {
651 Self::Clear => "/api/code-index/graph/clear",
652 Self::Rebuild => "/api/code-index/graph/rebuild",
653 }
654 }
655
656 pub fn success_prefix(self) -> &'static str {
657 match self {
658 Self::Clear => "Cleared code-index graph",
659 Self::Rebuild => "Rebuilt code-index graph",
660 }
661 }
662}
663
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
665pub struct GraphLifecycleRequest {
666 pub project_id: String,
667 pub daemon_url: Option<String>,
668}
669
670impl GraphLifecycleRequest {
671 pub fn from_context(ctx: &Context) -> Self {
672 Self {
673 project_id: ctx.project_id.clone(),
674 daemon_url: ctx.daemon_url.clone(),
675 }
676 }
677}
678
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680pub struct GraphLifecycleOutput {
681 pub project_id: String,
682 pub action: GraphLifecycleAction,
683 pub summary: String,
684 pub payload: Value,
685}
686
687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
688pub struct GraphReadRequest {
689 pub project_id: String,
690 pub symbol_id: String,
691 pub offset: usize,
692 pub limit: usize,
693 pub depth: usize,
694}
695
696#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
697pub struct GraphPayload {
698 pub nodes: Vec<GraphNode>,
699 pub links: Vec<GraphLink>,
700 #[serde(skip_serializing_if = "Option::is_none")]
701 pub center: Option<String>,
702}
703
704impl GraphPayload {
705 pub fn with_center(center: impl Into<String>) -> Self {
706 Self {
707 nodes: vec![],
708 links: vec![],
709 center: Some(center.into()),
710 }
711 }
712
713 pub fn push_node(&mut self, node: GraphNode) {
714 if node.id.is_empty() || self.nodes.iter().any(|existing| existing.id == node.id) {
715 return;
716 }
717 self.nodes.push(node);
718 }
719}
720
721#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
722pub struct GraphNode {
723 pub id: String,
724 pub name: String,
725 #[serde(rename = "type")]
726 pub node_type: String,
727 #[serde(skip_serializing_if = "Option::is_none")]
728 pub kind: Option<String>,
729 #[serde(skip_serializing_if = "Option::is_none")]
730 pub file_path: Option<String>,
731 #[serde(skip_serializing_if = "Option::is_none")]
732 pub line_start: Option<usize>,
733 #[serde(skip_serializing_if = "Option::is_none")]
734 pub signature: Option<String>,
735 #[serde(skip_serializing_if = "Option::is_none")]
736 pub symbol_count: Option<usize>,
737 #[serde(skip_serializing_if = "Option::is_none")]
738 pub language: Option<String>,
739 #[serde(skip_serializing_if = "Option::is_none")]
740 pub blast_distance: Option<usize>,
741}
742
743impl GraphNode {
744 pub fn new(
745 id: impl Into<String>,
746 name: impl Into<String>,
747 node_type: impl Into<String>,
748 ) -> Self {
749 Self {
750 id: id.into(),
751 name: name.into(),
752 node_type: node_type.into(),
753 kind: None,
754 file_path: None,
755 line_start: None,
756 signature: None,
757 symbol_count: None,
758 language: None,
759 blast_distance: None,
760 }
761 }
762
763 fn from_row(row: &Row, default_type: &str) -> Option<Self> {
764 let id = row_string(row, &["id", "node_id"])?;
765 let mut node = Self::new(
766 id.clone(),
767 row_string(row, &["name", "node_name"]).unwrap_or(id),
768 row_string(row, &["type", "node_type"]).unwrap_or_else(|| default_type.to_string()),
769 );
770 node.kind = row_string(row, &["kind"]);
771 node.file_path = row_string(row, &["file_path"]);
772 node.line_start = row_usize(row, &["line_start", "line"]);
773 node.signature = row_string(row, &["signature"]);
774 node.symbol_count = row_usize(row, &["symbol_count"]);
775 node.language = row_string(row, &["language"]);
776 node.blast_distance = row_usize(row, &["blast_distance", "distance"]);
777 Some(node)
778 }
779
780 fn from_prefixed_row(row: &Row, prefix: &str, default_type: &str) -> Option<Self> {
781 let id_key = format!("{prefix}_id");
782 let name_key = format!("{prefix}_name");
783 let type_key = format!("{prefix}_type");
784 let kind_key = format!("{prefix}_kind");
785 let file_path_key = format!("{prefix}_file_path");
786 let line_start_key = format!("{prefix}_line_start");
787 let signature_key = format!("{prefix}_signature");
788
789 let id = row_string_owned(row, &[id_key.as_str()])?;
790 let mut node = Self::new(
791 id.clone(),
792 row_string_owned(row, &[name_key.as_str()]).unwrap_or(id),
793 row_string_owned(row, &[type_key.as_str()]).unwrap_or_else(|| default_type.to_string()),
794 );
795 node.kind = row_string_owned(row, &[kind_key.as_str()]);
796 node.file_path = row_string_owned(row, &[file_path_key.as_str()]);
797 node.line_start = row_usize_owned(row, &[line_start_key.as_str()]);
798 node.signature = row_string_owned(row, &[signature_key.as_str()]);
799 Some(node)
800 }
801}
802
803#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
804pub struct GraphLink {
805 pub source: String,
806 pub target: String,
807 #[serde(rename = "type")]
808 pub link_type: String,
809 #[serde(skip_serializing_if = "Option::is_none")]
810 pub line: Option<usize>,
811 #[serde(skip_serializing_if = "Option::is_none")]
812 pub distance: Option<usize>,
813 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub metadata: Option<ProjectionMetadata>,
815}
816
817impl GraphLink {
818 pub fn new(
819 source: impl Into<String>,
820 target: impl Into<String>,
821 link_type: impl Into<String>,
822 ) -> Self {
823 Self {
824 source: source.into(),
825 target: target.into(),
826 link_type: link_type.into(),
827 line: None,
828 distance: None,
829 metadata: None,
830 }
831 }
832
833 pub fn from_row(row: &Row) -> Self {
834 let mut link = Self::new(
835 row_string(row, &["source"]).unwrap_or_default(),
836 row_string(row, &["target"]).unwrap_or_default(),
837 row_string(row, &["type", "rel_type"]).unwrap_or_else(|| "CALLS".to_string()),
838 );
839 link.line = row_usize(row, &["line"]);
840 link.distance = row_usize(row, &["distance"]);
841 link.metadata = row_to_projection_metadata(row);
842 link
843 }
844}
845
846#[derive(Debug, Clone, PartialEq, Eq)]
847pub enum GraphBlastRadiusTarget {
848 SymbolId(String),
849 FilePath(String),
850}
851
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub enum GraphReadError {
854 NotConfigured,
855 Unreachable { message: String },
856 QueryFailed { message: String },
857 InvalidTarget { message: String },
858}
859
860impl fmt::Display for GraphReadError {
861 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
862 match self {
863 Self::NotConfigured => {
864 f.write_str("FalkorDB is not configured; graph read APIs require FalkorDB")
865 }
866 Self::Unreachable { message } => {
867 write!(
868 f,
869 "FalkorDB is unreachable; graph read APIs require FalkorDB: {message}"
870 )
871 }
872 Self::QueryFailed { message } => {
873 write!(f, "FalkorDB graph read failed: {message}")
874 }
875 Self::InvalidTarget { message } => f.write_str(message),
876 }
877 }
878}
879
880impl std::error::Error for GraphReadError {}
881
882pub fn require_daemon_url(
883 daemon_url: Option<&str>,
884 action: GraphLifecycleAction,
885) -> anyhow::Result<&str> {
886 daemon_url.ok_or_else(|| {
887 anyhow::anyhow!(
888 "Gobby daemon URL is not configured. `{}` requires the Gobby daemon.",
889 action.cli_command()
890 )
891 })
892}
893
894pub(crate) fn build_lifecycle_url(
895 base_url: &str,
896 action: GraphLifecycleAction,
897 project_id: &str,
898) -> anyhow::Result<reqwest::Url> {
899 let base = base_url.trim_end_matches('/');
900 let mut url = reqwest::Url::parse(&format!("{base}{}", action.endpoint_path()))
901 .with_context(|| format!("invalid Gobby daemon URL: {base_url}"))?;
902 url.query_pairs_mut().append_pair("project_id", project_id);
903 Ok(url)
904}
905
906fn compact_detail(body: &str) -> String {
907 let detail = body.split_whitespace().collect::<Vec<_>>().join(" ");
908 let detail = detail.trim();
909 if detail.len() > 240 {
910 format!("{}...", &detail[..237])
911 } else {
912 detail.to_string()
913 }
914}
915
916pub(crate) fn format_http_error(
917 action: GraphLifecycleAction,
918 url: &reqwest::Url,
919 status: StatusCode,
920 body: &str,
921) -> String {
922 let detail = compact_detail(body);
923 if detail.is_empty() {
924 format!(
925 "`{}` failed: daemon returned HTTP {status} from {url}",
926 action.cli_command()
927 )
928 } else {
929 format!(
930 "`{}` failed: daemon returned HTTP {status} from {url}: {detail}",
931 action.cli_command()
932 )
933 }
934}
935
936pub(crate) fn parse_success_payload(
937 action: GraphLifecycleAction,
938 status: StatusCode,
939 body: &str,
940) -> anyhow::Result<Value> {
941 serde_json::from_str(body).map_err(|err| {
942 let detail = compact_detail(body);
943 if detail.is_empty() {
944 anyhow::anyhow!(
945 "`{}` failed: daemon returned HTTP {status} with invalid JSON: {err}",
946 action.cli_command()
947 )
948 } else {
949 anyhow::anyhow!(
950 "`{}` failed: daemon returned HTTP {status} with invalid JSON: {err}. Response: {detail}",
951 action.cli_command()
952 )
953 }
954 })
955}
956
957pub(crate) fn extract_summary_text(payload: &Value) -> Option<String> {
958 match payload {
959 Value::String(text) => {
960 let text = text.trim();
961 (!text.is_empty()).then(|| text.to_string())
962 }
963 Value::Object(map) => ["summary", "message", "detail", "status"]
964 .iter()
965 .find_map(|key| map.get(*key).and_then(Value::as_str))
966 .map(str::trim)
967 .filter(|text| !text.is_empty())
968 .map(ToOwned::to_owned),
969 _ => None,
970 }
971}
972
973pub fn run_lifecycle_action(
974 request: &GraphLifecycleRequest,
975 action: GraphLifecycleAction,
976) -> anyhow::Result<GraphLifecycleOutput> {
977 let daemon_url = require_daemon_url(request.daemon_url.as_deref(), action)?;
978 let url = build_lifecycle_url(daemon_url, action, &request.project_id)?;
979 let client = reqwest::blocking::Client::builder()
980 .timeout(std::time::Duration::from_secs(15))
981 .build()
982 .context("failed to build HTTP client")?;
983
984 let response = client
985 .post(url.clone())
986 .header("Accept", "application/json")
987 .send()
988 .with_context(|| {
989 format!(
990 "Failed to reach Gobby daemon at {daemon_url} for `{}`",
991 action.cli_command()
992 )
993 })?;
994
995 let status = response.status();
996 let body = response.text().unwrap_or_default();
997 if !status.is_success() {
998 anyhow::bail!("{}", format_http_error(action, &url, status, &body));
999 }
1000
1001 let payload = parse_success_payload(action, status, &body)?;
1002 let summary = extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
1003 Ok(GraphLifecycleOutput {
1004 project_id: request.project_id.clone(),
1005 action,
1006 summary,
1007 payload,
1008 })
1009}
1010
1011pub(crate) fn row_to_graph_result(row: &Row) -> GraphResult {
1012 GraphResult {
1013 id: row
1014 .get("caller_id")
1015 .or_else(|| row.get("callee_id"))
1016 .or_else(|| row.get("source_id"))
1017 .or_else(|| row.get("node_id"))
1018 .or_else(|| row.get("symbol_id"))
1019 .or_else(|| row.get("id"))
1020 .and_then(|v| v.as_str())
1021 .unwrap_or("")
1022 .to_string(),
1023 name: row
1024 .get("caller_name")
1025 .or_else(|| row.get("callee_name"))
1026 .or_else(|| row.get("source_name"))
1027 .or_else(|| row.get("node_name"))
1028 .or_else(|| row.get("symbol_name"))
1029 .or_else(|| row.get("name"))
1030 .or_else(|| row.get("module_name"))
1031 .and_then(|v| v.as_str())
1032 .unwrap_or("")
1033 .to_string(),
1034 file_path: row
1035 .get("file")
1036 .or_else(|| row.get("file_path"))
1037 .and_then(|v| v.as_str())
1038 .unwrap_or("")
1039 .to_string(),
1040 line: row.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
1041 relation: row
1042 .get("relation")
1043 .or_else(|| row.get("rel_type"))
1044 .and_then(|v| v.as_str())
1045 .map(String::from),
1046 distance: row
1047 .get("distance")
1048 .and_then(|v| v.as_u64())
1049 .map(|d| d as usize),
1050 metadata: row_to_projection_metadata(row),
1051 }
1052}
1053
1054pub fn extracted_code_edge_metadata(
1055 file_path: impl Into<String>,
1056 line: usize,
1057 source_symbol_id: Option<&str>,
1058) -> ProjectionMetadata {
1059 let mut metadata = ProjectionMetadata::gcode_extracted()
1060 .with_source_file_path(file_path)
1061 .with_source_line(line);
1062 if let Some(source_symbol_id) = source_symbol_id {
1063 metadata = metadata.with_source_symbol_id(source_symbol_id);
1064 }
1065 metadata
1066}
1067
1068fn row_to_projection_metadata(row: &Row) -> Option<ProjectionMetadata> {
1069 let provenance = row
1070 .get("provenance")
1071 .and_then(|v| v.as_str())
1072 .and_then(ProjectionProvenance::from_wire_value)?;
1073 let source_system = row.get("source_system").and_then(|v| v.as_str())?;
1074
1075 let mut metadata = ProjectionMetadata::new(provenance, source_system);
1076 metadata.confidence = row.get("confidence").and_then(|v| v.as_f64());
1077 metadata.source_file_path = row_string(row, &["metadata_source_file_path"]);
1078 metadata.source_line = row
1079 .get("source_line")
1080 .or_else(|| row.get("line"))
1081 .and_then(|v| v.as_u64())
1082 .map(|line| line as usize);
1083 metadata.source_symbol_id = row
1084 .get("source_symbol_id")
1085 .or_else(|| row.get("caller_id"))
1086 .or_else(|| row.get("source_id"))
1087 .and_then(|v| v.as_str())
1088 .map(ToOwned::to_owned);
1089 metadata.matching_method = row
1090 .get("matching_method")
1091 .and_then(|v| v.as_str())
1092 .map(ToOwned::to_owned);
1093 Some(metadata)
1094}
1095
1096fn row_string(row: &Row, keys: &[&str]) -> Option<String> {
1097 row_string_owned(row, keys)
1098}
1099
1100fn row_string_owned(row: &Row, keys: &[&str]) -> Option<String> {
1101 keys.iter()
1102 .find_map(|key| row.get(*key).and_then(|value| value.as_str()))
1103 .filter(|value| !value.is_empty())
1104 .map(ToOwned::to_owned)
1105}
1106
1107fn row_usize(row: &Row, keys: &[&str]) -> Option<usize> {
1108 row_usize_owned(row, keys)
1109}
1110
1111fn row_usize_owned(row: &Row, keys: &[&str]) -> Option<usize> {
1112 keys.iter()
1113 .find_map(|key| row.get(*key))
1114 .and_then(|value| {
1115 value
1116 .as_u64()
1117 .or_else(|| value.as_i64().and_then(|value| value.try_into().ok()))
1118 })
1119 .map(|value| value as usize)
1120}
1121
1122fn add_link_from_row(payload: &mut GraphPayload, row: &Row) {
1123 let link = GraphLink::from_row(row);
1124 if link.source.is_empty() || link.target.is_empty() {
1125 return;
1126 }
1127 payload.links.push(link);
1128}
1129
1130fn add_node_from_row(payload: &mut GraphPayload, row: &Row, default_type: &str) {
1131 if let Some(node) = GraphNode::from_row(row, default_type) {
1132 payload.push_node(node);
1133 }
1134}
1135
1136fn add_prefixed_node_from_row(
1137 payload: &mut GraphPayload,
1138 row: &Row,
1139 prefix: &str,
1140 default_type: &str,
1141) {
1142 if let Some(node) = GraphNode::from_prefixed_row(row, prefix, default_type) {
1143 payload.push_node(node);
1144 }
1145}
1146
1147fn clamp_limit(limit: usize) -> usize {
1148 typed_query::clamp_limit(limit, MAX_GRAPH_LIMIT)
1149}
1150
1151fn clamp_offset(offset: usize) -> usize {
1152 typed_query::clamp_offset(offset, MAX_GRAPH_LIMIT)
1153}
1154
1155pub(crate) fn count_callers_query(
1156 project_id: &str,
1157 symbol_id: &str,
1158) -> (String, HashMap<String, String>) {
1159 (
1160 format!(
1161 "MATCH (caller:CodeSymbol {{project: $project}})-[:CALLS]->(target {{id: $id, project: $project}}) \
1162 WHERE {CALL_TARGET_PREDICATE} \
1163 RETURN count(caller) AS cnt"
1164 ),
1165 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1166 )
1167}
1168
1169pub(crate) fn count_usages_query(
1170 project_id: &str,
1171 symbol_id: &str,
1172) -> (String, HashMap<String, String>) {
1173 (
1174 format!(
1175 "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
1176 WHERE {CALL_TARGET_PREDICATE} \
1177 RETURN count(source) AS cnt"
1178 ),
1179 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1180 )
1181}
1182
1183pub(crate) fn find_callers_query(
1184 project_id: &str,
1185 symbol_id: &str,
1186 offset: usize,
1187 limit: usize,
1188) -> (String, HashMap<String, String>) {
1189 let offset = clamp_offset(offset);
1190 let limit = clamp_limit(limit);
1191 (
1192 format!(
1193 "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
1194 WHERE {CALL_TARGET_PREDICATE} \
1195 RETURN caller.id AS caller_id, caller.name AS caller_name, \
1196 r.file AS file, r.line AS line \
1197 SKIP {offset} LIMIT {limit}"
1198 ),
1199 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1200 )
1201}
1202
1203pub(crate) fn find_usages_query(
1204 project_id: &str,
1205 symbol_id: &str,
1206 offset: usize,
1207 limit: usize,
1208) -> (String, HashMap<String, String>) {
1209 let offset = clamp_offset(offset);
1210 let limit = clamp_limit(limit);
1211 (
1212 format!(
1213 "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
1214 WHERE {CALL_TARGET_PREDICATE} \
1215 RETURN source.id AS source_id, source.name AS source_name, \
1216 'CALLS' AS rel_type, r.file AS file, r.line AS line \
1217 SKIP {offset} LIMIT {limit}"
1218 ),
1219 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1220 )
1221}
1222
1223pub(crate) fn find_callers_batch_query(
1224 project_id: &str,
1225 symbol_ids: &[String],
1226 limit: usize,
1227) -> (String, HashMap<String, String>) {
1228 let limit = clamp_limit(limit);
1229 let ids = typed_query::id_list_literal(symbol_ids);
1230 (
1231 format!(
1232 "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1233 WHERE ({CALL_TARGET_PREDICATE}) AND target.id IN [{ids}] \
1234 RETURN caller.id AS caller_id, caller.name AS caller_name, \
1235 r.file AS file, r.line AS line \
1236 LIMIT {limit}"
1237 ),
1238 typed_query::string_params(&[("project", project_id)]),
1239 )
1240}
1241
1242pub(crate) fn find_callees_batch_query(
1243 project_id: &str,
1244 symbol_ids: &[String],
1245 limit: usize,
1246) -> (String, HashMap<String, String>) {
1247 let limit = clamp_limit(limit);
1248 let ids = typed_query::id_list_literal(symbol_ids);
1249 (
1250 format!(
1251 "MATCH (src:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1252 WHERE src.id IN [{ids}] AND ({CALL_TARGET_PREDICATE}) \
1253 RETURN target.id AS callee_id, target.name AS callee_name, \
1254 r.file AS file, r.line AS line \
1255 LIMIT {limit}"
1256 ),
1257 typed_query::string_params(&[("project", project_id)]),
1258 )
1259}
1260
1261pub(crate) fn get_imports_query(
1262 project_id: &str,
1263 file_path: &str,
1264) -> (String, HashMap<String, String>) {
1265 (
1266 "MATCH (f:CodeFile {path: $path, project: $project})-[:IMPORTS]->(m:CodeModule) \
1267 RETURN m.name AS module_name"
1268 .to_string(),
1269 typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1270 )
1271}
1272
1273pub(crate) fn blast_radius_query(depth: usize, limit: usize) -> String {
1274 let depth = depth.clamp(1, 5);
1275 let limit = clamp_limit(limit);
1276 format!(
1277 "MATCH (target {{id: $id, project: $project}}) \
1278 WHERE {CALL_TARGET_PREDICATE} \
1279 MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target) \
1280 WITH affected, min(length(path)) AS distance \
1281 OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
1282 RETURN DISTINCT affected.id AS node_id, \
1283 affected.name AS node_name, \
1284 affected.kind AS kind, file.path AS file_path, \
1285 affected.line_start AS line, \
1286 distance, 'call' AS rel_type \
1287 ORDER BY distance ASC, affected.name ASC \
1288 LIMIT {limit}"
1289 )
1290}
1291
1292fn project_overview_files_query(
1293 project_id: &str,
1294 limit: usize,
1295) -> (String, HashMap<String, String>) {
1296 let limit = clamp_limit(limit);
1297 (
1298 format!(
1299 "MATCH (f:CodeFile {{project: $project}}) \
1300 OPTIONAL MATCH (f)-[:DEFINES]->(s:CodeSymbol) \
1301 WITH f, count(DISTINCT s) AS sym_count \
1302 OPTIONAL MATCH (f)-[:IMPORTS]->(m:CodeModule) \
1303 WITH f, sym_count, count(m) AS imp_count \
1304 RETURN f.path AS id, f.path AS name, 'file' AS type, \
1305 f.path AS file_path, sym_count AS symbol_count \
1306 ORDER BY imp_count DESC, sym_count DESC, f.path \
1307 LIMIT {limit}"
1308 ),
1309 typed_query::string_params(&[("project", project_id)]),
1310 )
1311}
1312
1313fn project_overview_imports_query(
1314 project_id: &str,
1315 file_paths: &[String],
1316 limit: usize,
1317) -> (String, HashMap<String, String>) {
1318 let limit = clamp_limit(limit);
1319 let file_paths = typed_query::id_list_literal(file_paths);
1320 (
1321 format!(
1322 "MATCH (f:CodeFile {{project: $project}})-[r:IMPORTS]->(m:CodeModule {{project: $project}}) \
1323 WHERE f.path IN [{file_paths}] \
1324 RETURN f.path AS source, m.name AS target, 'IMPORTS' AS type, {LINK_METADATA_RETURN} \
1325 LIMIT {limit}"
1326 ),
1327 typed_query::string_params(&[("project", project_id)]),
1328 )
1329}
1330
1331fn project_overview_defines_query(
1332 project_id: &str,
1333 file_paths: &[String],
1334 limit: usize,
1335) -> (String, HashMap<String, String>) {
1336 let limit = clamp_limit(limit);
1337 let file_paths = typed_query::id_list_literal(file_paths);
1338 (
1339 format!(
1340 "MATCH (f:CodeFile {{project: $project}})-[r:DEFINES]->(s:CodeSymbol {{project: $project}}) \
1341 WHERE f.path IN [{file_paths}] \
1342 RETURN f.path AS source, s.id AS target, 'DEFINES' AS type, \
1343 s.name AS symbol_name, s.kind AS symbol_kind, \
1344 s.file_path AS symbol_file_path, s.line_start AS line_start, \
1345 {LINK_METADATA_RETURN} \
1346 LIMIT {limit}"
1347 ),
1348 typed_query::string_params(&[("project", project_id)]),
1349 )
1350}
1351
1352fn project_overview_calls_query(
1353 project_id: &str,
1354 file_paths: &[String],
1355 limit: usize,
1356) -> (String, HashMap<String, String>) {
1357 let limit = clamp_limit(limit);
1358 let file_paths = typed_query::id_list_literal(file_paths);
1359 (
1360 format!(
1361 "MATCH (f:CodeFile {{project: $project}})-[:DEFINES]->(s:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1362 WHERE f.path IN [{file_paths}] AND ({CALL_TARGET_PREDICATE}) \
1363 RETURN s.id AS source, target.id AS target, 'CALLS' AS type, \
1364 target.name AS target_name, {TARGET_TYPE_CASE} AS target_type, \
1365 target.kind AS target_kind, target.file_path AS target_file_path, \
1366 target.line_start AS target_line_start, r.line AS line, \
1367 {LINK_METADATA_RETURN} \
1368 LIMIT {limit}"
1369 ),
1370 typed_query::string_params(&[("project", project_id)]),
1371 )
1372}
1373
1374fn file_symbols_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
1375 (
1376 format!(
1377 "MATCH (:CodeFile {{path: $path, project: $project}})-[r:DEFINES]->(s:CodeSymbol {{project: $project}}) \
1378 RETURN s.id AS id, s.name AS name, coalesce(s.kind, 'function') AS type, \
1379 s.kind AS kind, s.file_path AS file_path, \
1380 s.line_start AS line_start, s.signature AS signature, \
1381 {LINK_METADATA_RETURN}"
1382 ),
1383 typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1384 )
1385}
1386
1387fn file_calls_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
1388 (
1389 format!(
1390 "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1391 WHERE ({CALL_TARGET_PREDICATE}) \
1392 AND (source.file_path = $path OR (target:CodeSymbol AND target.file_path = $path)) \
1393 RETURN source.id AS source_id, source.name AS source_name, \
1394 coalesce(source.kind, 'function') AS source_type, \
1395 source.kind AS source_kind, source.file_path AS source_file_path, \
1396 source.line_start AS source_line_start, source.signature AS source_signature, \
1397 target.id AS target_id, target.name AS target_name, \
1398 {TARGET_TYPE_CASE} AS target_type, target.kind AS target_kind, \
1399 target.file_path AS target_file_path, \
1400 target.line_start AS target_line_start, target.signature AS target_signature, \
1401 source.id AS source, target.id AS target, 'CALLS' AS type, r.line AS line, \
1402 {LINK_METADATA_RETURN}"
1403 ),
1404 typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1405 )
1406}
1407
1408fn symbol_neighbors_query(
1409 project_id: &str,
1410 symbol_id: &str,
1411 limit: usize,
1412) -> (String, HashMap<String, String>) {
1413 let limit = clamp_limit(limit);
1414 (
1415 format!(
1416 "MATCH (center {{id: $id, project: $project}}) \
1417 WHERE center:CodeSymbol OR center:UnresolvedCallee OR center:ExternalSymbol \
1418 MATCH (center)-[r:CALLS]-(neighbor {{project: $project}}) \
1419 WHERE {NEIGHBOR_PREDICATE} \
1420 RETURN neighbor.id AS id, neighbor.name AS name, {NEIGHBOR_TYPE_CASE} AS type, \
1421 neighbor.kind AS kind, neighbor.file_path AS file_path, \
1422 neighbor.line_start AS line_start, neighbor.signature AS signature, \
1423 CASE WHEN startNode(r) = center THEN 'outgoing' ELSE 'incoming' END AS direction, \
1424 r.line AS line, {LINK_METADATA_RETURN} \
1425 LIMIT {limit}"
1426 ),
1427 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1428 )
1429}
1430
1431fn blast_radius_center_query(
1432 project_id: &str,
1433 symbol_id: &str,
1434) -> (String, HashMap<String, String>) {
1435 (
1436 format!(
1437 "MATCH (n {{id: $id, project: $project}}) \
1438 WHERE n:CodeSymbol OR n:UnresolvedCallee OR n:ExternalSymbol \
1439 RETURN n.id AS id, n.name AS name, {NODE_TYPE_CASE} AS type, \
1440 n.kind AS kind, n.file_path AS file_path \
1441 LIMIT 1"
1442 ),
1443 typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1444 )
1445}
1446
1447fn blast_radius_file_call_query(
1448 project_id: &str,
1449 file_path: &str,
1450 depth: usize,
1451 limit: usize,
1452) -> (String, HashMap<String, String>) {
1453 let depth = depth.clamp(1, 5);
1454 let limit = clamp_limit(limit);
1455 (
1456 format!(
1457 "MATCH (tf:CodeFile {{path: $path, project: $project}})-[:DEFINES]->(target_sym:CodeSymbol {{project: $project}}) \
1458 MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target_sym) \
1459 WITH affected, min(length(path)) AS distance \
1460 OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
1461 RETURN DISTINCT affected.id AS node_id, \
1462 affected.name AS node_name, \
1463 affected.kind AS kind, file.path AS file_path, \
1464 affected.line_start AS line, distance, 'call' AS rel_type, \
1465 coalesce(affected.kind, 'function') AS node_type \
1466 ORDER BY distance ASC, affected.name ASC \
1467 LIMIT {limit}"
1468 ),
1469 typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1470 )
1471}
1472
1473fn blast_radius_file_import_query(
1474 project_id: &str,
1475 file_path: &str,
1476 depth: usize,
1477 limit: usize,
1478) -> (String, HashMap<String, String>) {
1479 let depth = depth.clamp(1, 5);
1480 let limit = clamp_limit(limit);
1481 (
1482 format!(
1483 "MATCH (tf:CodeFile {{path: $path, project: $project}})-[:IMPORTS]->(m:CodeModule {{project: $project}}) \
1484 MATCH path = (importer:CodeFile {{project: $project}})-[:IMPORTS*1..{depth}]->(m) \
1485 WHERE importer.path <> $path \
1486 WITH importer, min(length(path)) AS distance \
1487 RETURN DISTINCT importer.path AS node_id, \
1488 importer.path AS node_name, NULL AS kind, importer.path AS file_path, \
1489 NULL AS line, distance, 'import' AS rel_type, 'file' AS node_type \
1490 ORDER BY distance ASC \
1491 LIMIT {limit}"
1492 ),
1493 typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1494 )
1495}
1496
1497fn count_from_rows(rows: &[Row]) -> usize {
1498 rows.first()
1499 .and_then(|r| r.get("cnt"))
1500 .and_then(|v| {
1501 v.as_u64()
1502 .or_else(|| v.as_i64().and_then(|value| value.try_into().ok()))
1503 })
1504 .unwrap_or(0) as usize
1505}
1506
1507pub fn require_graph_reads(ctx: &Context) -> anyhow::Result<()> {
1508 if ctx.falkordb.is_none() {
1509 return Err(GraphReadError::NotConfigured.into());
1510 }
1511 Ok(())
1512}
1513
1514fn with_required_core_graph<T>(
1515 ctx: &Context,
1516 f: impl FnOnce(&mut GraphClient) -> anyhow::Result<T>,
1517) -> anyhow::Result<T> {
1518 let config = ctx.falkordb.as_ref().ok_or(GraphReadError::NotConfigured)?;
1519 let connection_config = config.connection_config();
1520 match gobby_core::falkor::with_graph(
1521 Some(&connection_config),
1522 &config.graph_name,
1523 None,
1524 |client| f(client).map(Some),
1525 ) {
1526 Ok((Some(value), ServiceState::Available)) => Ok(value),
1527 Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
1528 Ok((_, ServiceState::Unreachable { message })) => {
1529 Err(GraphReadError::Unreachable { message }.into())
1530 }
1531 Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
1532 message: "graph read returned no value".to_string(),
1533 }
1534 .into()),
1535 Err(error) => Err(GraphReadError::QueryFailed {
1536 message: error.to_string(),
1537 }
1538 .into()),
1539 }
1540}
1541
1542pub fn project_overview_graph(ctx: &Context, limit: usize) -> anyhow::Result<GraphPayload> {
1543 with_required_core_graph(ctx, |client| {
1544 let limit = clamp_limit(limit);
1545 let link_limit = clamp_limit(limit.saturating_mul(4));
1546 let max_nodes = limit.saturating_mul(8);
1547
1548 let (query, params) = project_overview_files_query(&ctx.project_id, limit);
1549 let file_rows = client.query(&query, Some(params))?;
1550 let mut payload = GraphPayload::default();
1551 for row in &file_rows {
1552 add_node_from_row(&mut payload, row, "file");
1553 }
1554
1555 let file_paths = payload
1556 .nodes
1557 .iter()
1558 .filter(|node| node.node_type == "file")
1559 .map(|node| node.id.clone())
1560 .collect::<Vec<_>>();
1561 if file_paths.is_empty() {
1562 return Ok(payload);
1563 }
1564
1565 let (query, params) =
1566 project_overview_imports_query(&ctx.project_id, &file_paths, link_limit);
1567 for row in client.query(&query, Some(params))? {
1568 add_link_from_row(&mut payload, &row);
1569 if let Some(module_id) = row_string(&row, &["target"]) {
1570 payload.push_node(GraphNode::new(module_id.clone(), module_id, "module"));
1571 }
1572 if payload.nodes.len() >= max_nodes {
1573 break;
1574 }
1575 }
1576
1577 let (query, params) =
1578 project_overview_defines_query(&ctx.project_id, &file_paths, link_limit);
1579 for row in client.query(&query, Some(params))? {
1580 add_link_from_row(&mut payload, &row);
1581 if let Some(symbol_id) = row_string(&row, &["target"]) {
1582 let mut node = GraphNode::new(
1583 symbol_id.clone(),
1584 row_string(&row, &["symbol_name"]).unwrap_or(symbol_id),
1585 row_string(&row, &["symbol_kind"]).unwrap_or_else(|| "function".to_string()),
1586 );
1587 node.kind = row_string(&row, &["symbol_kind"]);
1588 node.file_path = row_string(&row, &["symbol_file_path", "source"]);
1589 node.line_start = row_usize(&row, &["line_start"]);
1590 payload.push_node(node);
1591 }
1592 if payload.nodes.len() >= max_nodes {
1593 break;
1594 }
1595 }
1596
1597 let (query, params) =
1598 project_overview_calls_query(&ctx.project_id, &file_paths, link_limit);
1599 for row in client.query(&query, Some(params))? {
1600 add_link_from_row(&mut payload, &row);
1601 if let Some(target_id) = row_string(&row, &["target"]) {
1602 let mut node = GraphNode::new(
1603 target_id.clone(),
1604 row_string(&row, &["target_name"]).unwrap_or(target_id),
1605 row_string(&row, &["target_type"]).unwrap_or_else(|| "unresolved".to_string()),
1606 );
1607 node.kind = row_string(&row, &["target_kind"]);
1608 node.file_path = row_string(&row, &["target_file_path"]);
1609 node.line_start = row_usize(&row, &["target_line_start"]);
1610 payload.push_node(node);
1611 }
1612 if payload.nodes.len() >= max_nodes {
1613 break;
1614 }
1615 }
1616
1617 Ok(payload)
1618 })
1619}
1620
1621pub fn file_graph(ctx: &Context, file_path: &str) -> anyhow::Result<GraphPayload> {
1622 with_required_core_graph(ctx, |client| {
1623 let mut payload = GraphPayload::default();
1624 let (query, params) = file_symbols_query(&ctx.project_id, file_path);
1625 for row in client.query(&query, Some(params))? {
1626 add_node_from_row(&mut payload, &row, "function");
1627 if let Some(symbol_id) = row_string(&row, &["id"]) {
1628 let mut link = GraphLink::new(file_path, symbol_id, "DEFINES");
1629 link.metadata = row_to_projection_metadata(&row);
1630 payload.links.push(link);
1631 }
1632 }
1633
1634 let (query, params) = file_calls_query(&ctx.project_id, file_path);
1635 for row in client.query(&query, Some(params))? {
1636 add_prefixed_node_from_row(&mut payload, &row, "source", "function");
1637 add_prefixed_node_from_row(&mut payload, &row, "target", "unresolved");
1638 add_link_from_row(&mut payload, &row);
1639 }
1640
1641 Ok(payload)
1642 })
1643}
1644
1645pub fn symbol_neighbors(
1646 ctx: &Context,
1647 symbol_id: &str,
1648 limit: usize,
1649) -> anyhow::Result<GraphPayload> {
1650 with_required_core_graph(ctx, |client| {
1651 let (query, params) = symbol_neighbors_query(&ctx.project_id, symbol_id, limit);
1652 let rows = client.query(&query, Some(params))?;
1653 let mut payload = GraphPayload::default();
1654
1655 for row in rows {
1656 add_node_from_row(&mut payload, &row, "unresolved");
1657 let Some(neighbor_id) = row_string(&row, &["id"]) else {
1658 continue;
1659 };
1660 let direction = row_string(&row, &["direction"]).unwrap_or_default();
1661 let mut link = if direction == "outgoing" {
1662 GraphLink::new(symbol_id, neighbor_id, "CALLS")
1663 } else {
1664 GraphLink::new(neighbor_id, symbol_id, "CALLS")
1665 };
1666 link.line = row_usize(&row, &["line"]);
1667 link.metadata = row_to_projection_metadata(&row);
1668 payload.links.push(link);
1669 }
1670
1671 Ok(payload)
1672 })
1673}
1674
1675pub fn blast_radius_graph(
1676 ctx: &Context,
1677 target: GraphBlastRadiusTarget,
1678 depth: usize,
1679 limit: usize,
1680) -> anyhow::Result<GraphPayload> {
1681 with_required_core_graph(ctx, |client| {
1682 let (center_id, mut center_node, rows) = match target {
1683 GraphBlastRadiusTarget::SymbolId(symbol_id) => {
1684 let (query, params) = blast_radius_center_query(&ctx.project_id, &symbol_id);
1685 let center_rows = client.query(&query, Some(params))?;
1686 let center_node = center_rows
1687 .first()
1688 .and_then(|row| GraphNode::from_row(row, "function"))
1689 .unwrap_or_else(|| GraphNode::new(&symbol_id, &symbol_id, "function"));
1690
1691 let query = blast_radius_query(depth, limit);
1692 let params =
1693 typed_query::string_params(&[("project", &ctx.project_id), ("id", &symbol_id)]);
1694 (symbol_id, center_node, client.query(&query, Some(params))?)
1695 }
1696 GraphBlastRadiusTarget::FilePath(file_path) => {
1697 let mut rows = vec![];
1698 let (query, params) =
1699 blast_radius_file_call_query(&ctx.project_id, &file_path, depth, limit);
1700 rows.extend(client.query(&query, Some(params))?);
1701 let (query, params) =
1702 blast_radius_file_import_query(&ctx.project_id, &file_path, depth, limit);
1703 rows.extend(client.query(&query, Some(params))?);
1704 (
1705 file_path.clone(),
1706 GraphNode::new(&file_path, &file_path, "file"),
1707 rows,
1708 )
1709 }
1710 };
1711
1712 center_node.blast_distance = Some(0);
1713 let mut payload = GraphPayload::with_center(center_id.clone());
1714 payload.push_node(center_node);
1715
1716 for row in rows {
1717 let Some(node_id) = row_string(&row, &["node_id"]) else {
1718 continue;
1719 };
1720 let mut node = GraphNode::new(
1721 node_id.clone(),
1722 row_string(&row, &["node_name"]).unwrap_or_else(|| node_id.clone()),
1723 row_string(&row, &["node_type"]).unwrap_or_else(|| "function".to_string()),
1724 );
1725 node.kind = row_string(&row, &["kind"]);
1726 node.file_path = row_string(&row, &["file_path"]);
1727 node.line_start = row_usize(&row, &["line"]);
1728 node.blast_distance = row_usize(&row, &["distance"]);
1729 payload.push_node(node);
1730
1731 let relation = row_string(&row, &["rel_type"]).unwrap_or_else(|| "call".to_string());
1732 let mut link = GraphLink::new(
1733 node_id,
1734 ¢er_id,
1735 if relation == "call" {
1736 "CALLS"
1737 } else {
1738 "IMPORTS"
1739 },
1740 );
1741 link.distance = row_usize(&row, &["distance"]);
1742 link.metadata = row_to_projection_metadata(&row);
1743 payload.links.push(link);
1744 }
1745
1746 Ok(payload)
1747 })
1748}
1749
1750pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
1751 with_required_core_graph(ctx, |client| {
1752 let (query, params) = count_callers_query(&ctx.project_id, symbol_id);
1753 let rows = client.query(&query, Some(params))?;
1754 Ok(count_from_rows(&rows))
1755 })
1756}
1757
1758pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
1759 with_required_core_graph(ctx, |client| {
1760 let (query, params) = count_usages_query(&ctx.project_id, symbol_id);
1761 let rows = client.query(&query, Some(params))?;
1762 Ok(count_from_rows(&rows))
1763 })
1764}
1765
1766pub fn find_callers(
1767 ctx: &Context,
1768 symbol_id: &str,
1769 offset: usize,
1770 limit: usize,
1771) -> anyhow::Result<Vec<GraphResult>> {
1772 with_required_core_graph(ctx, |client| {
1773 let (query, params) = find_callers_query(&ctx.project_id, symbol_id, offset, limit);
1774 let rows = client.query(&query, Some(params))?;
1775 Ok(rows.iter().map(row_to_graph_result).collect())
1776 })
1777}
1778
1779pub fn find_usages(
1780 ctx: &Context,
1781 symbol_id: &str,
1782 offset: usize,
1783 limit: usize,
1784) -> anyhow::Result<Vec<GraphResult>> {
1785 with_required_core_graph(ctx, |client| {
1786 let (query, params) = find_usages_query(&ctx.project_id, symbol_id, offset, limit);
1787 let rows = client.query(&query, Some(params))?;
1788 Ok(rows.iter().map(row_to_graph_result).collect())
1789 })
1790}
1791
1792pub fn find_callers_batch(
1793 ctx: &Context,
1794 symbol_ids: &[String],
1795 limit: usize,
1796) -> anyhow::Result<Vec<GraphResult>> {
1797 if symbol_ids.is_empty() {
1798 return Ok(vec![]);
1799 }
1800 with_required_core_graph(ctx, |client| {
1801 let (query, params) = find_callers_batch_query(&ctx.project_id, symbol_ids, limit);
1802 let rows = client.query(&query, Some(params))?;
1803 Ok(rows.iter().map(row_to_graph_result).collect())
1804 })
1805}
1806
1807pub fn find_callees_batch(
1808 ctx: &Context,
1809 symbol_ids: &[String],
1810 limit: usize,
1811) -> anyhow::Result<Vec<GraphResult>> {
1812 if symbol_ids.is_empty() {
1813 return Ok(vec![]);
1814 }
1815 with_required_core_graph(ctx, |client| {
1816 let (query, params) = find_callees_batch_query(&ctx.project_id, symbol_ids, limit);
1817 let rows = client.query(&query, Some(params))?;
1818 Ok(rows.iter().map(row_to_graph_result).collect())
1819 })
1820}
1821
1822pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
1823 with_required_core_graph(ctx, |client| {
1824 let (query, params) = get_imports_query(&ctx.project_id, file_path);
1825 let rows = client.query(&query, Some(params))?;
1826 Ok(rows.iter().map(row_to_graph_result).collect())
1827 })
1828}
1829
1830pub fn blast_radius(
1831 ctx: &Context,
1832 symbol_id: &str,
1833 depth: usize,
1834) -> anyhow::Result<Vec<GraphResult>> {
1835 with_required_core_graph(ctx, |client| {
1836 let query = blast_radius_query(depth, MAX_GRAPH_LIMIT);
1837 let params = typed_query::string_params(&[("project", &ctx.project_id), ("id", symbol_id)]);
1838 let rows = client.query(&query, Some(params))?;
1839 Ok(rows.iter().map(row_to_graph_result).collect())
1840 })
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845 use super::*;
1846 use crate::models::{ProjectionProvenance, SOURCE_SYSTEM_GCODE};
1847 use serde_json::json;
1848
1849 #[test]
1850 fn code_edges_carry_provenance() {
1851 let metadata = extracted_code_edge_metadata("src/lib.rs", 42, Some("caller-1"));
1852
1853 assert_eq!(metadata.provenance, ProjectionProvenance::Extracted);
1854 assert_eq!(metadata.confidence, Some(1.0));
1855 assert_eq!(metadata.source_system, SOURCE_SYSTEM_GCODE);
1856 assert_eq!(metadata.source_file_path.as_deref(), Some("src/lib.rs"));
1857 assert_eq!(metadata.source_line, Some(42));
1858 assert_eq!(metadata.source_symbol_id.as_deref(), Some("caller-1"));
1859 }
1860
1861 #[test]
1862 fn read_apis_return_node_link_payloads_with_link_metadata() {
1863 let mut payload = GraphPayload::default();
1864 payload.push_node(GraphNode::new("src/lib.rs", "src/lib.rs", "file"));
1865
1866 let link_row = Row::from([
1867 ("source".to_string(), json!("src/lib.rs")),
1868 ("target".to_string(), json!("symbol-1")),
1869 ("type".to_string(), json!("DEFINES")),
1870 ("line".to_string(), json!(12)),
1871 ("provenance".to_string(), json!("EXTRACTED")),
1872 ("confidence".to_string(), json!(1.0)),
1873 ("source_system".to_string(), json!("gcode")),
1874 ("source_file_path".to_string(), json!("src/lib.rs")),
1875 ("source_line".to_string(), json!(12)),
1876 ("source_symbol_id".to_string(), json!("symbol-1")),
1877 ]);
1878 payload.links.push(GraphLink::from_row(&link_row));
1879
1880 let encoded = serde_json::to_value(&payload).expect("payload serializes");
1881
1882 assert_eq!(encoded["nodes"][0]["id"], "src/lib.rs");
1883 assert_eq!(encoded["nodes"][0]["type"], "file");
1884 assert_eq!(encoded["links"][0]["source"], "src/lib.rs");
1885 assert_eq!(encoded["links"][0]["target"], "symbol-1");
1886 assert_eq!(encoded["links"][0]["type"], "DEFINES");
1887 assert_eq!(encoded["links"][0]["metadata"]["provenance"], "EXTRACTED");
1888 assert_eq!(encoded["links"][0]["metadata"]["source_system"], "gcode");
1889 }
1890
1891 #[test]
1892 fn file_calls_query_keeps_node_and_metadata_source_paths_distinct() {
1893 let (query, _) = file_calls_query("project-1", "src/lib.rs");
1894
1895 assert!(query.contains("source.file_path AS source_file_path"));
1896 assert!(query.contains("r.source_file_path AS metadata_source_file_path"));
1897 assert!(!query.contains("r.source_file_path AS source_file_path"));
1898 }
1899
1900 #[test]
1901 fn projection_metadata_uses_only_metadata_source_file_path() {
1902 let row = Row::from([
1903 ("provenance".to_string(), json!("EXTRACTED")),
1904 ("source_system".to_string(), json!("gcode")),
1905 ("source_file_path".to_string(), json!("src/node.rs")),
1906 (
1907 "metadata_source_file_path".to_string(),
1908 json!("src/edge.rs"),
1909 ),
1910 ]);
1911
1912 let metadata = row_to_projection_metadata(&row).expect("metadata");
1913
1914 assert_eq!(metadata.source_file_path.as_deref(), Some("src/edge.rs"));
1915 }
1916
1917 #[test]
1918 fn projection_metadata_does_not_fallback_to_node_source_file_path() {
1919 let row = Row::from([
1920 ("provenance".to_string(), json!("EXTRACTED")),
1921 ("source_system".to_string(), json!("gcode")),
1922 ("source_file_path".to_string(), json!("src/node.rs")),
1923 ]);
1924
1925 let metadata = row_to_projection_metadata(&row).expect("metadata");
1926
1927 assert_eq!(metadata.source_file_path, None);
1928 }
1929
1930 #[test]
1931 fn delete_preserves_current_symbols() {
1932 let current_ids = vec!["symbol-current".to_string()];
1933 let queries =
1934 delete_file_graph_queries("project-1", "src/lib.rs", ¤t_ids).expect("queries");
1935
1936 let combined = queries
1937 .iter()
1938 .map(|query| query.cypher.as_str())
1939 .collect::<Vec<_>>()
1940 .join("\n");
1941
1942 assert!(
1943 combined.contains(
1944 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})-[r:CALLS]->()"
1945 ),
1946 "{combined}"
1947 );
1948 assert!(
1949 combined.contains("WHERE NOT s.id IN $symbol_ids"),
1950 "{combined}"
1951 );
1952 assert!(
1953 !combined.contains(
1954 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})\n DETACH DELETE s"
1955 ),
1956 "{combined}"
1957 );
1958
1959 let stale_symbol_cleanup = queries
1960 .iter()
1961 .find(|query| query.cypher.contains("WHERE NOT s.id IN $symbol_ids"))
1962 .expect("stale symbol cleanup query");
1963 assert_eq!(
1964 stale_symbol_cleanup
1965 .params
1966 .get("symbol_ids")
1967 .map(String::as_str),
1968 Some("['symbol-current']")
1969 );
1970 }
1971
1972 #[test]
1973 fn cleanup_orphans_is_project_scoped() {
1974 let queries = cleanup_orphans_queries("project-1").expect("queries");
1975 assert_eq!(queries.len(), 3);
1976
1977 for query in &queries {
1978 assert_eq!(
1979 query.params.get("project").map(String::as_str),
1980 Some("'project-1'")
1981 );
1982 assert!(
1983 query.cypher.contains("{project: $project}"),
1984 "{}",
1985 query.cypher
1986 );
1987 }
1988
1989 assert!(
1990 queries[0]
1991 .cypher
1992 .contains("MATCH (m:CodeModule {project: $project})"),
1993 "{}",
1994 queries[0].cypher
1995 );
1996 assert!(
1997 queries[1]
1998 .cypher
1999 .contains("WHERE (n:UnresolvedCallee OR n:ExternalSymbol)"),
2000 "{}",
2001 queries[1].cypher
2002 );
2003 assert!(
2004 queries[2]
2005 .cypher
2006 .contains("MATCH (s:CodeSymbol {project: $project})")
2007 && queries[2].cypher.contains("s.file_path IS NULL")
2008 && queries[2].cypher.contains("NOT ()-[:DEFINES]->(s)")
2009 && queries[2].cypher.contains("NOT ()-[:CALLS]->(s)")
2010 && queries[2].cypher.contains("NOT (s)-[:CALLS]->()"),
2011 "{}",
2012 queries[2].cypher
2013 );
2014 }
2015
2016 #[test]
2017 fn delete_file_node_is_project_and_path_scoped() {
2018 let query = delete_file_node_query("project-1", "src/lib.rs").expect("query");
2019
2020 assert!(
2021 query
2022 .cypher
2023 .contains("MATCH (f:CodeFile {path: $file_path, project: $project})"),
2024 "{}",
2025 query.cypher
2026 );
2027 assert!(query.cypher.contains("DETACH DELETE f"), "{}", query.cypher);
2028 assert_eq!(
2029 query.params.get("project").map(String::as_str),
2030 Some("'project-1'")
2031 );
2032 assert_eq!(
2033 query.params.get("file_path").map(String::as_str),
2034 Some("'src/lib.rs'")
2035 );
2036 }
2037
2038 #[test]
2039 fn clear_project_is_project_scoped() {
2040 let query = clear_project_query("project-1").expect("query");
2041
2042 assert!(query.cypher.contains("MATCH (n {project: $project})"));
2043 assert!(query.cypher.contains("n:CodeFile"));
2044 assert!(query.cypher.contains("n:CodeSymbol"));
2045 assert_eq!(
2046 query.params.get("project").map(String::as_str),
2047 Some("'project-1'")
2048 );
2049 }
2050
2051 #[test]
2052 fn clear_project_targets_only_code_index_labels() {
2053 let query = clear_project_query("project-1").expect("query");
2054
2055 for code_label in [
2056 "n:CodeFile",
2057 "n:CodeSymbol",
2058 "n:CodeModule",
2059 "n:UnresolvedCallee",
2060 "n:ExternalSymbol",
2061 ] {
2062 assert!(query.cypher.contains(code_label), "missing {code_label}");
2063 }
2064
2065 for memory_label in [
2066 "Memory",
2067 "MemoryNode",
2068 "MemoryGraph",
2069 "Entity",
2070 "Observation",
2071 "Relationship",
2072 "RELATES_TO_CODE",
2073 ] {
2074 assert!(
2075 !query.cypher.contains(memory_label),
2076 "code graph clear must not target memory label {memory_label}"
2077 );
2078 }
2079 }
2080
2081 #[test]
2082 fn clear_all_code_index_targets_only_code_index_labels() {
2083 let query = clear_all_code_index_query().expect("query");
2084
2085 assert!(query.cypher.contains("MATCH (n)"));
2086 assert!(query.cypher.contains("n:CodeFile"));
2087 assert!(query.cypher.contains("n:CodeSymbol"));
2088 assert!(query.cypher.contains("n:CodeModule"));
2089 assert!(query.cypher.contains("n:UnresolvedCallee"));
2090 assert!(query.cypher.contains("n:ExternalSymbol"));
2091 assert!(!query.cypher.contains("config_store"));
2092 assert!(!query.cypher.contains("MATCH (n {project: $project})"));
2093 assert!(query.params.is_empty());
2094 }
2095}