1use std::collections::{HashMap, HashSet};
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 dedupe_limited_blast_rows(mut rows: Vec<Row>, limit: usize) -> Vec<Row> {
1498 rows.sort_by(|left, right| {
1499 row_usize(left, &["distance"])
1500 .unwrap_or(usize::MAX)
1501 .cmp(&row_usize(right, &["distance"]).unwrap_or(usize::MAX))
1502 .then_with(|| {
1503 row_string(left, &["node_name"])
1504 .unwrap_or_default()
1505 .cmp(&row_string(right, &["node_name"]).unwrap_or_default())
1506 })
1507 .then_with(|| {
1508 row_string(left, &["node_id"])
1509 .unwrap_or_default()
1510 .cmp(&row_string(right, &["node_id"]).unwrap_or_default())
1511 })
1512 });
1513
1514 let mut seen = HashSet::new();
1515 rows.retain(|row| {
1516 let Some(node_id) = row_string(row, &["node_id"]) else {
1517 return false;
1518 };
1519 seen.insert(node_id)
1520 });
1521 rows.truncate(clamp_limit(limit));
1522 rows
1523}
1524
1525fn count_from_rows(rows: &[Row]) -> usize {
1526 rows.first()
1527 .and_then(|r| r.get("cnt"))
1528 .and_then(|v| {
1529 v.as_u64()
1530 .or_else(|| v.as_i64().and_then(|value| value.try_into().ok()))
1531 })
1532 .unwrap_or(0) as usize
1533}
1534
1535pub fn require_graph_reads(ctx: &Context) -> anyhow::Result<()> {
1536 if ctx.falkordb.is_none() {
1537 return Err(GraphReadError::NotConfigured.into());
1538 }
1539 Ok(())
1540}
1541
1542fn with_required_core_graph<T>(
1543 ctx: &Context,
1544 f: impl FnOnce(&mut GraphClient) -> anyhow::Result<T>,
1545) -> anyhow::Result<T> {
1546 let config = ctx.falkordb.as_ref().ok_or(GraphReadError::NotConfigured)?;
1547 let connection_config = config.connection_config();
1548 match gobby_core::falkor::with_graph(
1549 Some(&connection_config),
1550 &config.graph_name,
1551 None,
1552 |client| f(client).map(Some),
1553 ) {
1554 Ok((Some(value), ServiceState::Available)) => Ok(value),
1555 Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
1556 Ok((_, ServiceState::Unreachable { message })) => {
1557 Err(GraphReadError::Unreachable { message }.into())
1558 }
1559 Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
1560 message: "graph read returned no value".to_string(),
1561 }
1562 .into()),
1563 Err(error) => Err(GraphReadError::QueryFailed {
1564 message: error.to_string(),
1565 }
1566 .into()),
1567 }
1568}
1569
1570pub fn project_overview_graph(ctx: &Context, limit: usize) -> anyhow::Result<GraphPayload> {
1571 with_required_core_graph(ctx, |client| {
1572 let limit = clamp_limit(limit);
1573 let link_limit = clamp_limit(limit.saturating_mul(4));
1574 let max_nodes = limit.saturating_mul(8);
1575
1576 let (query, params) = project_overview_files_query(&ctx.project_id, limit);
1577 let file_rows = client.query(&query, Some(params))?;
1578 let mut payload = GraphPayload::default();
1579 for row in &file_rows {
1580 add_node_from_row(&mut payload, row, "file");
1581 }
1582
1583 let file_paths = payload
1584 .nodes
1585 .iter()
1586 .filter(|node| node.node_type == "file")
1587 .map(|node| node.id.clone())
1588 .collect::<Vec<_>>();
1589 if file_paths.is_empty() {
1590 return Ok(payload);
1591 }
1592
1593 let (query, params) =
1594 project_overview_imports_query(&ctx.project_id, &file_paths, link_limit);
1595 for row in client.query(&query, Some(params))? {
1596 add_link_from_row(&mut payload, &row);
1597 if let Some(module_id) = row_string(&row, &["target"]) {
1598 payload.push_node(GraphNode::new(module_id.clone(), module_id, "module"));
1599 }
1600 if payload.nodes.len() >= max_nodes {
1601 break;
1602 }
1603 }
1604
1605 let (query, params) =
1606 project_overview_defines_query(&ctx.project_id, &file_paths, link_limit);
1607 for row in client.query(&query, Some(params))? {
1608 add_link_from_row(&mut payload, &row);
1609 if let Some(symbol_id) = row_string(&row, &["target"]) {
1610 let mut node = GraphNode::new(
1611 symbol_id.clone(),
1612 row_string(&row, &["symbol_name"]).unwrap_or(symbol_id),
1613 row_string(&row, &["symbol_kind"]).unwrap_or_else(|| "function".to_string()),
1614 );
1615 node.kind = row_string(&row, &["symbol_kind"]);
1616 node.file_path = row_string(&row, &["symbol_file_path", "source"]);
1617 node.line_start = row_usize(&row, &["line_start"]);
1618 payload.push_node(node);
1619 }
1620 if payload.nodes.len() >= max_nodes {
1621 break;
1622 }
1623 }
1624
1625 let (query, params) =
1626 project_overview_calls_query(&ctx.project_id, &file_paths, link_limit);
1627 for row in client.query(&query, Some(params))? {
1628 add_link_from_row(&mut payload, &row);
1629 if let Some(target_id) = row_string(&row, &["target"]) {
1630 let mut node = GraphNode::new(
1631 target_id.clone(),
1632 row_string(&row, &["target_name"]).unwrap_or(target_id),
1633 row_string(&row, &["target_type"]).unwrap_or_else(|| "unresolved".to_string()),
1634 );
1635 node.kind = row_string(&row, &["target_kind"]);
1636 node.file_path = row_string(&row, &["target_file_path"]);
1637 node.line_start = row_usize(&row, &["target_line_start"]);
1638 payload.push_node(node);
1639 }
1640 if payload.nodes.len() >= max_nodes {
1641 break;
1642 }
1643 }
1644
1645 Ok(payload)
1646 })
1647}
1648
1649pub fn file_graph(ctx: &Context, file_path: &str) -> anyhow::Result<GraphPayload> {
1650 with_required_core_graph(ctx, |client| {
1651 let mut payload = GraphPayload::default();
1652 let mut file_node = GraphNode::new(file_path, file_path, "file");
1653 file_node.file_path = Some(file_path.to_string());
1654 payload.push_node(file_node);
1655
1656 let (query, params) = file_symbols_query(&ctx.project_id, file_path);
1657 for row in client.query(&query, Some(params))? {
1658 add_node_from_row(&mut payload, &row, "function");
1659 if let Some(symbol_id) = row_string(&row, &["id"]) {
1660 let mut link = GraphLink::new(file_path, symbol_id, "DEFINES");
1661 link.metadata = row_to_projection_metadata(&row);
1662 payload.links.push(link);
1663 }
1664 }
1665
1666 let (query, params) = file_calls_query(&ctx.project_id, file_path);
1667 for row in client.query(&query, Some(params))? {
1668 add_prefixed_node_from_row(&mut payload, &row, "source", "function");
1669 add_prefixed_node_from_row(&mut payload, &row, "target", "unresolved");
1670 add_link_from_row(&mut payload, &row);
1671 }
1672
1673 Ok(payload)
1674 })
1675}
1676
1677pub fn symbol_neighbors(
1678 ctx: &Context,
1679 symbol_id: &str,
1680 limit: usize,
1681) -> anyhow::Result<GraphPayload> {
1682 with_required_core_graph(ctx, |client| {
1683 let mut payload = GraphPayload::with_center(symbol_id.to_string());
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 payload.push_node(center_node);
1691
1692 let (query, params) = symbol_neighbors_query(&ctx.project_id, symbol_id, limit);
1693 let rows = client.query(&query, Some(params))?;
1694
1695 for row in rows {
1696 add_node_from_row(&mut payload, &row, "unresolved");
1697 let Some(neighbor_id) = row_string(&row, &["id"]) else {
1698 continue;
1699 };
1700 let direction = row_string(&row, &["direction"]).unwrap_or_default();
1701 let mut link = if direction == "outgoing" {
1702 GraphLink::new(symbol_id, neighbor_id, "CALLS")
1703 } else {
1704 GraphLink::new(neighbor_id, symbol_id, "CALLS")
1705 };
1706 link.line = row_usize(&row, &["line"]);
1707 link.metadata = row_to_projection_metadata(&row);
1708 payload.links.push(link);
1709 }
1710
1711 Ok(payload)
1712 })
1713}
1714
1715pub fn blast_radius_graph(
1716 ctx: &Context,
1717 target: GraphBlastRadiusTarget,
1718 depth: usize,
1719 limit: usize,
1720) -> anyhow::Result<GraphPayload> {
1721 with_required_core_graph(ctx, |client| {
1722 let (center_id, mut center_node, rows) = match target {
1723 GraphBlastRadiusTarget::SymbolId(symbol_id) => {
1724 let (query, params) = blast_radius_center_query(&ctx.project_id, &symbol_id);
1725 let center_rows = client.query(&query, Some(params))?;
1726 let center_node = center_rows
1727 .first()
1728 .and_then(|row| GraphNode::from_row(row, "function"))
1729 .unwrap_or_else(|| GraphNode::new(&symbol_id, &symbol_id, "function"));
1730
1731 let query = blast_radius_query(depth, limit);
1732 let params =
1733 typed_query::string_params(&[("project", &ctx.project_id), ("id", &symbol_id)]);
1734 (symbol_id, center_node, client.query(&query, Some(params))?)
1735 }
1736 GraphBlastRadiusTarget::FilePath(file_path) => {
1737 let mut rows = vec![];
1738 let (query, params) =
1739 blast_radius_file_call_query(&ctx.project_id, &file_path, depth, limit);
1740 rows.extend(client.query(&query, Some(params))?);
1741 let (query, params) =
1742 blast_radius_file_import_query(&ctx.project_id, &file_path, depth, limit);
1743 rows.extend(client.query(&query, Some(params))?);
1744 let rows = dedupe_limited_blast_rows(rows, limit);
1745 (
1746 file_path.clone(),
1747 GraphNode::new(&file_path, &file_path, "file"),
1748 rows,
1749 )
1750 }
1751 };
1752
1753 center_node.blast_distance = Some(0);
1754 let mut payload = GraphPayload::with_center(center_id.clone());
1755 payload.push_node(center_node);
1756
1757 for row in rows {
1758 let Some(node_id) = row_string(&row, &["node_id"]) else {
1759 continue;
1760 };
1761 let mut node = GraphNode::new(
1762 node_id.clone(),
1763 row_string(&row, &["node_name"]).unwrap_or_else(|| node_id.clone()),
1764 row_string(&row, &["node_type"]).unwrap_or_else(|| "function".to_string()),
1765 );
1766 node.kind = row_string(&row, &["kind"]);
1767 node.file_path = row_string(&row, &["file_path"]);
1768 node.line_start = row_usize(&row, &["line"]);
1769 node.blast_distance = row_usize(&row, &["distance"]);
1770 payload.push_node(node);
1771
1772 let relation = row_string(&row, &["rel_type"]).unwrap_or_else(|| "call".to_string());
1773 let mut link = GraphLink::new(
1774 node_id,
1775 ¢er_id,
1776 if relation == "call" {
1777 "CALLS"
1778 } else {
1779 "IMPORTS"
1780 },
1781 );
1782 link.distance = row_usize(&row, &["distance"]);
1783 link.metadata = row_to_projection_metadata(&row);
1784 payload.links.push(link);
1785 }
1786
1787 Ok(payload)
1788 })
1789}
1790
1791pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
1792 with_required_core_graph(ctx, |client| {
1793 let (query, params) = count_callers_query(&ctx.project_id, symbol_id);
1794 let rows = client.query(&query, Some(params))?;
1795 Ok(count_from_rows(&rows))
1796 })
1797}
1798
1799pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
1800 with_required_core_graph(ctx, |client| {
1801 let (query, params) = count_usages_query(&ctx.project_id, symbol_id);
1802 let rows = client.query(&query, Some(params))?;
1803 Ok(count_from_rows(&rows))
1804 })
1805}
1806
1807pub fn find_callers(
1808 ctx: &Context,
1809 symbol_id: &str,
1810 offset: usize,
1811 limit: usize,
1812) -> anyhow::Result<Vec<GraphResult>> {
1813 with_required_core_graph(ctx, |client| {
1814 let (query, params) = find_callers_query(&ctx.project_id, symbol_id, offset, limit);
1815 let rows = client.query(&query, Some(params))?;
1816 Ok(rows.iter().map(row_to_graph_result).collect())
1817 })
1818}
1819
1820pub fn find_usages(
1821 ctx: &Context,
1822 symbol_id: &str,
1823 offset: usize,
1824 limit: usize,
1825) -> anyhow::Result<Vec<GraphResult>> {
1826 with_required_core_graph(ctx, |client| {
1827 let (query, params) = find_usages_query(&ctx.project_id, symbol_id, offset, limit);
1828 let rows = client.query(&query, Some(params))?;
1829 Ok(rows.iter().map(row_to_graph_result).collect())
1830 })
1831}
1832
1833pub fn find_callers_batch(
1834 ctx: &Context,
1835 symbol_ids: &[String],
1836 limit: usize,
1837) -> anyhow::Result<Vec<GraphResult>> {
1838 if symbol_ids.is_empty() {
1839 return Ok(vec![]);
1840 }
1841 with_required_core_graph(ctx, |client| {
1842 let (query, params) = find_callers_batch_query(&ctx.project_id, symbol_ids, limit);
1843 let rows = client.query(&query, Some(params))?;
1844 Ok(rows.iter().map(row_to_graph_result).collect())
1845 })
1846}
1847
1848pub fn find_callees_batch(
1849 ctx: &Context,
1850 symbol_ids: &[String],
1851 limit: usize,
1852) -> anyhow::Result<Vec<GraphResult>> {
1853 if symbol_ids.is_empty() {
1854 return Ok(vec![]);
1855 }
1856 with_required_core_graph(ctx, |client| {
1857 let (query, params) = find_callees_batch_query(&ctx.project_id, symbol_ids, limit);
1858 let rows = client.query(&query, Some(params))?;
1859 Ok(rows.iter().map(row_to_graph_result).collect())
1860 })
1861}
1862
1863pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
1864 with_required_core_graph(ctx, |client| {
1865 let (query, params) = get_imports_query(&ctx.project_id, file_path);
1866 let rows = client.query(&query, Some(params))?;
1867 Ok(rows.iter().map(row_to_graph_result).collect())
1868 })
1869}
1870
1871pub fn blast_radius(
1872 ctx: &Context,
1873 symbol_id: &str,
1874 depth: usize,
1875) -> anyhow::Result<Vec<GraphResult>> {
1876 with_required_core_graph(ctx, |client| {
1877 let query = blast_radius_query(depth, MAX_GRAPH_LIMIT);
1878 let params = typed_query::string_params(&[("project", &ctx.project_id), ("id", symbol_id)]);
1879 let rows = client.query(&query, Some(params))?;
1880 Ok(rows.iter().map(row_to_graph_result).collect())
1881 })
1882}
1883
1884#[cfg(test)]
1885mod tests {
1886 use super::*;
1887 use crate::config::CodeVectorSettings;
1888 use crate::models::{ProjectionProvenance, SOURCE_SYSTEM_GCODE};
1889 use serde_json::json;
1890
1891 fn test_context(falkordb: Option<crate::config::FalkorConfig>) -> Context {
1892 Context {
1893 database_url: "postgresql://localhost/nonexistent".to_string(),
1894 project_root: std::path::PathBuf::from("/tmp/project"),
1895 project_id: "project-1".to_string(),
1896 quiet: true,
1897 falkordb,
1898 qdrant: None,
1899 embedding: None,
1900 code_vectors: CodeVectorSettings::default(),
1901 daemon_url: None,
1902 }
1903 }
1904
1905 #[test]
1906 fn code_edges_carry_provenance() {
1907 let metadata = extracted_code_edge_metadata("src/lib.rs", 42, Some("caller-1"));
1908
1909 assert_eq!(metadata.provenance, ProjectionProvenance::Extracted);
1910 assert_eq!(metadata.confidence, Some(1.0));
1911 assert_eq!(metadata.source_system, SOURCE_SYSTEM_GCODE);
1912 assert_eq!(metadata.source_file_path.as_deref(), Some("src/lib.rs"));
1913 assert_eq!(metadata.source_line, Some(42));
1914 assert_eq!(metadata.source_symbol_id.as_deref(), Some("caller-1"));
1915 }
1916
1917 #[test]
1918 fn read_apis_return_node_link_payloads_with_link_metadata() {
1919 let mut payload = GraphPayload::default();
1920 payload.push_node(GraphNode::new("src/lib.rs", "src/lib.rs", "file"));
1921
1922 let link_row = Row::from([
1923 ("source".to_string(), json!("src/lib.rs")),
1924 ("target".to_string(), json!("symbol-1")),
1925 ("type".to_string(), json!("DEFINES")),
1926 ("line".to_string(), json!(12)),
1927 ("provenance".to_string(), json!("EXTRACTED")),
1928 ("confidence".to_string(), json!(1.0)),
1929 ("source_system".to_string(), json!("gcode")),
1930 ("source_file_path".to_string(), json!("src/lib.rs")),
1931 ("source_line".to_string(), json!(12)),
1932 ("source_symbol_id".to_string(), json!("symbol-1")),
1933 ]);
1934 payload.links.push(GraphLink::from_row(&link_row));
1935
1936 let encoded = serde_json::to_value(&payload).expect("payload serializes");
1937
1938 assert_eq!(encoded["nodes"][0]["id"], "src/lib.rs");
1939 assert_eq!(encoded["nodes"][0]["type"], "file");
1940 assert_eq!(encoded["links"][0]["source"], "src/lib.rs");
1941 assert_eq!(encoded["links"][0]["target"], "symbol-1");
1942 assert_eq!(encoded["links"][0]["type"], "DEFINES");
1943 assert_eq!(encoded["links"][0]["metadata"]["provenance"], "EXTRACTED");
1944 assert_eq!(encoded["links"][0]["metadata"]["source_system"], "gcode");
1945 }
1946
1947 #[test]
1948 fn phase7_graph_read_apis_surface_typed_unavailable_service() {
1949 let ctx = test_context(None);
1950
1951 let guard_error = require_graph_reads(&ctx).expect_err("missing FalkorDB must fail");
1952 assert!(matches!(
1953 guard_error.downcast_ref::<GraphReadError>(),
1954 Some(GraphReadError::NotConfigured)
1955 ));
1956
1957 let read_error =
1958 project_overview_graph(&ctx, 10).expect_err("graph read must require FalkorDB");
1959 assert!(matches!(
1960 read_error.downcast_ref::<GraphReadError>(),
1961 Some(GraphReadError::NotConfigured)
1962 ));
1963 }
1964
1965 #[test]
1966 fn file_blast_rows_are_deduped_and_limited_after_merge() {
1967 let rows = vec![
1968 Row::from([
1969 ("node_id".to_string(), json!("symbol-2")),
1970 ("node_name".to_string(), json!("zeta")),
1971 ("distance".to_string(), json!(2)),
1972 ]),
1973 Row::from([
1974 ("node_id".to_string(), json!("symbol-1")),
1975 ("node_name".to_string(), json!("alpha")),
1976 ("distance".to_string(), json!(1)),
1977 ]),
1978 Row::from([
1979 ("node_id".to_string(), json!("symbol-1")),
1980 ("node_name".to_string(), json!("alpha")),
1981 ("distance".to_string(), json!(3)),
1982 ]),
1983 ];
1984
1985 let rows = dedupe_limited_blast_rows(rows, 1);
1986
1987 assert_eq!(rows.len(), 1);
1988 assert_eq!(
1989 row_string(&rows[0], &["node_id"]).as_deref(),
1990 Some("symbol-1")
1991 );
1992 assert_eq!(row_usize(&rows[0], &["distance"]), Some(1));
1993 }
1994
1995 #[test]
1996 fn file_calls_query_keeps_node_and_metadata_source_paths_distinct() {
1997 let (query, _) = file_calls_query("project-1", "src/lib.rs");
1998
1999 assert!(query.contains("source.file_path AS source_file_path"));
2000 assert!(query.contains("r.source_file_path AS metadata_source_file_path"));
2001 assert!(!query.contains("r.source_file_path AS source_file_path"));
2002 }
2003
2004 #[test]
2005 fn projection_metadata_uses_only_metadata_source_file_path() {
2006 let row = Row::from([
2007 ("provenance".to_string(), json!("EXTRACTED")),
2008 ("source_system".to_string(), json!("gcode")),
2009 ("source_file_path".to_string(), json!("src/node.rs")),
2010 (
2011 "metadata_source_file_path".to_string(),
2012 json!("src/edge.rs"),
2013 ),
2014 ]);
2015
2016 let metadata = row_to_projection_metadata(&row).expect("metadata");
2017
2018 assert_eq!(metadata.source_file_path.as_deref(), Some("src/edge.rs"));
2019 }
2020
2021 #[test]
2022 fn projection_metadata_does_not_fallback_to_node_source_file_path() {
2023 let row = Row::from([
2024 ("provenance".to_string(), json!("EXTRACTED")),
2025 ("source_system".to_string(), json!("gcode")),
2026 ("source_file_path".to_string(), json!("src/node.rs")),
2027 ]);
2028
2029 let metadata = row_to_projection_metadata(&row).expect("metadata");
2030
2031 assert_eq!(metadata.source_file_path, None);
2032 }
2033
2034 #[test]
2035 fn delete_preserves_current_symbols() {
2036 let current_ids = vec!["symbol-current".to_string()];
2037 let queries =
2038 delete_file_graph_queries("project-1", "src/lib.rs", ¤t_ids).expect("queries");
2039
2040 let combined = queries
2041 .iter()
2042 .map(|query| query.cypher.as_str())
2043 .collect::<Vec<_>>()
2044 .join("\n");
2045
2046 assert!(
2047 combined.contains(
2048 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})-[r:CALLS]->()"
2049 ),
2050 "{combined}"
2051 );
2052 assert!(
2053 combined.contains("WHERE NOT s.id IN $symbol_ids"),
2054 "{combined}"
2055 );
2056 assert!(
2057 !combined.contains(
2058 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})\n DETACH DELETE s"
2059 ),
2060 "{combined}"
2061 );
2062
2063 let stale_symbol_cleanup = queries
2064 .iter()
2065 .find(|query| query.cypher.contains("WHERE NOT s.id IN $symbol_ids"))
2066 .expect("stale symbol cleanup query");
2067 assert_eq!(
2068 stale_symbol_cleanup
2069 .params
2070 .get("symbol_ids")
2071 .map(String::as_str),
2072 Some("['symbol-current']")
2073 );
2074 }
2075
2076 #[test]
2077 fn cleanup_orphans_is_project_scoped() {
2078 let queries = cleanup_orphans_queries("project-1").expect("queries");
2079 assert_eq!(queries.len(), 3);
2080
2081 for query in &queries {
2082 assert_eq!(
2083 query.params.get("project").map(String::as_str),
2084 Some("'project-1'")
2085 );
2086 assert!(
2087 query.cypher.contains("{project: $project}"),
2088 "{}",
2089 query.cypher
2090 );
2091 }
2092
2093 assert!(
2094 queries[0]
2095 .cypher
2096 .contains("MATCH (m:CodeModule {project: $project})"),
2097 "{}",
2098 queries[0].cypher
2099 );
2100 assert!(
2101 queries[1]
2102 .cypher
2103 .contains("WHERE (n:UnresolvedCallee OR n:ExternalSymbol)"),
2104 "{}",
2105 queries[1].cypher
2106 );
2107 assert!(
2108 queries[2]
2109 .cypher
2110 .contains("MATCH (s:CodeSymbol {project: $project})")
2111 && queries[2].cypher.contains("s.file_path IS NULL")
2112 && queries[2].cypher.contains("NOT ()-[:DEFINES]->(s)")
2113 && queries[2].cypher.contains("NOT ()-[:CALLS]->(s)")
2114 && queries[2].cypher.contains("NOT (s)-[:CALLS]->()"),
2115 "{}",
2116 queries[2].cypher
2117 );
2118 }
2119
2120 #[test]
2121 fn delete_file_node_is_project_and_path_scoped() {
2122 let query = delete_file_node_query("project-1", "src/lib.rs").expect("query");
2123
2124 assert!(
2125 query
2126 .cypher
2127 .contains("MATCH (f:CodeFile {path: $file_path, project: $project})"),
2128 "{}",
2129 query.cypher
2130 );
2131 assert!(query.cypher.contains("DETACH DELETE f"), "{}", query.cypher);
2132 assert_eq!(
2133 query.params.get("project").map(String::as_str),
2134 Some("'project-1'")
2135 );
2136 assert_eq!(
2137 query.params.get("file_path").map(String::as_str),
2138 Some("'src/lib.rs'")
2139 );
2140 }
2141
2142 #[test]
2143 fn clear_project_is_project_scoped() {
2144 let query = clear_project_query("project-1").expect("query");
2145
2146 assert!(query.cypher.contains("MATCH (n {project: $project})"));
2147 assert!(query.cypher.contains("n:CodeFile"));
2148 assert!(query.cypher.contains("n:CodeSymbol"));
2149 assert_eq!(
2150 query.params.get("project").map(String::as_str),
2151 Some("'project-1'")
2152 );
2153 }
2154
2155 #[test]
2156 fn clear_project_targets_only_code_index_labels() {
2157 let query = clear_project_query("project-1").expect("query");
2158
2159 for code_label in [
2160 "n:CodeFile",
2161 "n:CodeSymbol",
2162 "n:CodeModule",
2163 "n:UnresolvedCallee",
2164 "n:ExternalSymbol",
2165 ] {
2166 assert!(query.cypher.contains(code_label), "missing {code_label}");
2167 }
2168
2169 for memory_label in [
2170 "Memory",
2171 "MemoryNode",
2172 "MemoryGraph",
2173 "Entity",
2174 "Observation",
2175 "Relationship",
2176 "RELATES_TO_CODE",
2177 ] {
2178 assert!(
2179 !query.cypher.contains(memory_label),
2180 "code graph clear must not target memory label {memory_label}"
2181 );
2182 }
2183 }
2184
2185 #[test]
2186 fn clear_all_code_index_targets_only_code_index_labels() {
2187 let query = clear_all_code_index_query().expect("query");
2188
2189 assert!(query.cypher.contains("MATCH (n)"));
2190 assert!(query.cypher.contains("n:CodeFile"));
2191 assert!(query.cypher.contains("n:CodeSymbol"));
2192 assert!(query.cypher.contains("n:CodeModule"));
2193 assert!(query.cypher.contains("n:UnresolvedCallee"));
2194 assert!(query.cypher.contains("n:ExternalSymbol"));
2195 assert!(!query.cypher.contains("config_store"));
2196 assert!(!query.cypher.contains("MATCH (n {project: $project})"));
2197 assert!(query.params.is_empty());
2198 }
2199}