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