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 metadata_source_file_path, \
45     r.source_line AS source_line, \
46     r.source_symbol_id AS source_symbol_id, \
47     r.matching_method AS matching_method";
48const MAX_GRAPH_LIMIT: usize = 100;
49const EXTRACTED_PROVENANCE: &str = "EXTRACTED";
50const SOURCE_SYSTEM_GCODE: &str = crate::models::SOURCE_SYSTEM_GCODE;
51
52pub struct CodeGraph<'a> {
53    project_id: &'a str,
54    client: &'a mut GraphClient,
55}
56
57impl<'a> CodeGraph<'a> {
58    pub fn new(project_id: &'a str, client: &'a mut GraphClient) -> Self {
59        Self { project_id, client }
60    }
61
62    pub fn sync_file(
63        &mut self,
64        file_path: &str,
65        imports: &[ImportRelation],
66        definitions: &[Symbol],
67        calls: &[CallRelation],
68    ) -> anyhow::Result<usize> {
69        self.ensure_file_node(file_path, definitions.len())?;
70        let current_symbol_ids = definitions
71            .iter()
72            .map(|symbol| symbol.id.clone())
73            .collect::<Vec<_>>();
74        self.delete_file_graph(file_path, &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_string(row, &["metadata_source_file_path"]);
1045    metadata.source_line = row
1046        .get("source_line")
1047        .or_else(|| row.get("line"))
1048        .and_then(|v| v.as_u64())
1049        .map(|line| line as usize);
1050    metadata.source_symbol_id = row
1051        .get("source_symbol_id")
1052        .or_else(|| row.get("caller_id"))
1053        .or_else(|| row.get("source_id"))
1054        .and_then(|v| v.as_str())
1055        .map(ToOwned::to_owned);
1056    metadata.matching_method = row
1057        .get("matching_method")
1058        .and_then(|v| v.as_str())
1059        .map(ToOwned::to_owned);
1060    Some(metadata)
1061}
1062
1063fn row_string(row: &Row, keys: &[&str]) -> Option<String> {
1064    row_string_owned(row, keys)
1065}
1066
1067fn row_string_owned(row: &Row, keys: &[&str]) -> Option<String> {
1068    keys.iter()
1069        .find_map(|key| row.get(*key).and_then(|value| value.as_str()))
1070        .filter(|value| !value.is_empty())
1071        .map(ToOwned::to_owned)
1072}
1073
1074fn row_usize(row: &Row, keys: &[&str]) -> Option<usize> {
1075    row_usize_owned(row, keys)
1076}
1077
1078fn row_usize_owned(row: &Row, keys: &[&str]) -> Option<usize> {
1079    keys.iter()
1080        .find_map(|key| row.get(*key))
1081        .and_then(|value| {
1082            value
1083                .as_u64()
1084                .or_else(|| value.as_i64().and_then(|value| value.try_into().ok()))
1085        })
1086        .map(|value| value as usize)
1087}
1088
1089fn add_link_from_row(payload: &mut GraphPayload, row: &Row) {
1090    let link = GraphLink::from_row(row);
1091    if link.source.is_empty() || link.target.is_empty() {
1092        return;
1093    }
1094    payload.links.push(link);
1095}
1096
1097fn add_node_from_row(payload: &mut GraphPayload, row: &Row, default_type: &str) {
1098    if let Some(node) = GraphNode::from_row(row, default_type) {
1099        payload.push_node(node);
1100    }
1101}
1102
1103fn add_prefixed_node_from_row(
1104    payload: &mut GraphPayload,
1105    row: &Row,
1106    prefix: &str,
1107    default_type: &str,
1108) {
1109    if let Some(node) = GraphNode::from_prefixed_row(row, prefix, default_type) {
1110        payload.push_node(node);
1111    }
1112}
1113
1114fn clamp_limit(limit: usize) -> usize {
1115    typed_query::clamp_limit(limit, MAX_GRAPH_LIMIT)
1116}
1117
1118fn clamp_offset(offset: usize) -> usize {
1119    typed_query::clamp_offset(offset, MAX_GRAPH_LIMIT)
1120}
1121
1122pub(crate) fn count_callers_query(
1123    project_id: &str,
1124    symbol_id: &str,
1125) -> (String, HashMap<String, String>) {
1126    (
1127        format!(
1128            "MATCH (caller:CodeSymbol {{project: $project}})-[:CALLS]->(target {{id: $id, project: $project}}) \
1129             WHERE {CALL_TARGET_PREDICATE} \
1130             RETURN count(caller) AS cnt"
1131        ),
1132        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1133    )
1134}
1135
1136pub(crate) fn count_usages_query(
1137    project_id: &str,
1138    symbol_id: &str,
1139) -> (String, HashMap<String, String>) {
1140    (
1141        format!(
1142            "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
1143             WHERE {CALL_TARGET_PREDICATE} \
1144             RETURN count(source) AS cnt"
1145        ),
1146        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1147    )
1148}
1149
1150pub(crate) fn find_callers_query(
1151    project_id: &str,
1152    symbol_id: &str,
1153    offset: usize,
1154    limit: usize,
1155) -> (String, HashMap<String, String>) {
1156    let offset = clamp_offset(offset);
1157    let limit = clamp_limit(limit);
1158    (
1159        format!(
1160            "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
1161             WHERE {CALL_TARGET_PREDICATE} \
1162             RETURN caller.id AS caller_id, caller.name AS caller_name, \
1163                    r.file AS file, r.line AS line \
1164             SKIP {offset} LIMIT {limit}"
1165        ),
1166        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1167    )
1168}
1169
1170pub(crate) fn find_usages_query(
1171    project_id: &str,
1172    symbol_id: &str,
1173    offset: usize,
1174    limit: usize,
1175) -> (String, HashMap<String, String>) {
1176    let offset = clamp_offset(offset);
1177    let limit = clamp_limit(limit);
1178    (
1179        format!(
1180            "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{id: $id, project: $project}}) \
1181             WHERE {CALL_TARGET_PREDICATE} \
1182             RETURN source.id AS source_id, source.name AS source_name, \
1183                    'CALLS' AS rel_type, r.file AS file, r.line AS line \
1184             SKIP {offset} LIMIT {limit}"
1185        ),
1186        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1187    )
1188}
1189
1190pub(crate) fn find_callers_batch_query(
1191    project_id: &str,
1192    symbol_ids: &[String],
1193    limit: usize,
1194) -> (String, HashMap<String, String>) {
1195    let limit = clamp_limit(limit);
1196    let ids = typed_query::id_list_literal(symbol_ids);
1197    (
1198        format!(
1199            "MATCH (caller:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1200             WHERE ({CALL_TARGET_PREDICATE}) AND target.id IN [{ids}] \
1201             RETURN caller.id AS caller_id, caller.name AS caller_name, \
1202                    r.file AS file, r.line AS line \
1203             LIMIT {limit}"
1204        ),
1205        typed_query::string_params(&[("project", project_id)]),
1206    )
1207}
1208
1209pub(crate) fn find_callees_batch_query(
1210    project_id: &str,
1211    symbol_ids: &[String],
1212    limit: usize,
1213) -> (String, HashMap<String, String>) {
1214    let limit = clamp_limit(limit);
1215    let ids = typed_query::id_list_literal(symbol_ids);
1216    (
1217        format!(
1218            "MATCH (src:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1219             WHERE src.id IN [{ids}] AND ({CALL_TARGET_PREDICATE}) \
1220             RETURN target.id AS callee_id, target.name AS callee_name, \
1221                    r.file AS file, r.line AS line \
1222             LIMIT {limit}"
1223        ),
1224        typed_query::string_params(&[("project", project_id)]),
1225    )
1226}
1227
1228pub(crate) fn get_imports_query(
1229    project_id: &str,
1230    file_path: &str,
1231) -> (String, HashMap<String, String>) {
1232    (
1233        "MATCH (f:CodeFile {path: $path, project: $project})-[:IMPORTS]->(m:CodeModule) \
1234         RETURN m.name AS module_name"
1235            .to_string(),
1236        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1237    )
1238}
1239
1240pub(crate) fn blast_radius_query(depth: usize, limit: usize) -> String {
1241    let depth = depth.clamp(1, 5);
1242    let limit = clamp_limit(limit);
1243    format!(
1244        "MATCH (target {{id: $id, project: $project}}) \
1245         WHERE {CALL_TARGET_PREDICATE} \
1246         MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target) \
1247         WITH affected, min(length(path)) AS distance \
1248         OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
1249         RETURN DISTINCT affected.id AS node_id, \
1250                affected.name AS node_name, \
1251                affected.kind AS kind, file.path AS file_path, \
1252                affected.line_start AS line, \
1253                distance, 'call' AS rel_type \
1254         ORDER BY distance ASC, affected.name ASC \
1255         LIMIT {limit}"
1256    )
1257}
1258
1259fn project_overview_files_query(
1260    project_id: &str,
1261    limit: usize,
1262) -> (String, HashMap<String, String>) {
1263    let limit = clamp_limit(limit);
1264    (
1265        format!(
1266            "MATCH (f:CodeFile {{project: $project}}) \
1267             OPTIONAL MATCH (f)-[:DEFINES]->(s:CodeSymbol) \
1268             WITH f, count(DISTINCT s) AS sym_count \
1269             OPTIONAL MATCH (f)-[:IMPORTS]->(m:CodeModule) \
1270             WITH f, sym_count, count(m) AS imp_count \
1271             RETURN f.path AS id, f.path AS name, 'file' AS type, \
1272                    f.path AS file_path, sym_count AS symbol_count \
1273             ORDER BY imp_count DESC, sym_count DESC, f.path \
1274             LIMIT {limit}"
1275        ),
1276        typed_query::string_params(&[("project", project_id)]),
1277    )
1278}
1279
1280fn project_overview_imports_query(
1281    project_id: &str,
1282    file_paths: &[String],
1283    limit: usize,
1284) -> (String, HashMap<String, String>) {
1285    let limit = clamp_limit(limit);
1286    let file_paths = typed_query::id_list_literal(file_paths);
1287    (
1288        format!(
1289            "MATCH (f:CodeFile {{project: $project}})-[r:IMPORTS]->(m:CodeModule {{project: $project}}) \
1290             WHERE f.path IN [{file_paths}] \
1291             RETURN f.path AS source, m.name AS target, 'IMPORTS' AS type, {LINK_METADATA_RETURN} \
1292             LIMIT {limit}"
1293        ),
1294        typed_query::string_params(&[("project", project_id)]),
1295    )
1296}
1297
1298fn project_overview_defines_query(
1299    project_id: &str,
1300    file_paths: &[String],
1301    limit: usize,
1302) -> (String, HashMap<String, String>) {
1303    let limit = clamp_limit(limit);
1304    let file_paths = typed_query::id_list_literal(file_paths);
1305    (
1306        format!(
1307            "MATCH (f:CodeFile {{project: $project}})-[r:DEFINES]->(s:CodeSymbol {{project: $project}}) \
1308             WHERE f.path IN [{file_paths}] \
1309             RETURN f.path AS source, s.id AS target, 'DEFINES' AS type, \
1310                    s.name AS symbol_name, s.kind AS symbol_kind, \
1311                    s.file_path AS symbol_file_path, s.line_start AS line_start, \
1312                    {LINK_METADATA_RETURN} \
1313             LIMIT {limit}"
1314        ),
1315        typed_query::string_params(&[("project", project_id)]),
1316    )
1317}
1318
1319fn project_overview_calls_query(
1320    project_id: &str,
1321    file_paths: &[String],
1322    limit: usize,
1323) -> (String, HashMap<String, String>) {
1324    let limit = clamp_limit(limit);
1325    let file_paths = typed_query::id_list_literal(file_paths);
1326    (
1327        format!(
1328            "MATCH (f:CodeFile {{project: $project}})-[:DEFINES]->(s:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1329             WHERE f.path IN [{file_paths}] AND ({CALL_TARGET_PREDICATE}) \
1330             RETURN s.id AS source, target.id AS target, 'CALLS' AS type, \
1331                    target.name AS target_name, {TARGET_TYPE_CASE} AS target_type, \
1332                    target.kind AS target_kind, target.file_path AS target_file_path, \
1333                    target.line_start AS target_line_start, r.line AS line, \
1334                    {LINK_METADATA_RETURN} \
1335             LIMIT {limit}"
1336        ),
1337        typed_query::string_params(&[("project", project_id)]),
1338    )
1339}
1340
1341fn file_symbols_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
1342    (
1343        format!(
1344            "MATCH (:CodeFile {{path: $path, project: $project}})-[r:DEFINES]->(s:CodeSymbol {{project: $project}}) \
1345             RETURN s.id AS id, s.name AS name, coalesce(s.kind, 'function') AS type, \
1346                    s.kind AS kind, s.file_path AS file_path, \
1347                    s.line_start AS line_start, s.signature AS signature, \
1348                    {LINK_METADATA_RETURN}"
1349        ),
1350        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1351    )
1352}
1353
1354fn file_calls_query(project_id: &str, file_path: &str) -> (String, HashMap<String, String>) {
1355    (
1356        format!(
1357            "MATCH (source:CodeSymbol {{project: $project}})-[r:CALLS]->(target {{project: $project}}) \
1358             WHERE ({CALL_TARGET_PREDICATE}) \
1359               AND (source.file_path = $path OR (target:CodeSymbol AND target.file_path = $path)) \
1360             RETURN source.id AS source_id, source.name AS source_name, \
1361                    coalesce(source.kind, 'function') AS source_type, \
1362                    source.kind AS source_kind, source.file_path AS source_file_path, \
1363                    source.line_start AS source_line_start, source.signature AS source_signature, \
1364                    target.id AS target_id, target.name AS target_name, \
1365                    {TARGET_TYPE_CASE} AS target_type, target.kind AS target_kind, \
1366                    target.file_path AS target_file_path, \
1367                    target.line_start AS target_line_start, target.signature AS target_signature, \
1368                    source.id AS source, target.id AS target, 'CALLS' AS type, r.line AS line, \
1369                    {LINK_METADATA_RETURN}"
1370        ),
1371        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1372    )
1373}
1374
1375fn symbol_neighbors_query(
1376    project_id: &str,
1377    symbol_id: &str,
1378    limit: usize,
1379) -> (String, HashMap<String, String>) {
1380    let limit = clamp_limit(limit);
1381    (
1382        format!(
1383            "MATCH (center {{id: $id, project: $project}}) \
1384             WHERE center:CodeSymbol OR center:UnresolvedCallee OR center:ExternalSymbol \
1385             MATCH (center)-[r:CALLS]-(neighbor {{project: $project}}) \
1386             WHERE {NEIGHBOR_PREDICATE} \
1387             RETURN neighbor.id AS id, neighbor.name AS name, {NEIGHBOR_TYPE_CASE} AS type, \
1388                    neighbor.kind AS kind, neighbor.file_path AS file_path, \
1389                    neighbor.line_start AS line_start, neighbor.signature AS signature, \
1390                    CASE WHEN startNode(r) = center THEN 'outgoing' ELSE 'incoming' END AS direction, \
1391                    r.line AS line, {LINK_METADATA_RETURN} \
1392             LIMIT {limit}"
1393        ),
1394        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1395    )
1396}
1397
1398fn blast_radius_center_query(
1399    project_id: &str,
1400    symbol_id: &str,
1401) -> (String, HashMap<String, String>) {
1402    (
1403        format!(
1404            "MATCH (n {{id: $id, project: $project}}) \
1405             WHERE n:CodeSymbol OR n:UnresolvedCallee OR n:ExternalSymbol \
1406             RETURN n.id AS id, n.name AS name, {NODE_TYPE_CASE} AS type, \
1407                    n.kind AS kind, n.file_path AS file_path \
1408             LIMIT 1"
1409        ),
1410        typed_query::string_params(&[("project", project_id), ("id", symbol_id)]),
1411    )
1412}
1413
1414fn blast_radius_file_call_query(
1415    project_id: &str,
1416    file_path: &str,
1417    depth: usize,
1418    limit: usize,
1419) -> (String, HashMap<String, String>) {
1420    let depth = depth.clamp(1, 5);
1421    let limit = clamp_limit(limit);
1422    (
1423        format!(
1424            "MATCH (tf:CodeFile {{path: $path, project: $project}})-[:DEFINES]->(target_sym:CodeSymbol {{project: $project}}) \
1425             MATCH path = (affected:CodeSymbol {{project: $project}})-[:CALLS*1..{depth}]->(target_sym) \
1426             WITH affected, min(length(path)) AS distance \
1427             OPTIONAL MATCH (file:CodeFile {{project: $project}})-[:DEFINES]->(affected) \
1428             RETURN DISTINCT affected.id AS node_id, \
1429                    affected.name AS node_name, \
1430                    affected.kind AS kind, file.path AS file_path, \
1431                    affected.line_start AS line, distance, 'call' AS rel_type, \
1432                    coalesce(affected.kind, 'function') AS node_type \
1433             ORDER BY distance ASC, affected.name ASC \
1434             LIMIT {limit}"
1435        ),
1436        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1437    )
1438}
1439
1440fn blast_radius_file_import_query(
1441    project_id: &str,
1442    file_path: &str,
1443    depth: usize,
1444    limit: usize,
1445) -> (String, HashMap<String, String>) {
1446    let depth = depth.clamp(1, 5);
1447    let limit = clamp_limit(limit);
1448    (
1449        format!(
1450            "MATCH (tf:CodeFile {{path: $path, project: $project}})-[:IMPORTS]->(m:CodeModule {{project: $project}}) \
1451             MATCH path = (importer:CodeFile {{project: $project}})-[:IMPORTS*1..{depth}]->(m) \
1452             WHERE importer.path <> $path \
1453             WITH importer, min(length(path)) AS distance \
1454             RETURN DISTINCT importer.path AS node_id, \
1455                    importer.path AS node_name, NULL AS kind, importer.path AS file_path, \
1456                    NULL AS line, distance, 'import' AS rel_type, 'file' AS node_type \
1457             ORDER BY distance ASC \
1458             LIMIT {limit}"
1459        ),
1460        typed_query::string_params(&[("project", project_id), ("path", file_path)]),
1461    )
1462}
1463
1464fn count_from_rows(rows: &[Row]) -> usize {
1465    rows.first()
1466        .and_then(|r| r.get("cnt"))
1467        .and_then(|v| {
1468            v.as_u64()
1469                .or_else(|| v.as_i64().and_then(|value| value.try_into().ok()))
1470        })
1471        .unwrap_or(0) as usize
1472}
1473
1474pub fn require_graph_reads(ctx: &Context) -> anyhow::Result<()> {
1475    if ctx.falkordb.is_none() {
1476        return Err(GraphReadError::NotConfigured.into());
1477    }
1478    Ok(())
1479}
1480
1481fn with_required_core_graph<T>(
1482    ctx: &Context,
1483    f: impl FnOnce(&mut GraphClient) -> anyhow::Result<T>,
1484) -> anyhow::Result<T> {
1485    let config = ctx.falkordb.as_ref().ok_or(GraphReadError::NotConfigured)?;
1486    let connection_config = config.connection_config();
1487    match gobby_core::falkor::with_graph(
1488        Some(&connection_config),
1489        &config.graph_name,
1490        None,
1491        |client| f(client).map(Some),
1492    ) {
1493        Ok((Some(value), ServiceState::Available)) => Ok(value),
1494        Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
1495        Ok((_, ServiceState::Unreachable { message })) => {
1496            Err(GraphReadError::Unreachable { message }.into())
1497        }
1498        Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
1499            message: "graph read returned no value".to_string(),
1500        }
1501        .into()),
1502        Err(error) => Err(GraphReadError::QueryFailed {
1503            message: error.to_string(),
1504        }
1505        .into()),
1506    }
1507}
1508
1509pub fn project_overview_graph(ctx: &Context, limit: usize) -> anyhow::Result<GraphPayload> {
1510    with_required_core_graph(ctx, |client| {
1511        let limit = clamp_limit(limit);
1512        let link_limit = clamp_limit(limit.saturating_mul(4));
1513        let max_nodes = limit.saturating_mul(8);
1514
1515        let (query, params) = project_overview_files_query(&ctx.project_id, limit);
1516        let file_rows = client.query(&query, Some(params))?;
1517        let mut payload = GraphPayload::default();
1518        for row in &file_rows {
1519            add_node_from_row(&mut payload, row, "file");
1520        }
1521
1522        let file_paths = payload
1523            .nodes
1524            .iter()
1525            .filter(|node| node.node_type == "file")
1526            .map(|node| node.id.clone())
1527            .collect::<Vec<_>>();
1528        if file_paths.is_empty() {
1529            return Ok(payload);
1530        }
1531
1532        let (query, params) =
1533            project_overview_imports_query(&ctx.project_id, &file_paths, link_limit);
1534        for row in client.query(&query, Some(params))? {
1535            add_link_from_row(&mut payload, &row);
1536            if let Some(module_id) = row_string(&row, &["target"]) {
1537                payload.push_node(GraphNode::new(module_id.clone(), module_id, "module"));
1538            }
1539            if payload.nodes.len() >= max_nodes {
1540                break;
1541            }
1542        }
1543
1544        let (query, params) =
1545            project_overview_defines_query(&ctx.project_id, &file_paths, link_limit);
1546        for row in client.query(&query, Some(params))? {
1547            add_link_from_row(&mut payload, &row);
1548            if let Some(symbol_id) = row_string(&row, &["target"]) {
1549                let mut node = GraphNode::new(
1550                    symbol_id.clone(),
1551                    row_string(&row, &["symbol_name"]).unwrap_or(symbol_id),
1552                    row_string(&row, &["symbol_kind"]).unwrap_or_else(|| "function".to_string()),
1553                );
1554                node.kind = row_string(&row, &["symbol_kind"]);
1555                node.file_path = row_string(&row, &["symbol_file_path", "source"]);
1556                node.line_start = row_usize(&row, &["line_start"]);
1557                payload.push_node(node);
1558            }
1559            if payload.nodes.len() >= max_nodes {
1560                break;
1561            }
1562        }
1563
1564        let (query, params) =
1565            project_overview_calls_query(&ctx.project_id, &file_paths, link_limit);
1566        for row in client.query(&query, Some(params))? {
1567            add_link_from_row(&mut payload, &row);
1568            if let Some(target_id) = row_string(&row, &["target"]) {
1569                let mut node = GraphNode::new(
1570                    target_id.clone(),
1571                    row_string(&row, &["target_name"]).unwrap_or(target_id),
1572                    row_string(&row, &["target_type"]).unwrap_or_else(|| "unresolved".to_string()),
1573                );
1574                node.kind = row_string(&row, &["target_kind"]);
1575                node.file_path = row_string(&row, &["target_file_path"]);
1576                node.line_start = row_usize(&row, &["target_line_start"]);
1577                payload.push_node(node);
1578            }
1579            if payload.nodes.len() >= max_nodes {
1580                break;
1581            }
1582        }
1583
1584        Ok(payload)
1585    })
1586}
1587
1588pub fn file_graph(ctx: &Context, file_path: &str) -> anyhow::Result<GraphPayload> {
1589    with_required_core_graph(ctx, |client| {
1590        let mut payload = GraphPayload::default();
1591        let (query, params) = file_symbols_query(&ctx.project_id, file_path);
1592        for row in client.query(&query, Some(params))? {
1593            add_node_from_row(&mut payload, &row, "function");
1594            if let Some(symbol_id) = row_string(&row, &["id"]) {
1595                let mut link = GraphLink::new(file_path, symbol_id, "DEFINES");
1596                link.metadata = row_to_projection_metadata(&row);
1597                payload.links.push(link);
1598            }
1599        }
1600
1601        let (query, params) = file_calls_query(&ctx.project_id, file_path);
1602        for row in client.query(&query, Some(params))? {
1603            add_prefixed_node_from_row(&mut payload, &row, "source", "function");
1604            add_prefixed_node_from_row(&mut payload, &row, "target", "unresolved");
1605            add_link_from_row(&mut payload, &row);
1606        }
1607
1608        Ok(payload)
1609    })
1610}
1611
1612pub fn symbol_neighbors(
1613    ctx: &Context,
1614    symbol_id: &str,
1615    limit: usize,
1616) -> anyhow::Result<GraphPayload> {
1617    with_required_core_graph(ctx, |client| {
1618        let (query, params) = symbol_neighbors_query(&ctx.project_id, symbol_id, limit);
1619        let rows = client.query(&query, Some(params))?;
1620        let mut payload = GraphPayload::default();
1621
1622        for row in rows {
1623            add_node_from_row(&mut payload, &row, "unresolved");
1624            let Some(neighbor_id) = row_string(&row, &["id"]) else {
1625                continue;
1626            };
1627            let direction = row_string(&row, &["direction"]).unwrap_or_default();
1628            let mut link = if direction == "outgoing" {
1629                GraphLink::new(symbol_id, neighbor_id, "CALLS")
1630            } else {
1631                GraphLink::new(neighbor_id, symbol_id, "CALLS")
1632            };
1633            link.line = row_usize(&row, &["line"]);
1634            link.metadata = row_to_projection_metadata(&row);
1635            payload.links.push(link);
1636        }
1637
1638        Ok(payload)
1639    })
1640}
1641
1642pub fn blast_radius_graph(
1643    ctx: &Context,
1644    target: GraphBlastRadiusTarget,
1645    depth: usize,
1646    limit: usize,
1647) -> anyhow::Result<GraphPayload> {
1648    with_required_core_graph(ctx, |client| {
1649        let (center_id, mut center_node, rows) = match target {
1650            GraphBlastRadiusTarget::SymbolId(symbol_id) => {
1651                let (query, params) = blast_radius_center_query(&ctx.project_id, &symbol_id);
1652                let center_rows = client.query(&query, Some(params))?;
1653                let center_node = center_rows
1654                    .first()
1655                    .and_then(|row| GraphNode::from_row(row, "function"))
1656                    .unwrap_or_else(|| GraphNode::new(&symbol_id, &symbol_id, "function"));
1657
1658                let query = blast_radius_query(depth, limit);
1659                let params =
1660                    typed_query::string_params(&[("project", &ctx.project_id), ("id", &symbol_id)]);
1661                (symbol_id, center_node, client.query(&query, Some(params))?)
1662            }
1663            GraphBlastRadiusTarget::FilePath(file_path) => {
1664                let mut rows = vec![];
1665                let (query, params) =
1666                    blast_radius_file_call_query(&ctx.project_id, &file_path, depth, limit);
1667                rows.extend(client.query(&query, Some(params))?);
1668                let (query, params) =
1669                    blast_radius_file_import_query(&ctx.project_id, &file_path, depth, limit);
1670                rows.extend(client.query(&query, Some(params))?);
1671                (
1672                    file_path.clone(),
1673                    GraphNode::new(&file_path, &file_path, "file"),
1674                    rows,
1675                )
1676            }
1677        };
1678
1679        center_node.blast_distance = Some(0);
1680        let mut payload = GraphPayload::with_center(center_id.clone());
1681        payload.push_node(center_node);
1682
1683        for row in rows {
1684            let Some(node_id) = row_string(&row, &["node_id"]) else {
1685                continue;
1686            };
1687            let mut node = GraphNode::new(
1688                node_id.clone(),
1689                row_string(&row, &["node_name"]).unwrap_or_else(|| node_id.clone()),
1690                row_string(&row, &["node_type"]).unwrap_or_else(|| "function".to_string()),
1691            );
1692            node.kind = row_string(&row, &["kind"]);
1693            node.file_path = row_string(&row, &["file_path"]);
1694            node.line_start = row_usize(&row, &["line"]);
1695            node.blast_distance = row_usize(&row, &["distance"]);
1696            payload.push_node(node);
1697
1698            let relation = row_string(&row, &["rel_type"]).unwrap_or_else(|| "call".to_string());
1699            let mut link = GraphLink::new(
1700                node_id,
1701                &center_id,
1702                if relation == "call" {
1703                    "CALLS"
1704                } else {
1705                    "IMPORTS"
1706                },
1707            );
1708            link.distance = row_usize(&row, &["distance"]);
1709            link.metadata = row_to_projection_metadata(&row);
1710            payload.links.push(link);
1711        }
1712
1713        Ok(payload)
1714    })
1715}
1716
1717pub fn count_callers(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
1718    with_required_core_graph(ctx, |client| {
1719        let (query, params) = count_callers_query(&ctx.project_id, symbol_id);
1720        let rows = client.query(&query, Some(params))?;
1721        Ok(count_from_rows(&rows))
1722    })
1723}
1724
1725pub fn count_usages(ctx: &Context, symbol_id: &str) -> anyhow::Result<usize> {
1726    with_required_core_graph(ctx, |client| {
1727        let (query, params) = count_usages_query(&ctx.project_id, symbol_id);
1728        let rows = client.query(&query, Some(params))?;
1729        Ok(count_from_rows(&rows))
1730    })
1731}
1732
1733pub fn find_callers(
1734    ctx: &Context,
1735    symbol_id: &str,
1736    offset: usize,
1737    limit: usize,
1738) -> anyhow::Result<Vec<GraphResult>> {
1739    with_required_core_graph(ctx, |client| {
1740        let (query, params) = find_callers_query(&ctx.project_id, symbol_id, offset, limit);
1741        let rows = client.query(&query, Some(params))?;
1742        Ok(rows.iter().map(row_to_graph_result).collect())
1743    })
1744}
1745
1746pub fn find_usages(
1747    ctx: &Context,
1748    symbol_id: &str,
1749    offset: usize,
1750    limit: usize,
1751) -> anyhow::Result<Vec<GraphResult>> {
1752    with_required_core_graph(ctx, |client| {
1753        let (query, params) = find_usages_query(&ctx.project_id, symbol_id, offset, limit);
1754        let rows = client.query(&query, Some(params))?;
1755        Ok(rows.iter().map(row_to_graph_result).collect())
1756    })
1757}
1758
1759pub fn find_callers_batch(
1760    ctx: &Context,
1761    symbol_ids: &[String],
1762    limit: usize,
1763) -> anyhow::Result<Vec<GraphResult>> {
1764    if symbol_ids.is_empty() {
1765        return Ok(vec![]);
1766    }
1767    with_required_core_graph(ctx, |client| {
1768        let (query, params) = find_callers_batch_query(&ctx.project_id, symbol_ids, limit);
1769        let rows = client.query(&query, Some(params))?;
1770        Ok(rows.iter().map(row_to_graph_result).collect())
1771    })
1772}
1773
1774pub fn find_callees_batch(
1775    ctx: &Context,
1776    symbol_ids: &[String],
1777    limit: usize,
1778) -> anyhow::Result<Vec<GraphResult>> {
1779    if symbol_ids.is_empty() {
1780        return Ok(vec![]);
1781    }
1782    with_required_core_graph(ctx, |client| {
1783        let (query, params) = find_callees_batch_query(&ctx.project_id, symbol_ids, limit);
1784        let rows = client.query(&query, Some(params))?;
1785        Ok(rows.iter().map(row_to_graph_result).collect())
1786    })
1787}
1788
1789pub fn get_imports(ctx: &Context, file_path: &str) -> anyhow::Result<Vec<GraphResult>> {
1790    with_required_core_graph(ctx, |client| {
1791        let (query, params) = get_imports_query(&ctx.project_id, file_path);
1792        let rows = client.query(&query, Some(params))?;
1793        Ok(rows.iter().map(row_to_graph_result).collect())
1794    })
1795}
1796
1797pub fn blast_radius(
1798    ctx: &Context,
1799    symbol_id: &str,
1800    depth: usize,
1801) -> anyhow::Result<Vec<GraphResult>> {
1802    with_required_core_graph(ctx, |client| {
1803        let query = blast_radius_query(depth, MAX_GRAPH_LIMIT);
1804        let params = typed_query::string_params(&[("project", &ctx.project_id), ("id", symbol_id)]);
1805        let rows = client.query(&query, Some(params))?;
1806        Ok(rows.iter().map(row_to_graph_result).collect())
1807    })
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812    use super::*;
1813    use crate::models::{ProjectionProvenance, SOURCE_SYSTEM_GCODE};
1814    use serde_json::json;
1815
1816    #[test]
1817    fn code_edges_carry_provenance() {
1818        let metadata = extracted_code_edge_metadata("src/lib.rs", 42, Some("caller-1"));
1819
1820        assert_eq!(metadata.provenance, ProjectionProvenance::Extracted);
1821        assert_eq!(metadata.confidence, Some(1.0));
1822        assert_eq!(metadata.source_system, SOURCE_SYSTEM_GCODE);
1823        assert_eq!(metadata.source_file_path.as_deref(), Some("src/lib.rs"));
1824        assert_eq!(metadata.source_line, Some(42));
1825        assert_eq!(metadata.source_symbol_id.as_deref(), Some("caller-1"));
1826    }
1827
1828    #[test]
1829    fn read_apis_return_node_link_payloads_with_link_metadata() {
1830        let mut payload = GraphPayload::default();
1831        payload.push_node(GraphNode::new("src/lib.rs", "src/lib.rs", "file"));
1832
1833        let link_row = Row::from([
1834            ("source".to_string(), json!("src/lib.rs")),
1835            ("target".to_string(), json!("symbol-1")),
1836            ("type".to_string(), json!("DEFINES")),
1837            ("line".to_string(), json!(12)),
1838            ("provenance".to_string(), json!("EXTRACTED")),
1839            ("confidence".to_string(), json!(1.0)),
1840            ("source_system".to_string(), json!("gcode")),
1841            ("source_file_path".to_string(), json!("src/lib.rs")),
1842            ("source_line".to_string(), json!(12)),
1843            ("source_symbol_id".to_string(), json!("symbol-1")),
1844        ]);
1845        payload.links.push(GraphLink::from_row(&link_row));
1846
1847        let encoded = serde_json::to_value(&payload).expect("payload serializes");
1848
1849        assert_eq!(encoded["nodes"][0]["id"], "src/lib.rs");
1850        assert_eq!(encoded["nodes"][0]["type"], "file");
1851        assert_eq!(encoded["links"][0]["source"], "src/lib.rs");
1852        assert_eq!(encoded["links"][0]["target"], "symbol-1");
1853        assert_eq!(encoded["links"][0]["type"], "DEFINES");
1854        assert_eq!(encoded["links"][0]["metadata"]["provenance"], "EXTRACTED");
1855        assert_eq!(encoded["links"][0]["metadata"]["source_system"], "gcode");
1856    }
1857
1858    #[test]
1859    fn file_calls_query_keeps_node_and_metadata_source_paths_distinct() {
1860        let (query, _) = file_calls_query("project-1", "src/lib.rs");
1861
1862        assert!(query.contains("source.file_path AS source_file_path"));
1863        assert!(query.contains("r.source_file_path AS metadata_source_file_path"));
1864        assert!(!query.contains("r.source_file_path AS source_file_path"));
1865    }
1866
1867    #[test]
1868    fn projection_metadata_uses_only_metadata_source_file_path() {
1869        let row = Row::from([
1870            ("provenance".to_string(), json!("EXTRACTED")),
1871            ("source_system".to_string(), json!("gcode")),
1872            ("source_file_path".to_string(), json!("src/node.rs")),
1873            (
1874                "metadata_source_file_path".to_string(),
1875                json!("src/edge.rs"),
1876            ),
1877        ]);
1878
1879        let metadata = row_to_projection_metadata(&row).expect("metadata");
1880
1881        assert_eq!(metadata.source_file_path.as_deref(), Some("src/edge.rs"));
1882    }
1883
1884    #[test]
1885    fn projection_metadata_does_not_fallback_to_node_source_file_path() {
1886        let row = Row::from([
1887            ("provenance".to_string(), json!("EXTRACTED")),
1888            ("source_system".to_string(), json!("gcode")),
1889            ("source_file_path".to_string(), json!("src/node.rs")),
1890        ]);
1891
1892        let metadata = row_to_projection_metadata(&row).expect("metadata");
1893
1894        assert_eq!(metadata.source_file_path, None);
1895    }
1896
1897    #[test]
1898    fn delete_preserves_current_symbols() {
1899        let current_ids = vec!["symbol-current".to_string()];
1900        let queries =
1901            delete_file_graph_queries("project-1", "src/lib.rs", &current_ids).expect("queries");
1902
1903        let combined = queries
1904            .iter()
1905            .map(|query| query.cypher.as_str())
1906            .collect::<Vec<_>>()
1907            .join("\n");
1908
1909        assert!(
1910            combined.contains(
1911                "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})-[r:CALLS]->()"
1912            ),
1913            "{combined}"
1914        );
1915        assert!(
1916            combined.contains("WHERE NOT s.id IN $symbol_ids"),
1917            "{combined}"
1918        );
1919        assert!(
1920            !combined.contains(
1921                "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})\n                DETACH DELETE s"
1922            ),
1923            "{combined}"
1924        );
1925
1926        let stale_symbol_cleanup = queries
1927            .iter()
1928            .find(|query| query.cypher.contains("WHERE NOT s.id IN $symbol_ids"))
1929            .expect("stale symbol cleanup query");
1930        assert_eq!(
1931            stale_symbol_cleanup
1932                .params
1933                .get("symbol_ids")
1934                .map(String::as_str),
1935            Some("['symbol-current']")
1936        );
1937    }
1938
1939    #[test]
1940    fn cleanup_orphans_is_project_scoped() {
1941        let queries = cleanup_orphans_queries("project-1").expect("queries");
1942        assert_eq!(queries.len(), 3);
1943
1944        for query in &queries {
1945            assert_eq!(
1946                query.params.get("project").map(String::as_str),
1947                Some("'project-1'")
1948            );
1949            assert!(
1950                query.cypher.contains("{project: $project}"),
1951                "{}",
1952                query.cypher
1953            );
1954        }
1955
1956        assert!(
1957            queries[0]
1958                .cypher
1959                .contains("MATCH (m:CodeModule {project: $project})"),
1960            "{}",
1961            queries[0].cypher
1962        );
1963        assert!(
1964            queries[1]
1965                .cypher
1966                .contains("WHERE (n:UnresolvedCallee OR n:ExternalSymbol)"),
1967            "{}",
1968            queries[1].cypher
1969        );
1970        assert!(
1971            queries[2]
1972                .cypher
1973                .contains("MATCH (s:CodeSymbol {project: $project})")
1974                && queries[2].cypher.contains("s.file_path IS NULL")
1975                && queries[2].cypher.contains("NOT ()-[:DEFINES]->(s)")
1976                && queries[2].cypher.contains("NOT ()-[:CALLS]->(s)")
1977                && queries[2].cypher.contains("NOT (s)-[:CALLS]->()"),
1978            "{}",
1979            queries[2].cypher
1980        );
1981    }
1982
1983    #[test]
1984    fn clear_project_is_project_scoped() {
1985        let query = clear_project_query("project-1").expect("query");
1986
1987        assert!(query.cypher.contains("MATCH (n {project: $project})"));
1988        assert!(query.cypher.contains("n:CodeFile"));
1989        assert!(query.cypher.contains("n:CodeSymbol"));
1990        assert_eq!(
1991            query.params.get("project").map(String::as_str),
1992            Some("'project-1'")
1993        );
1994    }
1995
1996    #[test]
1997    fn clear_all_code_index_targets_only_code_index_labels() {
1998        let query = clear_all_code_index_query().expect("query");
1999
2000        assert!(query.cypher.contains("MATCH (n)"));
2001        assert!(query.cypher.contains("n:CodeFile"));
2002        assert!(query.cypher.contains("n:CodeSymbol"));
2003        assert!(query.cypher.contains("n:CodeModule"));
2004        assert!(query.cypher.contains("n:UnresolvedCallee"));
2005        assert!(query.cypher.contains("n:ExternalSymbol"));
2006        assert!(!query.cypher.contains("config_store"));
2007        assert!(!query.cypher.contains("MATCH (n {project: $project})"));
2008        assert!(query.params.is_empty());
2009    }
2010}