Skip to main content

gobby_code/graph/
code_graph.rs

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, &current_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                &center_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", &current_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}