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