Skip to main content

gobby_code/graph/
code_graph.rs

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