Skip to main content

gobby_code/graph/code_graph/
write.rs

1//! Code-index graph projection writes.
2//!
3//! This is the intentional exception to the broader "Gobby-owned stores are
4//! externally managed" rule: `gcode` owns the code-index graph projection and
5//! writes FalkorDB `Code*` nodes/edges derived from its PostgreSQL index rows.
6
7use std::collections::BTreeMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use anyhow::Context as _;
12
13use crate::config::Context;
14use crate::graph::typed_query::{TypedQuery, TypedValue};
15use crate::index::import_resolution::UNPARSED_IMPORT_PREFIX;
16use crate::models::{
17    CallRelation, CallTargetKind, ImportRelation, Symbol, make_external_symbol_id,
18    make_unresolved_callee_id,
19};
20use gobby_core::degradation::ServiceState;
21use gobby_core::falkor::GraphClient;
22
23use super::GraphReadError;
24use super::connection::with_required_core_graph;
25
26const PROJECT_NODE_PREDICATE: &str =
27    "n:CodeFile OR n:CodeSymbol OR n:CodeModule OR n:UnresolvedCallee OR n:ExternalSymbol";
28const EXTRACTED_PROVENANCE: &str = "EXTRACTED";
29const SOURCE_SYSTEM_GCODE: &str = crate::models::SOURCE_SYSTEM_GCODE;
30const PROJECT_INDEXED_LABELS: &[&str] = &[
31    "CodeFile",
32    "CodeSymbol",
33    "CodeModule",
34    "UnresolvedCallee",
35    "ExternalSymbol",
36];
37static SYNC_TOKEN_COUNTER: AtomicU64 = AtomicU64::new(0);
38const ADD_IMPORTS_CYPHER: &str = "UNWIND $imports AS import
39         MERGE (f:CodeFile {path: import.source_file, project: $project})
40         MERGE (m:CodeModule {name: import.target_module, project: $project})
41         MERGE (f)-[r:IMPORTS]->(m)
42         SET r.provenance = $provenance,
43             r.confidence = $confidence,
44             r.source_system = $source_system,
45             r.source_file_path = import.source_file,
46             r.sync_token = $sync_token";
47const ADD_DEFINITIONS_CYPHER: &str = "UNWIND $symbols AS symbol
48         MERGE (f:CodeFile {path: $file_path, project: $project})
49         MERGE (s:CodeSymbol {id: symbol.id, project: $project})
50         SET s.name = symbol.name,
51             s.qualified_name = symbol.qualified_name,
52             s.kind = symbol.kind,
53             s.language = symbol.language,
54             s.file_path = $file_path,
55             s.line_start = symbol.line_start,
56             s.line_end = symbol.line_end,
57             s.updated_at = timestamp(),
58             s.sync_token = $sync_token
59         MERGE (f)-[r:DEFINES]->(s)
60         SET r.provenance = $provenance,
61             r.confidence = $confidence,
62             r.source_system = $source_system,
63             r.source_file_path = $file_path,
64             r.source_line = symbol.line_start,
65             r.source_symbol_id = symbol.id,
66             r.sync_token = $sync_token";
67const ADD_SYMBOL_CALLS_CYPHER: &str = "UNWIND $symbol_calls AS call
68         MERGE (caller:CodeSymbol {id: call.caller_id, project: $project})
69         MERGE (callee:CodeSymbol {id: call.target_id, project: $project})
70         ON CREATE SET callee.name = call.callee_name, callee.updated_at = timestamp()
71         MERGE (caller)-[r:CALLS {file: call.file_path, line: call.line}]->(callee)
72         SET r.provenance = $provenance,
73             r.confidence = $confidence,
74             r.source_system = $source_system,
75             r.source_file_path = call.file_path,
76             r.source_line = call.line,
77             r.source_symbol_id = call.caller_id,
78             r.sync_token = $sync_token";
79const ADD_EXTERNAL_CALLS_CYPHER: &str = "UNWIND $external_calls AS call
80         MERGE (caller:CodeSymbol {id: call.caller_id, project: $project})
81         MERGE (callee:ExternalSymbol {id: call.target_id, project: $project})
82         ON CREATE SET callee.name = call.callee_name,
83             callee.external_module = call.callee_module,
84             callee.module = call.callee_module,
85             callee.updated_at = timestamp(),
86             callee.sync_token = $sync_token
87         MERGE (caller)-[r:CALLS {file: call.file_path, line: call.line}]->(callee)
88         SET r.provenance = $provenance,
89             r.confidence = $confidence,
90             r.source_system = $source_system,
91             r.source_file_path = call.file_path,
92             r.source_line = call.line,
93             r.source_symbol_id = call.caller_id,
94             r.sync_token = $sync_token";
95const ADD_UNRESOLVED_CALLS_CYPHER: &str = "UNWIND $unresolved_calls AS call
96         MERGE (caller:CodeSymbol {id: call.caller_id, project: $project})
97         MERGE (callee:UnresolvedCallee {id: call.target_id, project: $project})
98         ON CREATE SET callee.name = call.callee_name,
99             callee.updated_at = timestamp(),
100             callee.sync_token = $sync_token
101         MERGE (caller)-[r:CALLS {file: call.file_path, line: call.line}]->(callee)
102         SET r.provenance = $provenance,
103             r.confidence = $confidence,
104             r.source_system = $source_system,
105             r.source_file_path = call.file_path,
106             r.source_line = call.line,
107             r.source_symbol_id = call.caller_id,
108             r.sync_token = $sync_token";
109
110pub struct CodeGraph<'a> {
111    project_id: &'a str,
112    client: &'a mut GraphClient,
113}
114
115impl<'a> CodeGraph<'a> {
116    pub fn new(project_id: &'a str, client: &'a mut GraphClient) -> Self {
117        Self { project_id, client }
118    }
119
120    pub fn sync_file(
121        &mut self,
122        file_path: &str,
123        imports: &[ImportRelation],
124        definitions: &[Symbol],
125        calls: &[CallRelation],
126        cleanup_orphans: bool,
127    ) -> anyhow::Result<usize> {
128        let sync_token = new_sync_token(file_path);
129        let import_items = import_graph_items(file_path, imports);
130        let symbols = definition_graph_symbols(definitions);
131        let current_symbol_ids = symbols
132            .iter()
133            .map(|symbol| symbol.id.clone())
134            .collect::<Vec<_>>();
135        let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
136        let relationship_count = import_items.len()
137            + symbols.len()
138            + call_groups.symbol.len()
139            + call_groups.external.len()
140            + call_groups.unresolved.len();
141        execute_write_query(
142            self.client,
143            sync_file_mutation_query(SyncFileMutation {
144                project_id: self.project_id,
145                file_path,
146                symbol_count: definitions.len(),
147                imports: &import_items,
148                symbols: &symbols,
149                calls: &call_groups,
150                sync_token: &sync_token,
151            })?,
152        )?;
153        self.delete_stale_file_graph(file_path, &current_symbol_ids, &sync_token)?;
154        if cleanup_orphans {
155            self.cleanup_orphans()?;
156        }
157        Ok(relationship_count)
158    }
159
160    pub fn ensure_project_indexes(&mut self) -> anyhow::Result<()> {
161        for label in PROJECT_INDEXED_LABELS {
162            self.client.ensure_exact_node_index(label, "project")?;
163        }
164        Ok(())
165    }
166
167    pub fn ensure_file_node(
168        &mut self,
169        file_path: &str,
170        symbol_count: usize,
171        sync_token: &str,
172    ) -> anyhow::Result<()> {
173        execute_write_query(
174            self.client,
175            ensure_file_node_query(self.project_id, file_path, symbol_count, sync_token)?,
176        )
177    }
178
179    pub fn add_imports(
180        &mut self,
181        file_path: &str,
182        imports: &[ImportRelation],
183        sync_token: &str,
184    ) -> anyhow::Result<usize> {
185        let items = import_graph_items(file_path, imports);
186        if items.is_empty() {
187            return Ok(0);
188        }
189        let written = items.len();
190        execute_write_query(
191            self.client,
192            add_imports_query(self.project_id, &items, sync_token)?,
193        )?;
194        Ok(written)
195    }
196
197    pub fn add_definitions(
198        &mut self,
199        file_path: &str,
200        definitions: &[Symbol],
201        sync_token: &str,
202    ) -> anyhow::Result<usize> {
203        let symbols = definitions
204            .iter()
205            .filter(|symbol| !symbol.id.is_empty() && !symbol.name.is_empty())
206            .collect::<Vec<_>>();
207        if symbols.is_empty() {
208            return Ok(0);
209        }
210        let written = symbols.len();
211        execute_write_query(
212            self.client,
213            add_definitions_query(self.project_id, file_path, &symbols, sync_token)?,
214        )?;
215        Ok(written)
216    }
217
218    pub fn add_calls(
219        &mut self,
220        file_path: &str,
221        calls: &[CallRelation],
222        sync_token: &str,
223    ) -> anyhow::Result<usize> {
224        let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
225
226        let mut written = 0;
227        if !call_groups.symbol.is_empty() {
228            written += call_groups.symbol.len();
229            execute_write_query(
230                self.client,
231                add_symbol_calls_query(self.project_id, &call_groups.symbol, sync_token)?,
232            )?;
233        }
234        if !call_groups.external.is_empty() {
235            written += call_groups.external.len();
236            execute_write_query(
237                self.client,
238                add_external_calls_query(self.project_id, &call_groups.external, sync_token)?,
239            )?;
240        }
241        if !call_groups.unresolved.is_empty() {
242            written += call_groups.unresolved.len();
243            execute_write_query(
244                self.client,
245                add_unresolved_calls_query(self.project_id, &call_groups.unresolved, sync_token)?,
246            )?;
247        }
248        Ok(written)
249    }
250
251    pub fn delete_stale_file_graph(
252        &mut self,
253        file_path: &str,
254        current_symbol_ids: &[String],
255        sync_token: &str,
256    ) -> anyhow::Result<()> {
257        for query in delete_stale_file_graph_queries(
258            self.project_id,
259            file_path,
260            current_symbol_ids,
261            sync_token,
262        )? {
263            execute_write_query(self.client, query)?;
264        }
265        Ok(())
266    }
267
268    pub fn delete_file_graph(
269        &mut self,
270        file_path: &str,
271        current_symbol_ids: &[String],
272    ) -> anyhow::Result<()> {
273        for query in delete_file_graph_queries(self.project_id, file_path, current_symbol_ids)? {
274            execute_write_query(self.client, query)?;
275        }
276        Ok(())
277    }
278
279    pub fn delete_file_node(&mut self, file_path: &str) -> anyhow::Result<()> {
280        execute_write_query(
281            self.client,
282            delete_file_node_query(self.project_id, file_path)?,
283        )
284    }
285
286    pub fn delete_file_projection(&mut self, file_path: &str) -> anyhow::Result<()> {
287        self.delete_file_graph(file_path, &[])?;
288        self.delete_file_node(file_path)?;
289        self.cleanup_orphans()
290    }
291
292    pub fn cleanup_orphans(&mut self) -> anyhow::Result<()> {
293        for query in cleanup_orphans_queries(self.project_id)? {
294            execute_write_query(self.client, query)?;
295        }
296        Ok(())
297    }
298
299    pub fn clear_project(&mut self) -> anyhow::Result<()> {
300        execute_write_query(self.client, clear_project_query(self.project_id)?)
301    }
302}
303
304pub fn sync_file_graph(
305    ctx: &Context,
306    file_path: &str,
307    imports: &[ImportRelation],
308    definitions: &[Symbol],
309    calls: &[CallRelation],
310    cleanup_orphans: bool,
311) -> anyhow::Result<usize> {
312    with_code_graph(ctx, |graph| {
313        graph.sync_file(file_path, imports, definitions, calls, cleanup_orphans)
314    })
315}
316
317pub fn with_code_graph<T>(
318    ctx: &Context,
319    f: impl FnOnce(&mut CodeGraph<'_>) -> anyhow::Result<T>,
320) -> anyhow::Result<T> {
321    with_required_core_graph(ctx, |client| {
322        let mut graph = CodeGraph::new(&ctx.project_id, client);
323        graph.ensure_project_indexes()?;
324        f(&mut graph)
325    })
326}
327
328pub fn delete_file_graph(
329    ctx: &Context,
330    file_path: &str,
331    current_symbol_ids: &[String],
332) -> anyhow::Result<()> {
333    with_required_core_graph(ctx, |client| {
334        CodeGraph::new(&ctx.project_id, client).delete_file_graph(file_path, current_symbol_ids)
335    })
336}
337
338pub fn delete_file_projection(ctx: &Context, file_path: &str) -> anyhow::Result<()> {
339    with_required_core_graph(ctx, |client| {
340        CodeGraph::new(&ctx.project_id, client).delete_file_projection(file_path)
341    })
342}
343
344pub fn cleanup_orphans(ctx: &Context) -> anyhow::Result<()> {
345    with_code_graph(ctx, |graph| graph.cleanup_orphans())
346}
347
348pub fn clear_project(ctx: &Context) -> anyhow::Result<()> {
349    with_required_core_graph(ctx, |client| {
350        CodeGraph::new(&ctx.project_id, client).clear_project()
351    })
352}
353
354pub fn clear_all_code_index(config: &crate::config::FalkorConfig) -> anyhow::Result<()> {
355    let connection_config = config.connection_config();
356    match gobby_core::falkor::with_graph(
357        Some(&connection_config),
358        &config.graph_name,
359        None,
360        |client| execute_write_query(client, clear_all_code_index_query()?).map(Some),
361    ) {
362        Ok((Some(()), ServiceState::Available)) => Ok(()),
363        Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
364        Ok((_, ServiceState::Unreachable { message })) => {
365            log::warn!("FalkorDB was unreachable while clearing code graph: {message}");
366            Err(GraphReadError::Unreachable { message }.into())
367        }
368        Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
369            message: "graph clear returned no value".to_string(),
370        }
371        .into()),
372        Err(error) => Err(GraphReadError::QueryFailed {
373            message: error.to_string(),
374        }
375        .into()),
376    }
377}
378
379fn execute_write_query(client: &mut GraphClient, query: TypedQuery) -> anyhow::Result<()> {
380    let TypedQuery { cypher, params } = query;
381    client.query(&cypher, Some(params))?;
382    Ok(())
383}
384
385fn new_sync_token(file_path: &str) -> String {
386    let nanos = SystemTime::now()
387        .duration_since(UNIX_EPOCH)
388        .map(|duration| duration.as_nanos())
389        .unwrap_or_default();
390    let suffix = SYNC_TOKEN_COUNTER.fetch_add(1, Ordering::Relaxed);
391    format!("{}:{}:{nanos}:{suffix}", std::process::id(), file_path)
392}
393
394fn typed_query<I, K>(cypher: impl Into<String>, params: I) -> anyhow::Result<TypedQuery>
395where
396    I: IntoIterator<Item = (K, TypedValue)>,
397    K: Into<String>,
398{
399    Ok(TypedQuery::with_params(cypher, params)?)
400}
401
402fn usize_value(value: usize) -> anyhow::Result<TypedValue> {
403    Ok(TypedValue::Integer(i64::try_from(value).context(
404        "graph integer value exceeds FalkorDB i64 range",
405    )?))
406}
407
408#[derive(Debug, Clone)]
409pub(super) struct ImportGraphItem {
410    pub(super) source_file: String,
411    pub(super) target_module: String,
412}
413
414#[derive(Debug, Clone)]
415pub(super) struct CallGraphItem {
416    caller_id: String,
417    target_id: String,
418    callee_name: String,
419    pub(super) file_path: String,
420    line: usize,
421    callee_module: Option<String>,
422}
423
424#[derive(Debug, Clone, Default)]
425pub(super) struct CallGraphItems {
426    symbol: Vec<CallGraphItem>,
427    external: Vec<CallGraphItem>,
428    pub(super) unresolved: Vec<CallGraphItem>,
429}
430
431fn map_value(values: impl IntoIterator<Item = (&'static str, TypedValue)>) -> TypedValue {
432    TypedValue::Map(
433        values
434            .into_iter()
435            .map(|(key, value)| (key.to_string(), value))
436            .collect::<BTreeMap<_, _>>(),
437    )
438}
439
440pub(super) fn import_graph_items(
441    file_path: &str,
442    imports: &[ImportRelation],
443) -> Vec<ImportGraphItem> {
444    imports
445        .iter()
446        .filter(|import| {
447            !import.module_name.is_empty()
448                && !import.module_name.starts_with(UNPARSED_IMPORT_PREFIX)
449        })
450        .map(|import| ImportGraphItem {
451            source_file: file_path.to_string(),
452            target_module: import.module_name.clone(),
453        })
454        .collect()
455}
456
457fn definition_graph_symbols(definitions: &[Symbol]) -> Vec<&Symbol> {
458    definitions
459        .iter()
460        .filter(|symbol| !symbol.id.is_empty() && !symbol.name.is_empty())
461        .collect()
462}
463
464pub(super) fn partition_call_graph_items(
465    project_id: &str,
466    file_path: &str,
467    calls: &[CallRelation],
468) -> CallGraphItems {
469    let mut groups = CallGraphItems::default();
470    for call in calls {
471        if call.caller_symbol_id.is_empty() {
472            continue;
473        }
474        let Some(target) = GraphCallTarget::from_call(project_id, call) else {
475            continue;
476        };
477        let item = CallGraphItem {
478            caller_id: call.caller_symbol_id.clone(),
479            target_id: target.id().to_string(),
480            callee_name: call.callee_name.clone(),
481            file_path: file_path.to_string(),
482            line: call.line,
483            callee_module: target.module().map(str::to_string),
484        };
485        match target {
486            GraphCallTarget::Symbol { .. } => groups.symbol.push(item),
487            GraphCallTarget::External { .. } => groups.external.push(item),
488            GraphCallTarget::Unresolved { .. } => groups.unresolved.push(item),
489        }
490    }
491    groups
492}
493
494fn metadata_params(sync_token: &str) -> Vec<(&'static str, TypedValue)> {
495    vec![
496        (
497            "provenance",
498            TypedValue::String(EXTRACTED_PROVENANCE.to_string()),
499        ),
500        ("confidence", TypedValue::Float(1.0)),
501        (
502            "source_system",
503            TypedValue::String(SOURCE_SYSTEM_GCODE.to_string()),
504        ),
505        sync_token_param(sync_token),
506    ]
507}
508
509fn sync_token_param(sync_token: &str) -> (&'static str, TypedValue) {
510    ("sync_token", TypedValue::String(sync_token.to_string()))
511}
512
513fn append_sync_segment(cypher: &mut String, segment: &str) {
514    if !cypher.is_empty() {
515        cypher.push_str("\nWITH DISTINCT 1 AS _\n");
516    }
517    cypher.push_str(segment);
518}
519
520struct SyncFileMutation<'a> {
521    project_id: &'a str,
522    file_path: &'a str,
523    symbol_count: usize,
524    imports: &'a [ImportGraphItem],
525    symbols: &'a [&'a Symbol],
526    calls: &'a CallGraphItems,
527    sync_token: &'a str,
528}
529
530fn sync_file_mutation_query(input: SyncFileMutation<'_>) -> anyhow::Result<TypedQuery> {
531    let mut cypher = String::new();
532    append_sync_segment(
533        &mut cypher,
534        "MERGE (f:CodeFile {path: $file_path, project: $project})
535         SET f.updated_at = timestamp(),
536             f.symbol_count = $symbol_count,
537             f.sync_token = $sync_token",
538    );
539    if !input.imports.is_empty() {
540        append_sync_segment(&mut cypher, ADD_IMPORTS_CYPHER);
541    }
542    if !input.symbols.is_empty() {
543        append_sync_segment(&mut cypher, ADD_DEFINITIONS_CYPHER);
544    }
545    if !input.calls.symbol.is_empty() {
546        append_sync_segment(&mut cypher, ADD_SYMBOL_CALLS_CYPHER);
547    }
548    if !input.calls.external.is_empty() {
549        append_sync_segment(&mut cypher, ADD_EXTERNAL_CALLS_CYPHER);
550    }
551    if !input.calls.unresolved.is_empty() {
552        append_sync_segment(&mut cypher, ADD_UNRESOLVED_CALLS_CYPHER);
553    }
554    let mut params = vec![
555        ("project", TypedValue::String(input.project_id.to_string())),
556        ("file_path", TypedValue::String(input.file_path.to_string())),
557        ("symbol_count", usize_value(input.symbol_count)?),
558        ("imports", import_rows(input.imports)),
559        ("symbols", symbol_rows(input.symbols)?),
560        ("symbol_calls", call_rows(&input.calls.symbol)?),
561        ("external_calls", call_rows(&input.calls.external)?),
562        ("unresolved_calls", call_rows(&input.calls.unresolved)?),
563    ];
564    params.extend(metadata_params(input.sync_token));
565    typed_query(cypher, params)
566}
567
568pub(crate) fn ensure_file_node_query(
569    project_id: &str,
570    file_path: &str,
571    symbol_count: usize,
572    sync_token: &str,
573) -> anyhow::Result<TypedQuery> {
574    typed_query(
575        "MERGE (f:CodeFile {path: $file_path, project: $project})
576         SET f.updated_at = timestamp(),
577             f.symbol_count = $symbol_count,
578             f.sync_token = $sync_token",
579        [
580            ("project", TypedValue::String(project_id.to_string())),
581            ("file_path", TypedValue::String(file_path.to_string())),
582            ("symbol_count", usize_value(symbol_count)?),
583            sync_token_param(sync_token),
584        ],
585    )
586}
587
588fn add_imports_query(
589    project_id: &str,
590    imports: &[ImportGraphItem],
591    sync_token: &str,
592) -> anyhow::Result<TypedQuery> {
593    let mut params = vec![
594        ("project", TypedValue::String(project_id.to_string())),
595        (
596            "imports",
597            TypedValue::List(
598                imports
599                    .iter()
600                    .map(|import| {
601                        map_value([
602                            (
603                                "source_file",
604                                TypedValue::String(import.source_file.clone()),
605                            ),
606                            (
607                                "target_module",
608                                TypedValue::String(import.target_module.clone()),
609                            ),
610                        ])
611                    })
612                    .collect(),
613            ),
614        ),
615    ];
616    params.extend(metadata_params(sync_token));
617    typed_query(ADD_IMPORTS_CYPHER, params)
618}
619
620fn add_definitions_query(
621    project_id: &str,
622    file_path: &str,
623    symbols: &[&Symbol],
624    sync_token: &str,
625) -> anyhow::Result<TypedQuery> {
626    let mut params = vec![
627        ("project", TypedValue::String(project_id.to_string())),
628        ("file_path", TypedValue::String(file_path.to_string())),
629        (
630            "symbols",
631            TypedValue::List(
632                symbols
633                    .iter()
634                    .map(|symbol| {
635                        Ok(map_value([
636                            ("id", TypedValue::String(symbol.id.clone())),
637                            ("name", TypedValue::String(symbol.name.clone())),
638                            (
639                                "qualified_name",
640                                TypedValue::String(symbol.qualified_name.clone()),
641                            ),
642                            ("kind", TypedValue::String(symbol.kind.clone())),
643                            ("language", TypedValue::String(symbol.language.clone())),
644                            ("line_start", usize_value(symbol.line_start)?),
645                            ("line_end", usize_value(symbol.line_end)?),
646                        ]))
647                    })
648                    .collect::<anyhow::Result<Vec<_>>>()?,
649            ),
650        ),
651    ];
652    params.extend(metadata_params(sync_token));
653    typed_query(ADD_DEFINITIONS_CYPHER, params)
654}
655
656enum GraphCallTarget {
657    Symbol { id: String },
658    External { id: String, module: String },
659    Unresolved { id: String },
660}
661
662impl GraphCallTarget {
663    fn from_call(project_id: &str, call: &CallRelation) -> Option<Self> {
664        if let Some(id) = call.callee_symbol_id.as_deref().filter(|id| !id.is_empty()) {
665            return Some(Self::Symbol { id: id.to_string() });
666        }
667        if call.callee_name.is_empty() {
668            return None;
669        }
670        if call.callee_target_kind == CallTargetKind::External {
671            let module = call.callee_external_module.clone().unwrap_or_default();
672            return Some(Self::External {
673                id: make_external_symbol_id(project_id, &call.callee_name, Some(&module)),
674                module,
675            });
676        }
677        Some(Self::Unresolved {
678            id: make_unresolved_callee_id(project_id, &call.callee_name),
679        })
680    }
681
682    fn id(&self) -> &str {
683        match self {
684            Self::Symbol { id } | Self::External { id, .. } | Self::Unresolved { id } => id,
685        }
686    }
687
688    fn module(&self) -> Option<&str> {
689        match self {
690            Self::External { module, .. } => Some(module),
691            Self::Symbol { .. } | Self::Unresolved { .. } => None,
692        }
693    }
694}
695
696pub fn call_target_id(project_id: &str, call: &CallRelation) -> Option<String> {
697    match GraphCallTarget::from_call(project_id, call)? {
698        GraphCallTarget::Symbol { id }
699        | GraphCallTarget::External { id, .. }
700        | GraphCallTarget::Unresolved { id } => Some(id),
701    }
702}
703
704fn call_rows(calls: &[CallGraphItem]) -> anyhow::Result<TypedValue> {
705    Ok(TypedValue::List(
706        calls
707            .iter()
708            .map(|call| {
709                Ok(map_value([
710                    ("caller_id", TypedValue::String(call.caller_id.clone())),
711                    ("target_id", TypedValue::String(call.target_id.clone())),
712                    ("callee_name", TypedValue::String(call.callee_name.clone())),
713                    ("file_path", TypedValue::String(call.file_path.clone())),
714                    ("line", usize_value(call.line)?),
715                    (
716                        "callee_module",
717                        TypedValue::String(call.callee_module.clone().unwrap_or_default()),
718                    ),
719                ]))
720            })
721            .collect::<anyhow::Result<Vec<_>>>()?,
722    ))
723}
724
725fn import_rows(imports: &[ImportGraphItem]) -> TypedValue {
726    TypedValue::List(
727        imports
728            .iter()
729            .map(|import| {
730                map_value([
731                    (
732                        "source_file",
733                        TypedValue::String(import.source_file.clone()),
734                    ),
735                    (
736                        "target_module",
737                        TypedValue::String(import.target_module.clone()),
738                    ),
739                ])
740            })
741            .collect(),
742    )
743}
744
745fn symbol_rows(symbols: &[&Symbol]) -> anyhow::Result<TypedValue> {
746    Ok(TypedValue::List(
747        symbols
748            .iter()
749            .map(|symbol| {
750                Ok(map_value([
751                    ("id", TypedValue::String(symbol.id.clone())),
752                    ("name", TypedValue::String(symbol.name.clone())),
753                    (
754                        "qualified_name",
755                        TypedValue::String(symbol.qualified_name.clone()),
756                    ),
757                    ("kind", TypedValue::String(symbol.kind.clone())),
758                    ("language", TypedValue::String(symbol.language.clone())),
759                    ("line_start", usize_value(symbol.line_start)?),
760                    ("line_end", usize_value(symbol.line_end)?),
761                ]))
762            })
763            .collect::<anyhow::Result<Vec<_>>>()?,
764    ))
765}
766
767fn add_symbol_calls_query(
768    project_id: &str,
769    calls: &[CallGraphItem],
770    sync_token: &str,
771) -> anyhow::Result<TypedQuery> {
772    let mut params = vec![
773        ("project", TypedValue::String(project_id.to_string())),
774        ("symbol_calls", call_rows(calls)?),
775    ];
776    params.extend(metadata_params(sync_token));
777    typed_query(ADD_SYMBOL_CALLS_CYPHER, params)
778}
779
780fn add_external_calls_query(
781    project_id: &str,
782    calls: &[CallGraphItem],
783    sync_token: &str,
784) -> anyhow::Result<TypedQuery> {
785    let mut params = vec![
786        ("project", TypedValue::String(project_id.to_string())),
787        ("external_calls", call_rows(calls)?),
788    ];
789    params.extend(metadata_params(sync_token));
790    typed_query(ADD_EXTERNAL_CALLS_CYPHER, params)
791}
792
793fn add_unresolved_calls_query(
794    project_id: &str,
795    calls: &[CallGraphItem],
796    sync_token: &str,
797) -> anyhow::Result<TypedQuery> {
798    let mut params = vec![
799        ("project", TypedValue::String(project_id.to_string())),
800        ("unresolved_calls", call_rows(calls)?),
801    ];
802    params.extend(metadata_params(sync_token));
803    typed_query(ADD_UNRESOLVED_CALLS_CYPHER, params)
804}
805
806pub(crate) fn delete_file_graph_queries(
807    project_id: &str,
808    file_path: &str,
809    current_symbol_ids: &[String],
810) -> anyhow::Result<Vec<TypedQuery>> {
811    let base_params = || {
812        [
813            ("project", TypedValue::String(project_id.to_string())),
814            ("file_path", TypedValue::String(file_path.to_string())),
815        ]
816    };
817    let mut queries = vec![
818        typed_query(
819            "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:IMPORTS]->(:CodeModule)
820             DELETE r",
821            base_params(),
822        )?,
823        typed_query(
824            "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:DEFINES]->(:CodeSymbol)
825             DELETE r",
826            base_params(),
827        )?,
828        typed_query(
829            "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})-[r:CALLS]->()
830             DELETE r",
831            base_params(),
832        )?,
833    ];
834
835    if current_symbol_ids.is_empty() {
836        queries.push(typed_query(
837            "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
838             DETACH DELETE s",
839            base_params(),
840        )?);
841    } else {
842        let mut params = vec![
843            ("project", TypedValue::String(project_id.to_string())),
844            ("file_path", TypedValue::String(file_path.to_string())),
845            (
846                "symbol_ids",
847                TypedValue::List(
848                    current_symbol_ids
849                        .iter()
850                        .map(|id| TypedValue::String(id.clone()))
851                        .collect(),
852                ),
853            ),
854        ];
855        queries.push(typed_query(
856            "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
857             WHERE NOT s.id IN $symbol_ids
858             DETACH DELETE s",
859            params.drain(..),
860        )?);
861    }
862
863    Ok(queries)
864}
865
866pub(crate) fn delete_stale_file_graph_queries(
867    project_id: &str,
868    file_path: &str,
869    current_symbol_ids: &[String],
870    sync_token: &str,
871) -> anyhow::Result<Vec<TypedQuery>> {
872    let base_params = || {
873        [
874            ("project", TypedValue::String(project_id.to_string())),
875            ("file_path", TypedValue::String(file_path.to_string())),
876            sync_token_param(sync_token),
877        ]
878    };
879    let mut queries = vec![
880        typed_query(
881            "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:IMPORTS]->(:CodeModule {project: $project})
882             WHERE r.sync_token IS NULL OR r.sync_token <> $sync_token
883             DELETE r",
884            base_params(),
885        )?,
886        typed_query(
887            "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:DEFINES]->(:CodeSymbol {project: $project})
888             WHERE r.sync_token IS NULL OR r.sync_token <> $sync_token
889             DELETE r",
890            base_params(),
891        )?,
892        typed_query(
893            "MATCH (s:CodeSymbol {project: $project})-[r:CALLS]->(n {project: $project})
894             WHERE (r.file = $file_path OR r.source_file_path = $file_path)
895               AND (r.sync_token IS NULL OR r.sync_token <> $sync_token)
896             DELETE r",
897            base_params(),
898        )?,
899    ];
900
901    let mut symbol_params = vec![
902        ("project", TypedValue::String(project_id.to_string())),
903        ("file_path", TypedValue::String(file_path.to_string())),
904        sync_token_param(sync_token),
905    ];
906    if current_symbol_ids.is_empty() {
907        queries.push(typed_query(
908            "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
909             WHERE s.sync_token IS NULL OR s.sync_token <> $sync_token
910             DETACH DELETE s",
911            symbol_params,
912        )?);
913    } else {
914        symbol_params.push((
915            "symbol_ids",
916            TypedValue::List(
917                current_symbol_ids
918                    .iter()
919                    .map(|id| TypedValue::String(id.clone()))
920                    .collect(),
921            ),
922        ));
923        queries.push(typed_query(
924            "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
925             WHERE (s.sync_token IS NULL OR s.sync_token <> $sync_token)
926               AND NOT s.id IN $symbol_ids
927             DETACH DELETE s",
928            symbol_params,
929        )?);
930    }
931
932    Ok(queries)
933}
934
935pub(crate) fn delete_file_node_query(
936    project_id: &str,
937    file_path: &str,
938) -> anyhow::Result<TypedQuery> {
939    typed_query(
940        "MATCH (f:CodeFile {path: $file_path, project: $project})
941         DETACH DELETE f",
942        [
943            ("project", TypedValue::String(project_id.to_string())),
944            ("file_path", TypedValue::String(file_path.to_string())),
945        ],
946    )
947}
948
949pub(crate) fn cleanup_orphans_queries(project_id: &str) -> anyhow::Result<Vec<TypedQuery>> {
950    let project_param = || [("project", TypedValue::String(project_id.to_string()))];
951    // Orphan cleanup runs after low-activity sync paths so failed writes leave
952    // the previous projection available.
953    cleanup_orphans_cypher_segments()
954        .into_iter()
955        .map(|cypher| typed_query(cypher, project_param()))
956        .collect()
957}
958
959fn cleanup_orphans_cypher_segments() -> [&'static str; 3] {
960    [
961        "MATCH (m:CodeModule {project: $project})
962             WHERE NOT (:CodeFile {project: $project})-[:IMPORTS]->(m)
963             DETACH DELETE m",
964        "MATCH (n {project: $project})
965             WHERE (n:UnresolvedCallee OR n:ExternalSymbol)
966               AND NOT ({project: $project})-[:CALLS]->(n)
967             DETACH DELETE n",
968        "MATCH (s:CodeSymbol {project: $project})
969             WHERE s.file_path IS NULL
970               AND NOT (:CodeFile {project: $project})-[:DEFINES]->(s)
971               AND NOT ({project: $project})-[:CALLS]->(s)
972               AND NOT (s)-[:CALLS]->({project: $project})
973             DETACH DELETE s",
974    ]
975}
976
977pub(crate) fn clear_project_query(project_id: &str) -> anyhow::Result<TypedQuery> {
978    typed_query(
979        format!(
980            "MATCH (n {{project: $project}})
981             WHERE {PROJECT_NODE_PREDICATE}
982             DETACH DELETE n"
983        ),
984        [("project", TypedValue::String(project_id.to_string()))],
985    )
986}
987
988pub(crate) fn clear_all_code_index_query() -> anyhow::Result<TypedQuery> {
989    typed_query(
990        format!(
991            "MATCH (n)
992             WHERE {PROJECT_NODE_PREDICATE}
993             DETACH DELETE n"
994        ),
995        Vec::<(&str, TypedValue)>::new(),
996    )
997}