Skip to main content

gitcortex_store/kuzu/
mod.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::Path,
4};
5
6use gitcortex_core::{
7    error::{GitCortexError, Result},
8    graph::{Edge, GraphDiff, Node, NodeId},
9    schema::{EdgeConfidence, NodeKind, SCHEMA_VERSION},
10    store::{
11        AttributeFilter, CallSite, CallersDeep, GraphStats, GraphStore, SubGraph, SymbolContext,
12        TypeHierarchy,
13    },
14};
15use kuzu::{Connection, Database, SystemConfig, Value};
16
17use crate::{branch, schema as db_schema};
18
19mod bulk;
20mod conv;
21mod escape;
22mod queries;
23mod values;
24
25use conv::{edge_kind_from_str, lang_scope_clause, vis_str};
26use escape::{esc, esc_multiline};
27use queries::{collect_ids, row_to_node, rows_to_nodes, NODE_COLS, NODE_COL_COUNT, SYMBOL_RANK};
28use values::{i64_val, str_val};
29
30// Batch sizes for `UNWIND`-based inserts. Nodes carry a (≤16 KB) def_body, so
31// their chunk is kept small to bound query-string size; edges are three ids
32// each, so they batch much larger.
33const NODE_INSERT_CHUNK: usize = 128;
34const EDGE_INSERT_CHUNK: usize = 1000;
35
36/// Render a `Node` as a Cypher struct literal `{id:'…', kind:'…', …}` for use
37/// inside an `UNWIND [...] AS r CREATE` batch. String fields are escaped and
38/// single-quoted; bools/ints are emitted bare.
39fn node_struct_literal(node: &Node) -> String {
40    let id = esc(&node.id.as_str());
41    let kind = esc(&node.kind.to_string());
42    let name = esc(&node.name);
43    let qname = esc(&node.qualified_name);
44    let file = esc(node.file.to_string_lossy().as_ref());
45    let sl = node.span.start_line as i64;
46    let el = node.span.end_line as i64;
47    let loc = node.metadata.loc as i64;
48    let vis = esc(&vis_str(&node.metadata.visibility));
49    let m = &node.metadata;
50    let generic_bounds = esc(&m.generic_bounds.join("|"));
51    let annotations = esc(&m.annotations.join("|"));
52    let def_sig = esc_multiline(&m.definition.signature);
53    let def_body = esc_multiline(&m.definition.body);
54    let def_doc = esc_multiline(m.definition.doc_comment.as_deref().unwrap_or(""));
55    let def_start_byte = m.definition.start_byte as i64;
56    let def_end_byte = m.definition.end_byte as i64;
57    let complexity = match m.lld.complexity {
58        Some(c) => c as i64,
59        None => -1i64,
60    };
61
62    format!(
63        "{{id:'{id}', kind:'{kind}', name:'{name}', qualified_name:'{qname}', file:'{file}', \
64         start_line:{sl}, end_line:{el}, loc:{loc}, visibility:'{vis}', \
65         is_async:{ia}, is_unsafe:{iu}, is_static:{ist}, is_abstract:{iab}, is_final:{ifi}, \
66         is_property:{ip}, is_generator:{ig}, is_const:{ic}, generic_bounds:'{generic_bounds}', \
67         def_signature:'{def_sig}', def_body:'{def_body}', def_doc:'{def_doc}', \
68         def_start_byte:{def_start_byte}, def_end_byte:{def_end_byte}, \
69         complexity:{complexity}, annotations:'{annotations}'}}",
70        ia = m.is_async,
71        iu = m.is_unsafe,
72        ist = m.is_static,
73        iab = m.is_abstract,
74        ifi = m.is_final,
75        ip = m.is_property,
76        ig = m.is_generator,
77        ic = m.is_const,
78    )
79}
80
81/// True when the branch's node table has zero rows (fresh / never indexed).
82fn node_table_is_empty(conn: &Connection, nt: &str) -> Result<bool> {
83    let mut r = conn
84        .query(&format!("MATCH (n:{nt}) RETURN count(n) AS c LIMIT 1"))
85        .map_err(|e| GitCortexError::Store(format!("count nodes: {e}")))?;
86    match r.by_ref().next() {
87        Some(row) => match &row[0] {
88            kuzu::Value::Int64(n) => Ok(*n == 0),
89            _ => Ok(false),
90        },
91        None => Ok(true),
92    }
93}
94
95fn rows_to_edges(result: kuzu::QueryResult) -> Result<Vec<Edge>> {
96    let mut out = Vec::new();
97    for row in result {
98        let src_str = str_val(&row[0])?;
99        let dst_str = str_val(&row[1])?;
100        let kind_str = str_val(&row[2])?;
101        let line = i64_val(&row[3])
102            .ok()
103            .filter(|line| *line >= 0)
104            .map(|line| line as u32);
105        let confidence = EdgeConfidence::from_label(&str_val(&row[4]).unwrap_or_default());
106        out.push(Edge {
107            src: NodeId::try_from(src_str.as_str())
108                .map_err(|e| GitCortexError::Store(format!("bad src id: {e}")))?,
109            dst: NodeId::try_from(dst_str.as_str())
110                .map_err(|e| GitCortexError::Store(format!("bad dst id: {e}")))?,
111            kind: edge_kind_from_str(&kind_str),
112            line,
113            confidence,
114        });
115    }
116    Ok(out)
117}
118
119/// Bulk-load a full-index diff via CSV `COPY`. Stages CSVs in a unique temp
120/// dir, loads them, then removes the dir. See [`bulk`] for the rationale.
121fn bulk_apply(conn: &Connection, nt: &str, et: &str, diff: &GraphDiff) -> Result<()> {
122    // Unique staging dir per call: pid + nanos + a process-wide atomic counter,
123    // so concurrent `apply_diff`s (e.g. parallel tests in one binary) never
124    // share a directory.
125    use std::sync::atomic::{AtomicU64, Ordering};
126    static SEQ: AtomicU64 = AtomicU64::new(0);
127    let stage = std::env::temp_dir().join(format!(
128        "gcx-bulk-{}-{}-{}",
129        std::process::id(),
130        std::time::SystemTime::now()
131            .duration_since(std::time::UNIX_EPOCH)
132            .map(|d| d.as_nanos())
133            .unwrap_or(0),
134        SEQ.fetch_add(1, Ordering::Relaxed),
135    ));
136    std::fs::create_dir_all(&stage)
137        .map_err(|e| GitCortexError::Store(format!("create staging dir: {e}")))?;
138
139    let result = bulk::bulk_load(conn, nt, et, &stage, &diff.added_nodes, &diff.added_edges);
140
141    // Best-effort cleanup regardless of load outcome.
142    let _ = std::fs::remove_dir_all(&stage);
143
144    result.map(|_| ())
145}
146
147const DEFERRED_CHUNK: usize = 500;
148
149/// Resolve a batch of deferred cross-file edges via one UNWIND query per
150/// language-scope group instead of one query per pair.
151///
152/// Pairs are grouped by the caller's language family so the scope clause is
153/// uniform across all rows in a chunk. Each group is split into chunks of at
154/// most [`DEFERRED_CHUNK`] pairs to keep query strings bounded.
155fn resolve_deferred_batch(
156    conn: &Connection,
157    nt: &str,
158    et: &str,
159    pairs: &[(NodeId, String)],
160    caller_file: &HashMap<String, String>,
161    edge_kind: &str,
162    kind_filter: &str,
163) -> Result<()> {
164    if pairs.is_empty() {
165        return Ok(());
166    }
167    let mut by_scope: HashMap<String, Vec<(String, String)>> = HashMap::new();
168    for (src_id, tgt_name) in pairs {
169        let src_str = src_id.as_str();
170        let scope = caller_file
171            .get(src_str.as_str())
172            .map(|f| lang_scope_clause(f, "tgt"))
173            .unwrap_or_default();
174        by_scope
175            .entry(scope)
176            .or_default()
177            .push((src_str, tgt_name.clone()));
178    }
179    for (scope_clause, group) in &by_scope {
180        for chunk in group.chunks(DEFERRED_CHUNK) {
181            let kind_and = if kind_filter.is_empty() {
182                String::new()
183            } else {
184                format!(" AND ({kind_filter})")
185            };
186
187            // Pass 1: find which (src, tgt_name) pairs have an Imports edge
188            // backing in the DB → those become Resolved.
189            let pair_list = chunk
190                .iter()
191                .map(|(src, tgt)| format!("{{s:'{}',t:'{}'}}", esc(src), esc(tgt)))
192                .collect::<Vec<_>>()
193                .join(",");
194            let mut qr = conn
195                .query(&format!(
196                    "UNWIND [{pair_list}] AS r \
197                     MATCH (src:{nt} {{id: r.s}}), (imp:{nt})-[:{et} {{kind:'imports'}}]->(tgt:{nt}) \
198                     WHERE tgt.name = r.t AND imp.file = src.file \
199                     RETURN DISTINCT r.s AS s, r.t AS t"
200                ))
201                .map_err(|e| GitCortexError::Store(format!("find import-verified {edge_kind}: {e}")))?;
202            let mut verified: HashSet<(String, String)> = HashSet::new();
203            for row in qr.by_ref() {
204                if let (Value::String(s), Value::String(t)) = (&row[0], &row[1]) {
205                    verified.insert((s.clone(), t.clone()));
206                }
207            }
208
209            // Pass 2a: create Resolved edges for import-verified pairs.
210            let resolved_list = chunk
211                .iter()
212                .filter(|(s, t)| verified.contains(&(s.clone(), t.clone())))
213                .map(|(src, tgt)| format!("{{s:'{}',t:'{}'}}", esc(src), esc(tgt)))
214                .collect::<Vec<_>>()
215                .join(",");
216            if !resolved_list.is_empty() {
217                conn.query(&format!(
218                    "UNWIND [{resolved_list}] AS r \
219                     MATCH (src:{nt} {{id: r.s}}), (tgt:{nt}) \
220                     WHERE tgt.name = r.t{kind_and}{scope_clause} \
221                     CREATE (src)-[:{et} {{kind: '{edge_kind}', line: -1, confidence: 'resolved'}}]->(tgt)"
222                ))
223                .map_err(|e| GitCortexError::Store(format!("batch resolved {edge_kind}: {e}")))?;
224            }
225
226            // Pass 2b: create Inferred edges for the remaining pairs.
227            let inferred_list = chunk
228                .iter()
229                .filter(|(s, t)| !verified.contains(&(s.clone(), t.clone())))
230                .map(|(src, tgt)| format!("{{s:'{}',t:'{}'}}", esc(src), esc(tgt)))
231                .collect::<Vec<_>>()
232                .join(",");
233            if !inferred_list.is_empty() {
234                conn.query(&format!(
235                    "UNWIND [{inferred_list}] AS r \
236                     MATCH (src:{nt} {{id: r.s}}), (tgt:{nt}) \
237                     WHERE tgt.name = r.t{kind_and}{scope_clause} \
238                     CREATE (src)-[:{et} {{kind: '{edge_kind}', line: -1, confidence: 'inferred'}}]->(tgt)"
239                ))
240                .map_err(|e| GitCortexError::Store(format!("batch inferred {edge_kind}: {e}")))?;
241            }
242        }
243    }
244    Ok(())
245}
246
247/// Like [`resolve_deferred_batch`] but for `Calls` edges, carrying each call's
248/// source line onto the created edge. Tuples are `(caller_id, callee_name, line)`.
249///
250/// Uses a two-pass strategy per chunk: first query joins through existing
251/// `Imports` edges to find import-verified pairs (→ `Resolved`); second query
252/// handles the remainder with a plain name-match (→ `Inferred`). This avoids
253/// duplicate edges without requiring `NOT EXISTS` subquery support.
254fn resolve_calls_batch(
255    conn: &Connection,
256    nt: &str,
257    et: &str,
258    triples: &[(NodeId, String, u32)],
259    caller_file: &HashMap<String, String>,
260) -> Result<()> {
261    if triples.is_empty() {
262        return Ok(());
263    }
264    let mut by_scope: HashMap<String, Vec<(String, String, u32)>> = HashMap::new();
265    for (src_id, tgt_name, line) in triples {
266        let src_str = src_id.as_str();
267        let scope = caller_file
268            .get(src_str.as_str())
269            .map(|f| lang_scope_clause(f, "tgt"))
270            .unwrap_or_default();
271        by_scope
272            .entry(scope)
273            .or_default()
274            .push((src_str, tgt_name.clone(), *line));
275    }
276    for (scope_clause, group) in &by_scope {
277        for chunk in group.chunks(DEFERRED_CHUNK) {
278            // Pass 1: discover which (src, tgt_name) pairs are import-verified.
279            let pair_list = chunk
280                .iter()
281                .map(|(src, tgt, _)| format!("{{s:'{}',t:'{}'}}", esc(src), esc(tgt)))
282                .collect::<Vec<_>>()
283                .join(",");
284            let mut qr = conn
285                .query(&format!(
286                    "UNWIND [{pair_list}] AS r \
287                     MATCH (src:{nt} {{id: r.s}}), (imp:{nt})-[:{et} {{kind:'imports'}}]->(tgt:{nt}) \
288                     WHERE tgt.name = r.t AND imp.file = src.file \
289                     RETURN DISTINCT r.s AS s, r.t AS t"
290                ))
291                .map_err(|e| GitCortexError::Store(format!("find import-verified calls: {e}")))?;
292            let mut verified: HashSet<(String, String)> = HashSet::new();
293            for row in qr.by_ref() {
294                if let (Value::String(s), Value::String(t)) = (&row[0], &row[1]) {
295                    verified.insert((s.clone(), t.clone()));
296                }
297            }
298
299            // Pass 2a: Resolved edges for import-verified call pairs.
300            let resolved_list = chunk
301                .iter()
302                .filter(|(s, t, _)| verified.contains(&(s.clone(), t.clone())))
303                .map(|(src, tgt, line)| {
304                    format!("{{s:'{}',t:'{}',ln:{}}}", esc(src), esc(tgt), line)
305                })
306                .collect::<Vec<_>>()
307                .join(",");
308            if !resolved_list.is_empty() {
309                conn.query(&format!(
310                    "UNWIND [{resolved_list}] AS r \
311                     MATCH (src:{nt} {{id: r.s}}), (tgt:{nt}) \
312                     WHERE tgt.name = r.t AND (tgt.kind = 'function' OR tgt.kind = 'method'){scope_clause} \
313                     CREATE (src)-[:{et} {{kind: 'calls', line: r.ln, confidence: 'resolved'}}]->(tgt)"
314                ))
315                .map_err(|e| GitCortexError::Store(format!("batch resolved calls: {e}")))?;
316            }
317
318            // Pass 2b: Inferred edges for the remaining call pairs.
319            let inferred_list = chunk
320                .iter()
321                .filter(|(s, t, _)| !verified.contains(&(s.clone(), t.clone())))
322                .map(|(src, tgt, line)| {
323                    format!("{{s:'{}',t:'{}',ln:{}}}", esc(src), esc(tgt), line)
324                })
325                .collect::<Vec<_>>()
326                .join(",");
327            if !inferred_list.is_empty() {
328                conn.query(&format!(
329                    "UNWIND [{inferred_list}] AS r \
330                     MATCH (src:{nt} {{id: r.s}}), (tgt:{nt}) \
331                     WHERE tgt.name = r.t AND (tgt.kind = 'function' OR tgt.kind = 'method'){scope_clause} \
332                     CREATE (src)-[:{et} {{kind: 'calls', line: r.ln, confidence: 'inferred'}}]->(tgt)"
333                ))
334                .map_err(|e| GitCortexError::Store(format!("batch inferred calls: {e}")))?;
335            }
336        }
337    }
338    Ok(())
339}
340
341// ── KuzuGraphStore ────────────────────────────────────────────────────────────
342
343/// Local KuzuDB-backed implementation of [`GraphStore`].
344///
345/// One database file per repo (`graph.kuzu`), with per-branch node/edge tables
346/// inside it. A fresh `Connection` is created for each operation so we avoid
347/// the self-referential lifetime that `Mutex<Connection<'db>>` would require.
348pub struct KuzuGraphStore {
349    db: Database,
350    repo_id: String,
351    /// Held for the store's lifetime so every in-process Kuzu owner participates
352    /// in the same cross-process exclusion protocol.
353    _repository_lock: Option<branch::RepositoryLock>,
354}
355
356fn acquire_repository_lock(repo_root: &Path, operation: &str) -> Result<branch::RepositoryLock> {
357    let mut lock = branch::RepositoryLock::try_acquire(repo_root)?.ok_or_else(|| {
358        let owner = branch::repository_lock_owner(repo_root);
359        GitCortexError::Store(format!(
360            "repository graph is active{}; close editor MCP sessions and stop `gcx viz`, then retry",
361            if owner.is_empty() {
362                String::new()
363            } else {
364                format!(" ({owner})")
365            }
366        ))
367    })?;
368    lock.set_owner(&format!("pid {} ({operation})", std::process::id()))?;
369    Ok(lock)
370}
371
372impl KuzuGraphStore {
373    /// Open (or create) the graph database for the repo at `repo_root`.
374    ///
375    /// Existing data with a different schema is never deleted as a side effect
376    /// of a query, hook, or server startup. An explicit `gcx init` performs the
377    /// rebuild through [`Self::open_for_init`].
378    pub fn open(repo_root: &Path) -> Result<Self> {
379        let lock = acquire_repository_lock(repo_root, "graph operation")?;
380        Self::open_with_schema_policy(repo_root, false, Some(lock))
381    }
382
383    /// Open the graph as the shared local MCP owner.
384    pub fn open_for_daemon(repo_root: &Path) -> Result<Self> {
385        let lock = acquire_repository_lock(repo_root, "repository daemon")?;
386        Self::open_with_schema_policy(repo_root, false, Some(lock))
387    }
388
389    /// Open the graph for an explicit initialization operation, rebuilding an
390    /// incompatible store only while exclusive repository ownership is held.
391    pub fn open_for_init(repo_root: &Path) -> Result<Self> {
392        let lock = acquire_repository_lock(repo_root, "initialization")?;
393        Self::open_with_schema_policy(repo_root, true, Some(lock))
394    }
395
396    fn open_with_schema_policy(
397        repo_root: &Path,
398        allow_rebuild: bool,
399        repository_lock: Option<branch::RepositoryLock>,
400    ) -> Result<Self> {
401        let repo_id = branch::storage_repo_id(repo_root);
402        let db_path = branch::db_path(&repo_id);
403        let existing_store = db_path.exists();
404        let persisted_schema = branch::read_schema_version(&repo_id);
405        if persisted_schema != SCHEMA_VERSION {
406            if existing_store && !allow_rebuild {
407                return Err(GitCortexError::Store(format!(
408                    "graph schema version {persisted_schema} is incompatible with expected version {SCHEMA_VERSION}; run `gcx init` to rebuild the local index"
409                )));
410            }
411            if existing_store {
412                eprintln!(
413                    "gitcortex: schema version mismatch (expected {}); rebuilding graph store during explicit initialization",
414                    SCHEMA_VERSION
415                );
416                branch::wipe_repo_data(&repo_id)?;
417            }
418            branch::write_schema_version(&repo_id, SCHEMA_VERSION)?;
419        }
420
421        let db_path = branch::db_path(&repo_id);
422        if let Some(parent) = db_path.parent() {
423            std::fs::create_dir_all(parent)?;
424        }
425
426        let db = Database::new(&db_path, SystemConfig::default()).map_err(|error| {
427            let detail = error.to_string();
428            if detail.to_ascii_lowercase().contains("lock") {
429                GitCortexError::Store(
430                    "graph store is already owned by another process; close active editor MCP sessions or stop `gcx viz`, then retry"
431                        .to_owned(),
432                )
433            } else {
434                GitCortexError::Store(format!("open db: {detail}"))
435            }
436        })?;
437
438        Ok(Self {
439            db,
440            repo_id,
441            _repository_lock: repository_lock,
442        })
443    }
444
445    // ── Private helpers ───────────────────────────────────────────────────────
446
447    fn conn(&self) -> Result<Connection<'_>> {
448        Connection::new(&self.db)
449            .map_err(|e| GitCortexError::Store(format!("open connection: {e}")))
450    }
451
452    fn ensure_branch(&self, branch: &str) -> Result<()> {
453        let mut conn = self.conn()?;
454        db_schema::ensure_branch(&mut conn, branch)
455    }
456}
457
458// ── GraphStore impl ───────────────────────────────────────────────────────────
459
460impl GraphStore for KuzuGraphStore {
461    // ── Write path ────────────────────────────────────────────────────────────
462
463    fn apply_diff(&mut self, branch: &str, diff: &GraphDiff) -> Result<()> {
464        if diff.is_empty() {
465            return Ok(());
466        }
467
468        self.ensure_branch(branch)?;
469        let nt = db_schema::node_table(branch);
470        let et = db_schema::edge_table(branch);
471        let conn = self.conn()?;
472
473        // ── Fast path: bulk COPY load for a fresh full index ───────────────────
474        // When the branch's node table is empty this is a first full index.
475        // Stage the nodes/edges as CSV and `COPY` them in — ~100× faster than
476        // per-row MATCH/CREATE on large repos.
477        //
478        // The diff's `removed_*` fields are ignored on this path: the indexer
479        // emits a `removed_files` entry for every parsed file + its ancestor
480        // folders (so an incremental re-parse first clears the old nodes), but
481        // against an empty table those deletes are vacuous. Deferred cross-file
482        // resolution is likewise skipped — on a full index every in-repo name
483        // is already in `added_edges`; the only `deferred_*` left are external
484        // (stdlib) names the store couldn't resolve anyway.
485        let empty = node_table_is_empty(&conn, &nt)?;
486        if std::env::var_os("GCX_TIMING").is_some() {
487            eprintln!(
488                "[gcx-timing] apply_diff path: table_empty={empty} nodes={} edges={}",
489                diff.added_nodes.len(),
490                diff.added_edges.len()
491            );
492        }
493        if empty {
494            return bulk_apply(&conn, &nt, &et, diff);
495        }
496
497        // Transaction 1: commit all deletes first.
498        // KuzuDB has a quirk where DETACH DELETE + CREATE in the same transaction
499        // can produce NULL for the last STRING column in newly created nodes.
500        // Splitting into separate transactions avoids this.
501        conn.query("BEGIN TRANSACTION")
502            .map_err(|e| GitCortexError::Store(format!("begin delete transaction: {e}")))?;
503
504        // 1. Remove nodes for deleted/replaced files.
505        //    Skip directory paths (no extension) — folder nodes are reused across
506        //    incremental updates to preserve their Contains edges to sibling files.
507        for file in &diff.removed_files {
508            if file.extension().is_none() {
509                continue;
510            }
511            let file_str = esc(file.to_string_lossy().as_ref());
512            conn.query(&format!(
513                "MATCH (n:{nt}) WHERE n.file = '{file_str}' DETACH DELETE n"
514            ))
515            .map_err(|e| GitCortexError::Store(format!("delete file nodes: {e}")))?;
516        }
517
518        // 2. Remove explicit node IDs.
519        for id in &diff.removed_node_ids {
520            let id_str = esc(&id.as_str());
521            conn.query(&format!(
522                "MATCH (n:{nt}) WHERE n.id = '{id_str}' DETACH DELETE n"
523            ))
524            .map_err(|e| GitCortexError::Store(format!("delete node: {e}")))?;
525        }
526
527        // 3. Remove explicit edges.
528        for (src, dst, kind) in &diff.removed_edges {
529            let s = esc(&src.as_str());
530            let d = esc(&dst.as_str());
531            let k = esc(&kind.to_string());
532            conn.query(&format!(
533                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) \
534                 WHERE s.id = '{s}' AND d.id = '{d}' AND e.kind = '{k}' \
535                 DELETE e"
536            ))
537            .map_err(|e| GitCortexError::Store(format!("delete edge: {e}")))?;
538        }
539
540        conn.query("COMMIT")
541            .map_err(|e| GitCortexError::Store(format!("commit deletes: {e}")))?;
542
543        // Build a remap table: for each Folder node in the diff, if a folder at
544        // that path already exists in the DB, reuse its ID so that existing
545        // Contains edges to sibling files are preserved.
546        // One batch query instead of one query per folder.
547        let mut id_remap: HashMap<String, String> = HashMap::new();
548        let folder_nodes: Vec<&Node> = diff
549            .added_nodes
550            .iter()
551            .filter(|n| n.kind == NodeKind::Folder)
552            .collect();
553        if !folder_nodes.is_empty() {
554            let path_list = folder_nodes
555                .iter()
556                .map(|n| format!("'{}'", esc(n.file.to_string_lossy().as_ref())))
557                .collect::<Vec<_>>()
558                .join(", ");
559            let mut rows = conn
560                .query(&format!(
561                    "MATCH (n:{nt}) WHERE n.file IN [{path_list}] AND n.kind = 'folder' \
562                     RETURN n.file, n.id"
563                ))
564                .map_err(|e| GitCortexError::Store(e.to_string()))?;
565            let mut existing_by_path: HashMap<String, String> = HashMap::new();
566            for row in rows.by_ref() {
567                if let (Ok(file), Ok(id)) = (str_val(&row[0]), str_val(&row[1])) {
568                    existing_by_path.insert(file, id);
569                }
570            }
571            for node in &folder_nodes {
572                let path_str = node.file.to_string_lossy().into_owned();
573                if let Some(existing_id) = existing_by_path.get(&path_str) {
574                    tracing::debug!("folder remap: {} → {}", node.file.display(), existing_id);
575                    id_remap.insert(node.id.as_str().to_owned(), existing_id.clone());
576                }
577            }
578        }
579
580        // Transaction 2: insert new nodes. Deduplicate by ID first so a rename
581        // delta (or any other case producing the same NodeId twice) never hits a
582        // PK violation. Folder nodes remapped to existing DB nodes are skipped.
583        conn.query("BEGIN TRANSACTION")
584            .map_err(|e| GitCortexError::Store(format!("begin node insert transaction: {e}")))?;
585
586        // Batch node inserts via `UNWIND [<struct>, …] CREATE`. One query per
587        // chunk instead of one per node — a ~100× cut in round-trips on a full
588        // index of a large repo. Chunk size is kept modest because each row
589        // carries the (truncated) def_body, so a chunk can still be a few MB.
590        let mut seen_node_ids: HashSet<String> = HashSet::new();
591        let rows: Vec<String> = diff
592            .added_nodes
593            .iter()
594            .filter(|n| seen_node_ids.insert(n.id.as_str().to_owned()))
595            // Folder node remapped to an existing DB node — skip INSERT.
596            .filter(|n| !id_remap.contains_key(&n.id.as_str()))
597            .map(node_struct_literal)
598            .collect();
599
600        for chunk in rows.chunks(NODE_INSERT_CHUNK) {
601            let list = chunk.join(", ");
602            conn.query(&format!(
603                "UNWIND [{list}] AS r \
604                 CREATE (:{nt} {{\
605                    id: r.id, kind: r.kind, name: r.name, \
606                    qualified_name: r.qualified_name, file: r.file, \
607                    start_line: r.start_line, end_line: r.end_line, loc: r.loc, \
608                    visibility: r.visibility, is_async: r.is_async, is_unsafe: r.is_unsafe, \
609                    is_static: r.is_static, is_abstract: r.is_abstract, is_final: r.is_final, \
610                    is_property: r.is_property, is_generator: r.is_generator, is_const: r.is_const, \
611                    generic_bounds: r.generic_bounds, \
612                    def_signature: r.def_signature, def_body: r.def_body, def_doc: r.def_doc, \
613                    def_start_byte: r.def_start_byte, def_end_byte: r.def_end_byte, \
614                    complexity: r.complexity, annotations: r.annotations\
615                 }})"
616            ))
617            .map_err(|e| GitCortexError::Store(format!("batch insert nodes: {e}")))?;
618        }
619
620        // Commit node inserts so the edge MATCH queries in step 3 see them.
621        conn.query("COMMIT")
622            .map_err(|e| GitCortexError::Store(format!("commit nodes: {e}")))?;
623
624        // Transaction 3: insert edges and resolve deferred references.
625        conn.query("BEGIN TRANSACTION")
626            .map_err(|e| GitCortexError::Store(format!("begin edge transaction: {e}")))?;
627
628        // 4. Insert new edges. Deduplicate by (src,dst,kind) to avoid creating
629        //    parallel edges. Remap folder IDs to existing DB nodes where applicable.
630        //    MATCH yields nothing for missing endpoints → skip silently.
631        let mut seen_edges: HashSet<(String, String, String)> = HashSet::new();
632        let edge_rows: Vec<String> = diff
633            .added_edges
634            .iter()
635            .filter(|e| {
636                seen_edges.insert((
637                    e.src.as_str().to_owned(),
638                    e.dst.as_str().to_owned(),
639                    e.kind.to_string(),
640                ))
641            })
642            .map(|edge| {
643                let src_raw = edge.src.as_str();
644                let dst_raw = edge.dst.as_str();
645                let s = esc(id_remap
646                    .get(&src_raw)
647                    .map(String::as_str)
648                    .unwrap_or(&src_raw));
649                let d = esc(id_remap
650                    .get(&dst_raw)
651                    .map(String::as_str)
652                    .unwrap_or(&dst_raw));
653                let k = esc(&edge.kind.to_string());
654                let line = edge.line.map(|l| l as i64).unwrap_or(-1);
655                let conf = esc(&edge.confidence.to_string());
656                format!("{{s:'{s}', d:'{d}', k:'{k}', ln:{line}, cf:'{conf}'}}")
657            })
658            .collect();
659
660        // Batch edge inserts via `UNWIND … MATCH … CREATE`. Edge rows are tiny
661        // (three ids), so a larger chunk than nodes is fine. Endpoints missing
662        // from the store yield no MATCH row and are skipped silently — same
663        // semantics as the per-edge version.
664        for chunk in edge_rows.chunks(EDGE_INSERT_CHUNK) {
665            let list = chunk.join(", ");
666            conn.query(&format!(
667                "UNWIND [{list}] AS r \
668                 MATCH (s:{nt} {{id: r.s}}), (d:{nt} {{id: r.d}}) \
669                 CREATE (s)-[:{et} {{kind: r.k, line: r.ln, confidence: r.cf}}]->(d)"
670            ))
671            .map_err(|e| GitCortexError::Store(format!("batch insert edges: {e}")))?;
672        }
673
674        // 6. Resolve cross-file deferred edges against the full store.
675        //    The diff-local pass couldn't find these callees/types because they
676        //    live in unchanged files. Batched by language scope: one UNWIND query
677        //    per language per edge kind instead of one query per pair.
678        let caller_file: HashMap<String, String> = diff
679            .added_nodes
680            .iter()
681            .map(|n| {
682                (
683                    n.id.as_str().to_owned(),
684                    n.file.to_string_lossy().into_owned(),
685                )
686            })
687            .collect();
688
689        resolve_calls_batch(&conn, &nt, &et, &diff.deferred_calls, &caller_file)?;
690        resolve_deferred_batch(
691            &conn,
692            &nt,
693            &et,
694            &diff.deferred_uses,
695            &caller_file,
696            "uses",
697            "tgt.kind = 'struct' OR tgt.kind = 'enum' OR tgt.kind = 'trait' \
698             OR tgt.kind = 'interface' OR tgt.kind = 'type_alias'",
699        )?;
700        resolve_deferred_batch(
701            &conn,
702            &nt,
703            &et,
704            &diff.deferred_implements,
705            &caller_file,
706            "implements",
707            "tgt.kind = 'trait' OR tgt.kind = 'interface'",
708        )?;
709        resolve_deferred_batch(
710            &conn,
711            &nt,
712            &et,
713            &diff.deferred_inherits,
714            &caller_file,
715            "inherits",
716            "tgt.kind = 'struct' OR tgt.kind = 'interface' OR tgt.kind = 'trait'",
717        )?;
718        resolve_deferred_batch(
719            &conn,
720            &nt,
721            &et,
722            &diff.deferred_throws,
723            &caller_file,
724            "throws",
725            "",
726        )?;
727        resolve_deferred_batch(
728            &conn,
729            &nt,
730            &et,
731            &diff.deferred_annotated,
732            &caller_file,
733            "annotated",
734            "tgt.kind = 'annotation' OR tgt.kind = 'macro' OR tgt.kind = 'function'",
735        )?;
736        // No kind_filter: a doc reference can point at any code symbol kind.
737        // No language scoping happens here either — `caller_file` maps to a
738        // `.md` path, which `lang_scope_clause` doesn't recognise, so the
739        // scope clause it builds is empty (cross-language by design).
740        resolve_deferred_batch(
741            &conn,
742            &nt,
743            &et,
744            &diff.deferred_doc_refs,
745            &caller_file,
746            "references",
747            "",
748        )?;
749
750        conn.query("COMMIT")
751            .map_err(|e| GitCortexError::Store(format!("commit edges: {e}")))?;
752
753        Ok(())
754    }
755
756    // ── Read path ─────────────────────────────────────────────────────────────
757
758    fn lookup_symbol(&self, branch: &str, name: &str, fuzzy: bool) -> Result<Vec<Node>> {
759        self.ensure_branch(branch)?;
760        let nt = db_schema::node_table(branch);
761        let name_esc = esc(name);
762        let conn = self.conn()?;
763
764        let condition = if fuzzy {
765            format!("contains(n.name, '{name_esc}')")
766        } else {
767            format!("n.name = '{name_esc}'")
768        };
769
770        let mut result = conn
771            .query(&format!(
772                "MATCH (n:{nt}) WHERE {condition} RETURN {NODE_COLS} ORDER BY {SYMBOL_RANK}"
773            ))
774            .map_err(|e| GitCortexError::Store(e.to_string()))?;
775
776        rows_to_nodes(&mut result)
777    }
778
779    fn find_callers(&self, branch: &str, function_name: &str) -> Result<Vec<Node>> {
780        self.ensure_branch(branch)?;
781        let nt = db_schema::node_table(branch);
782        let et = db_schema::edge_table(branch);
783        let name_esc = esc(function_name);
784        let conn = self.conn()?;
785
786        let mut result = conn
787            .query(&format!(
788                "MATCH (n:{nt})-[:{et} {{kind: 'calls'}}]->(callee:{nt}) \
789                 WHERE callee.name = '{name_esc}' \
790                 RETURN DISTINCT {NODE_COLS}"
791            ))
792            .map_err(|e| GitCortexError::Store(e.to_string()))?;
793
794        rows_to_nodes(&mut result)
795    }
796
797    fn find_callers_with_confidence(
798        &self,
799        branch: &str,
800        function_name: &str,
801    ) -> Result<Vec<(Node, EdgeConfidence)>> {
802        self.ensure_branch(branch)?;
803        let nt = db_schema::node_table(branch);
804        let et = db_schema::edge_table(branch);
805        let name_esc = esc(function_name);
806        let conn = self.conn()?;
807
808        let result = conn
809            .query(&format!(
810                "MATCH (n:{nt})-[e:{et} {{kind: 'calls'}}]->(callee:{nt}) \
811                 WHERE callee.name = '{name_esc}' \
812                 RETURN {NODE_COLS}, e.confidence"
813            ))
814            .map_err(|e| GitCortexError::Store(e.to_string()))?;
815
816        let mut out = Vec::new();
817        for row in result {
818            if row.len() <= NODE_COL_COUNT {
819                tracing::debug!(
820                    "skipping short row ({} cols) in find_callers_with_confidence",
821                    row.len()
822                );
823                continue;
824            }
825            let conf_str = str_val(&row[NODE_COL_COUNT]).unwrap_or_default();
826            let confidence = EdgeConfidence::from_label(&conf_str);
827            match row_to_node(row) {
828                Ok(node) => out.push((node, confidence)),
829                Err(e) => {
830                    tracing::debug!("skipping malformed row in find_callers_with_confidence: {e}")
831                }
832            }
833        }
834        Ok(out)
835    }
836
837    fn find_callers_by_id_with_confidence(
838        &self,
839        branch: &str,
840        target_id: &str,
841    ) -> Result<Vec<(Node, EdgeConfidence)>> {
842        self.ensure_branch(branch)?;
843        let nt = db_schema::node_table(branch);
844        let et = db_schema::edge_table(branch);
845        let id_esc = esc(target_id);
846        let conn = self.conn()?;
847
848        let result = conn
849            .query(&format!(
850                "MATCH (n:{nt})-[e:{et} {{kind: 'calls'}}]->(callee:{nt}) \
851                 WHERE callee.id = '{id_esc}' \
852                 RETURN {NODE_COLS}, e.confidence"
853            ))
854            .map_err(|e| GitCortexError::Store(e.to_string()))?;
855
856        let mut out = Vec::new();
857        for row in result {
858            if row.len() <= NODE_COL_COUNT {
859                tracing::debug!(
860                    "skipping short row ({} cols) in find_callers_by_id_with_confidence",
861                    row.len()
862                );
863                continue;
864            }
865            let confidence =
866                EdgeConfidence::from_label(&str_val(&row[NODE_COL_COUNT]).unwrap_or_default());
867            match row_to_node(row) {
868                Ok(node) => out.push((node, confidence)),
869                Err(e) => tracing::debug!(
870                    "skipping malformed row in find_callers_by_id_with_confidence: {e}"
871                ),
872            }
873        }
874        Ok(out)
875    }
876
877    fn find_callers_deep(
878        &self,
879        branch: &str,
880        function_name: &str,
881        depth: u8,
882    ) -> Result<CallersDeep> {
883        let depth = depth.min(5);
884        let mut hops: Vec<Vec<Node>> = Vec::new();
885        // Track seen node IDs to avoid cycles.
886        let mut seen: HashSet<String> = HashSet::new();
887        // The frontier holds the *names* of nodes whose callers we search next.
888        let mut frontier: Vec<String> = vec![function_name.to_owned()];
889        seen.insert(function_name.to_owned());
890
891        for _ in 0..depth {
892            if frontier.is_empty() {
893                break;
894            }
895            let mut hop_nodes: Vec<Node> = Vec::new();
896            let mut next_frontier: Vec<String> = Vec::new();
897            for target in &frontier {
898                for caller in self.find_callers(branch, target)? {
899                    let id = caller.id.as_str().to_owned();
900                    if seen.insert(id) {
901                        next_frontier.push(caller.name.clone());
902                        hop_nodes.push(caller);
903                    }
904                }
905            }
906            hops.push(hop_nodes);
907            frontier = next_frontier;
908        }
909
910        let total_affected: usize = hops.iter().map(|h| h.len()).sum();
911        let risk_level = match total_affected {
912            0..=2 => "LOW",
913            3..=10 => "MEDIUM",
914            11..=30 => "HIGH",
915            _ => "CRITICAL",
916        };
917
918        Ok(CallersDeep { hops, risk_level })
919    }
920
921    fn symbol_context(&self, branch: &str, name: &str) -> Result<SymbolContext> {
922        self.ensure_branch(branch)?;
923        let nt = db_schema::node_table(branch);
924        let et = db_schema::edge_table(branch);
925        let name_esc = esc(name);
926        let conn = self.conn()?;
927
928        // Definition — best match by kind priority (type decl > fn/method >
929        // … > module/file), so `wiki Echo` resolves to `type Echo` not a
930        // same-named method.
931        let mut def_result = conn
932            .query(&format!(
933                "MATCH (n:{nt}) WHERE n.name = '{name_esc}' \
934                 RETURN {NODE_COLS} ORDER BY {SYMBOL_RANK} LIMIT 1"
935            ))
936            .map_err(|e| GitCortexError::Store(e.to_string()))?;
937        let mut defs = rows_to_nodes(&mut def_result)?;
938        if defs.is_empty() {
939            return Err(GitCortexError::Store(format!(
940                "symbol '{name}' not found on branch '{branch}'"
941            )));
942        }
943        let definition = defs.remove(0);
944
945        // Scope callers/callees/used-by to THIS specific definition (by id),
946        // not by name. Otherwise a Java `welcome` would pull in callees from
947        // a Python `welcome` that happens to share the name. `find_callers`
948        // as a standalone tool remains name-based — callers without a specific
949        // definition node have no other handle.
950        let def_id = esc(&definition.id.as_str());
951
952        let mut caller_result = conn
953            .query(&format!(
954                "MATCH (n:{nt})-[:{et} {{kind: 'calls'}}]->(callee:{nt}) \
955                 WHERE callee.id = '{def_id}' \
956                 RETURN DISTINCT {NODE_COLS}"
957            ))
958            .map_err(|e| GitCortexError::Store(e.to_string()))?;
959        let callers = rows_to_nodes(&mut caller_result)?;
960
961        let mut callee_result = conn
962            .query(&format!(
963                "MATCH (caller:{nt})-[:{et} {{kind: 'calls'}}]->(n:{nt}) \
964                 WHERE caller.id = '{def_id}' \
965                 RETURN {NODE_COLS}"
966            ))
967            .map_err(|e| GitCortexError::Store(e.to_string()))?;
968        let callees = rows_to_nodes(&mut callee_result)?;
969
970        let mut used_result = conn
971            .query(&format!(
972                "MATCH (n:{nt})-[:{et} {{kind: 'uses'}}]->(ty:{nt}) \
973                 WHERE ty.id = '{def_id}' \
974                 RETURN {NODE_COLS}"
975            ))
976            .map_err(|e| GitCortexError::Store(e.to_string()))?;
977        let used_by = rows_to_nodes(&mut used_result)?;
978
979        Ok(SymbolContext {
980            definition,
981            callers,
982            callees,
983            used_by,
984        })
985    }
986
987    fn list_definitions(&self, branch: &str, file: &Path) -> Result<Vec<Node>> {
988        self.ensure_branch(branch)?;
989        let nt = db_schema::node_table(branch);
990        let file_esc = esc(file.to_string_lossy().as_ref());
991        let conn = self.conn()?;
992
993        let mut result = conn
994            .query(&format!(
995                "MATCH (n:{nt}) WHERE n.file = '{file_esc}' \
996                 RETURN {NODE_COLS} ORDER BY n.start_line"
997            ))
998            .map_err(|e| GitCortexError::Store(e.to_string()))?;
999
1000        rows_to_nodes(&mut result)
1001    }
1002
1003    fn branch_diff(&self, from: &str, to: &str) -> Result<GraphDiff> {
1004        self.ensure_branch(from)?;
1005        self.ensure_branch(to)?;
1006
1007        let from_nt = db_schema::node_table(from);
1008        let to_nt = db_schema::node_table(to);
1009        let mut conn = self.conn()?;
1010
1011        // Collect node IDs from each branch.
1012        let from_ids = collect_ids(&mut conn, &from_nt)?;
1013        let to_ids = collect_ids(&mut conn, &to_nt)?;
1014
1015        // Nodes in `to` but not in `from` → added.
1016        let added_ids: Vec<&String> = to_ids.iter().filter(|id| !from_ids.contains(*id)).collect();
1017
1018        // Nodes in `from` but not in `to` → removed.
1019        let removed_ids: Vec<&String> =
1020            from_ids.iter().filter(|id| !to_ids.contains(*id)).collect();
1021
1022        let mut diff = GraphDiff::default();
1023
1024        for id in added_ids {
1025            let id_esc = esc(id);
1026            let mut r = conn
1027                .query(&format!(
1028                    "MATCH (n:{to_nt}) WHERE n.id = '{id_esc}' RETURN {NODE_COLS}"
1029                ))
1030                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1031            diff.added_nodes.extend(rows_to_nodes(&mut r)?);
1032        }
1033
1034        for id in removed_ids {
1035            if let Ok(node_id) = NodeId::try_from(id.as_str()) {
1036                diff.removed_node_ids.push(node_id);
1037            }
1038        }
1039
1040        let from_et = db_schema::edge_table(from);
1041        let to_et = db_schema::edge_table(to);
1042        let read_edges = |node_table: &str, edge_table: &str| -> Result<Vec<Edge>> {
1043            let result = conn
1044                .query(&format!(
1045                    "MATCH (s:{node_table})-[e:{edge_table}]->(d:{node_table}) \
1046                     RETURN s.id, d.id, e.kind, e.line, e.confidence"
1047                ))
1048                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1049            rows_to_edges(result)
1050        };
1051        let from_edges = read_edges(&from_nt, &from_et)?;
1052        let to_edges = read_edges(&to_nt, &to_et)?;
1053        let edge_key = |edge: &Edge| (edge.src.as_str(), edge.dst.as_str(), edge.kind.clone());
1054        let from_by_key: HashMap<_, _> = from_edges
1055            .iter()
1056            .map(|edge| (edge_key(edge), edge))
1057            .collect();
1058        let to_by_key: HashMap<_, _> = to_edges.iter().map(|edge| (edge_key(edge), edge)).collect();
1059
1060        for (key, edge) in &to_by_key {
1061            if from_by_key.get(key).map(|previous| *previous != *edge) != Some(false) {
1062                diff.added_edges.push((*edge).clone());
1063            }
1064        }
1065        for (key, edge) in &from_by_key {
1066            if to_by_key.get(key).map(|next| *next != *edge) != Some(false) {
1067                diff.removed_edges
1068                    .push((edge.src.clone(), edge.dst.clone(), edge.kind.clone()));
1069            }
1070        }
1071
1072        Ok(diff)
1073    }
1074
1075    fn list_all_nodes(&self, branch: &str) -> Result<Vec<Node>> {
1076        self.ensure_branch(branch)?;
1077        let nt = db_schema::node_table(branch);
1078        let conn = self.conn()?;
1079        let mut result = conn
1080            .query(&format!("MATCH (n:{nt}) RETURN {NODE_COLS}"))
1081            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1082        rows_to_nodes(&mut result)
1083    }
1084
1085    fn search_nodes(&self, branch: &str, query: &str, limit: usize) -> Result<Vec<Node>> {
1086        self.ensure_branch(branch)?;
1087        let nt = db_schema::node_table(branch);
1088        // Lowercase both sides for case-insensitive substring matching.
1089        let q = esc(&query.to_ascii_lowercase());
1090        let conn = self.conn()?;
1091        // Push substring filter into Cypher so only matching rows cross the FFI
1092        // boundary. A 500-candidate cap keeps scoring overhead bounded even on
1093        // very large repos. The in-process scorer in search.rs re-ranks and
1094        // truncates to the caller-supplied limit.
1095        let cap = (limit * 50).max(500);
1096        let mut result = conn
1097            .query(&format!(
1098                "MATCH (n:{nt}) \
1099                 WHERE contains(lower(n.name), '{q}') OR contains(lower(n.qualified_name), '{q}') \
1100                 RETURN {NODE_COLS} \
1101                 LIMIT {cap}"
1102            ))
1103            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1104        rows_to_nodes(&mut result)
1105    }
1106
1107    fn get_nodes_by_ids(&self, branch: &str, ids: &[String]) -> Result<Vec<Node>> {
1108        if ids.is_empty() {
1109            return Ok(Vec::new());
1110        }
1111        self.ensure_branch(branch)?;
1112        let nt = db_schema::node_table(branch);
1113        let conn = self.conn()?;
1114        let id_list = ids
1115            .iter()
1116            .map(|id| format!("'{}'", esc(id)))
1117            .collect::<Vec<_>>()
1118            .join(", ");
1119        let mut result = conn
1120            .query(&format!(
1121                "MATCH (n:{nt}) WHERE n.id IN [{id_list}] RETURN {NODE_COLS}"
1122            ))
1123            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1124        rows_to_nodes(&mut result)
1125    }
1126
1127    fn list_all_edges(&self, branch: &str) -> Result<Vec<Edge>> {
1128        self.ensure_branch(branch)?;
1129        let nt = db_schema::node_table(branch);
1130        let et = db_schema::edge_table(branch);
1131        let conn = self.conn()?;
1132        let result = conn
1133            .query(&format!(
1134                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) RETURN s.id, d.id, e.kind, e.line, e.confidence"
1135            ))
1136            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1137        rows_to_edges(result)
1138    }
1139
1140    fn list_nodes_page(&self, branch: &str, offset: usize, limit: usize) -> Result<Vec<Node>> {
1141        self.ensure_branch(branch)?;
1142        let nt = db_schema::node_table(branch);
1143        let conn = self.conn()?;
1144        let mut result = conn
1145            .query(&format!(
1146                "MATCH (n:{nt}) RETURN {NODE_COLS} ORDER BY n.id SKIP {offset} LIMIT {limit}"
1147            ))
1148            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1149        rows_to_nodes(&mut result)
1150    }
1151
1152    fn list_edges_page(&self, branch: &str, offset: usize, limit: usize) -> Result<Vec<Edge>> {
1153        self.ensure_branch(branch)?;
1154        let nt = db_schema::node_table(branch);
1155        let et = db_schema::edge_table(branch);
1156        let conn = self.conn()?;
1157        let result = conn
1158            .query(&format!(
1159                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) \
1160                 RETURN s.id, d.id, e.kind, e.line, e.confidence \
1161                 ORDER BY s.id, d.id, e.kind, e.line SKIP {offset} LIMIT {limit}"
1162            ))
1163            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1164        rows_to_edges(result)
1165    }
1166
1167    fn search_by_attributes(
1168        &self,
1169        branch: &str,
1170        filter: &AttributeFilter,
1171        limit: usize,
1172    ) -> Result<Vec<Node>> {
1173        self.ensure_branch(branch)?;
1174        let nt = db_schema::node_table(branch);
1175        let conn = self.conn()?;
1176
1177        // Build AND-joined WHERE clauses from the set predicates.
1178        let mut clauses: Vec<String> = Vec::new();
1179        if let Some(k) = &filter.kind {
1180            clauses.push(format!("n.kind = '{}'", esc(&k.to_string())));
1181        }
1182        if let Some(a) = filter.is_async {
1183            clauses.push(format!("n.is_async = {a}"));
1184        }
1185        if let Some(v) = &filter.visibility {
1186            clauses.push(format!("n.visibility = '{}'", esc(&vis_str(v))));
1187        }
1188        // complexity is stored as -1 when absent; a bound must also exclude -1.
1189        if let Some(min) = filter.min_complexity {
1190            clauses.push(format!("n.complexity >= {min} AND n.complexity >= 0"));
1191        }
1192        if let Some(max) = filter.max_complexity {
1193            clauses.push(format!("n.complexity <= {max} AND n.complexity >= 0"));
1194        }
1195        if let Some(sub) = &filter.name_contains {
1196            clauses.push(format!(
1197                "contains(lower(n.name), '{}')",
1198                esc(&sub.to_ascii_lowercase())
1199            ));
1200        }
1201        if let Some(ann) = &filter.annotation {
1202            // annotations stored pipe-joined; substring match finds the name.
1203            clauses.push(format!(
1204                "contains(lower(n.annotations), '{}')",
1205                esc(&ann.to_ascii_lowercase())
1206            ));
1207        }
1208
1209        let where_clause = if clauses.is_empty() {
1210            String::new()
1211        } else {
1212            format!("WHERE {}", clauses.join(" AND "))
1213        };
1214
1215        let mut result = conn
1216            .query(&format!(
1217                "MATCH (n:{nt}) {where_clause} \
1218                 RETURN {NODE_COLS} ORDER BY {SYMBOL_RANK} LIMIT {limit}"
1219            ))
1220            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1221        rows_to_nodes(&mut result)
1222    }
1223
1224    fn graph_stats(&self, branch: &str) -> Result<GraphStats> {
1225        self.ensure_branch(branch)?;
1226        let nt = db_schema::node_table(branch);
1227        let et = db_schema::edge_table(branch);
1228        let conn = self.conn()?;
1229
1230        // Per-kind counts pushed into Cypher so only aggregate rows cross FFI.
1231        let read_counts = |query: &str| -> Result<Vec<(String, u64)>> {
1232            let result = conn
1233                .query(query)
1234                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1235            let mut pairs: Vec<(String, u64)> = Vec::new();
1236            for row in result {
1237                let kind = str_val(&row[0])?;
1238                let count = i64_val(&row[1])?.max(0) as u64;
1239                pairs.push((kind, count));
1240            }
1241            // Count desc, then kind asc — deterministic, matches trait default.
1242            pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1243            Ok(pairs)
1244        };
1245
1246        let nodes_by_kind = read_counts(&format!("MATCH (n:{nt}) RETURN n.kind, count(*) AS c"))?;
1247        let edges_by_kind = read_counts(&format!(
1248            "MATCH (:{nt})-[e:{et}]->(:{nt}) RETURN e.kind, count(*) AS c"
1249        ))?;
1250
1251        Ok(GraphStats {
1252            total_nodes: nodes_by_kind.iter().map(|(_, c)| c).sum(),
1253            total_edges: edges_by_kind.iter().map(|(_, c)| c).sum(),
1254            nodes_by_kind,
1255            edges_by_kind,
1256        })
1257    }
1258
1259    fn find_callees(&self, branch: &str, function_name: &str, depth: u8) -> Result<CallersDeep> {
1260        let depth = depth.min(5);
1261        let mut hops: Vec<Vec<Node>> = Vec::new();
1262        let mut seen: HashSet<String> = HashSet::new();
1263        let mut frontier: Vec<String> = vec![function_name.to_owned()];
1264        seen.insert(function_name.to_owned());
1265
1266        for _ in 0..depth {
1267            if frontier.is_empty() {
1268                break;
1269            }
1270            let mut hop_nodes: Vec<Node> = Vec::new();
1271            let mut next_frontier: Vec<String> = Vec::new();
1272            for caller_name in &frontier {
1273                let nt = db_schema::node_table(branch);
1274                let et = db_schema::edge_table(branch);
1275                let name_esc = esc(caller_name);
1276                let conn = self.conn()?;
1277                let mut result = conn
1278                    .query(&format!(
1279                        "MATCH (caller:{nt})-[:{et} {{kind: 'calls'}}]->(n:{nt}) \
1280                         WHERE caller.name = '{name_esc}' \
1281                         RETURN {NODE_COLS}"
1282                    ))
1283                    .map_err(|e| GitCortexError::Store(e.to_string()))?;
1284                for node in rows_to_nodes(&mut result)? {
1285                    let id = node.id.as_str().to_owned();
1286                    if seen.insert(id) {
1287                        next_frontier.push(node.name.clone());
1288                        hop_nodes.push(node);
1289                    }
1290                }
1291            }
1292            hops.push(hop_nodes);
1293            frontier = next_frontier;
1294        }
1295
1296        let total: usize = hops.iter().map(|h| h.len()).sum();
1297        let risk_level = match total {
1298            0..=2 => "LOW",
1299            3..=10 => "MEDIUM",
1300            11..=30 => "HIGH",
1301            _ => "CRITICAL",
1302        };
1303        Ok(CallersDeep { hops, risk_level })
1304    }
1305
1306    fn find_implementors(&self, branch: &str, trait_or_interface_name: &str) -> Result<Vec<Node>> {
1307        self.ensure_branch(branch)?;
1308        let nt = db_schema::node_table(branch);
1309        let et = db_schema::edge_table(branch);
1310        let name_esc = esc(trait_or_interface_name);
1311        let conn = self.conn()?;
1312        let mut result = conn
1313            .query(&format!(
1314                "MATCH (n:{nt})-[e:{et}]->(trait_node:{nt}) \
1315                 WHERE trait_node.name = '{name_esc}' \
1316                 AND (e.kind = 'implements' OR e.kind = 'inherits') \
1317                 RETURN DISTINCT {NODE_COLS} ORDER BY {SYMBOL_RANK}"
1318            ))
1319            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1320        rows_to_nodes(&mut result)
1321    }
1322
1323    fn find_type_usages(&self, branch: &str, type_name: &str) -> Result<Vec<Node>> {
1324        self.ensure_branch(branch)?;
1325        let nt = db_schema::node_table(branch);
1326        let et = db_schema::edge_table(branch);
1327        let name_esc = esc(type_name);
1328        let conn = self.conn()?;
1329        let mut result = conn
1330            .query(&format!(
1331                "MATCH (n:{nt})-[e:{et} {{kind: 'uses'}}]->(ty:{nt}) \
1332                 WHERE ty.name = '{name_esc}' \
1333                 RETURN DISTINCT {NODE_COLS} ORDER BY {SYMBOL_RANK}"
1334            ))
1335            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1336        rows_to_nodes(&mut result)
1337    }
1338
1339    fn find_call_sites(&self, branch: &str, function_name: &str) -> Result<Vec<CallSite>> {
1340        self.ensure_branch(branch)?;
1341        let nt = db_schema::node_table(branch);
1342        let et = db_schema::edge_table(branch);
1343        let name_esc = esc(function_name);
1344        let conn = self.conn()?;
1345        // Return the caller columns plus the call edge's line. Alias caller as
1346        // `n` so NODE_COLS maps positionally; append e.line as the last column.
1347        let mut result = conn
1348            .query(&format!(
1349                "MATCH (n:{nt})-[e:{et} {{kind: 'calls'}}]->(callee:{nt}) \
1350                 WHERE callee.name = '{name_esc}' \
1351                 RETURN {NODE_COLS}, e.line ORDER BY {SYMBOL_RANK}"
1352            ))
1353            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1354
1355        let mut sites = Vec::new();
1356        for row in result.by_ref() {
1357            // NODE_COLS is 25 columns; e.line is the 26th (index 25).
1358            let line = row.get(25).and_then(|v| match v {
1359                kuzu::Value::Int64(n) if *n >= 0 => Some(*n as u32),
1360                _ => None,
1361            });
1362            match queries::row_to_node(row) {
1363                Ok(caller) => sites.push(CallSite { caller, line }),
1364                Err(e) => tracing::debug!("skipping malformed call-site row: {e}"),
1365            }
1366        }
1367        Ok(sites)
1368    }
1369
1370    fn find_importers(&self, branch: &str, symbol_name: &str) -> Result<Vec<Node>> {
1371        self.ensure_branch(branch)?;
1372        let nt = db_schema::node_table(branch);
1373        let et = db_schema::edge_table(branch);
1374        let name_esc = esc(symbol_name);
1375        let conn = self.conn()?;
1376        let mut result = conn
1377            .query(&format!(
1378                "MATCH (n:{nt})-[e:{et} {{kind: 'imports'}}]->(target:{nt}) \
1379                 WHERE target.name = '{name_esc}' \
1380                 RETURN DISTINCT {NODE_COLS} ORDER BY {SYMBOL_RANK}"
1381            ))
1382            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1383        rows_to_nodes(&mut result)
1384    }
1385
1386    fn type_hierarchy(&self, branch: &str, name: &str) -> Result<TypeHierarchy> {
1387        self.ensure_branch(branch)?;
1388        let nt = db_schema::node_table(branch);
1389        let et = db_schema::edge_table(branch);
1390        let name_esc = esc(name);
1391        let conn = self.conn()?;
1392
1393        // Supertypes: types this type implements or extends (self → super).
1394        let mut super_result = conn
1395            .query(&format!(
1396                "MATCH (n:{nt})-[e:{et}]->(super:{nt}) \
1397                 WHERE n.name = '{name_esc}' \
1398                 AND (e.kind = 'implements' OR e.kind = 'inherits') \
1399                 RETURN DISTINCT {} ORDER BY {}",
1400                NODE_COLS.replace("n.", "super."),
1401                SYMBOL_RANK.replace("n.", "super.")
1402            ))
1403            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1404        let supertypes = rows_to_nodes(&mut super_result)?;
1405
1406        // Subtypes: types that implement or extend this type (sub → self).
1407        let mut sub_result = conn
1408            .query(&format!(
1409                "MATCH (sub:{nt})-[e:{et}]->(n:{nt}) \
1410                 WHERE n.name = '{name_esc}' \
1411                 AND (e.kind = 'implements' OR e.kind = 'inherits') \
1412                 RETURN DISTINCT {} ORDER BY {}",
1413                NODE_COLS.replace("n.", "sub."),
1414                SYMBOL_RANK.replace("n.", "sub.")
1415            ))
1416            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1417        let subtypes = rows_to_nodes(&mut sub_result)?;
1418
1419        Ok(TypeHierarchy {
1420            supertypes,
1421            subtypes,
1422        })
1423    }
1424
1425    fn trace_path(&self, branch: &str, from: &str, to: &str) -> Result<Vec<Node>> {
1426        self.ensure_branch(branch)?;
1427        let nt = db_schema::node_table(branch);
1428        let et = db_schema::edge_table(branch);
1429
1430        // BFS from `from` to `to` following Calls edges.
1431        let from_esc = esc(from);
1432        let conn = self.conn()?;
1433        let mut start_result = conn
1434            .query(&format!(
1435                "MATCH (n:{nt}) WHERE n.name = '{from_esc}' RETURN {NODE_COLS} LIMIT 1"
1436            ))
1437            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1438        let start_nodes = rows_to_nodes(&mut start_result)?;
1439        if start_nodes.is_empty() {
1440            return Ok(Vec::new());
1441        }
1442
1443        // BFS: queue of (current_name, path_so_far)
1444        let mut queue: std::collections::VecDeque<(String, Vec<String>)> =
1445            std::collections::VecDeque::new();
1446        queue.push_back((from.to_owned(), vec![from.to_owned()]));
1447        let mut visited: HashSet<String> = HashSet::new();
1448        visited.insert(from.to_owned());
1449
1450        const MAX_HOPS: usize = 6;
1451        while let Some((current, path)) = queue.pop_front() {
1452            if path.len() > MAX_HOPS {
1453                continue;
1454            }
1455            let cur_esc = esc(&current);
1456            let conn2 = self.conn()?;
1457            let mut callee_result = conn2
1458                .query(&format!(
1459                    "MATCH (caller:{nt})-[:{et} {{kind: 'calls'}}]->(n:{nt}) \
1460                     WHERE caller.name = '{cur_esc}' \
1461                     RETURN {NODE_COLS}"
1462                ))
1463                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1464            for node in rows_to_nodes(&mut callee_result)? {
1465                let node_name = node.name.clone();
1466                if node_name == to {
1467                    // Found — resolve full path names to nodes
1468                    let mut result_nodes = Vec::new();
1469                    for name in &path {
1470                        let conn3 = self.conn()?;
1471                        let n_esc = esc(name);
1472                        let mut r = conn3
1473                            .query(&format!(
1474                                "MATCH (n:{nt}) WHERE n.name = '{n_esc}' RETURN {NODE_COLS} LIMIT 1"
1475                            ))
1476                            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1477                        result_nodes.extend(rows_to_nodes(&mut r)?);
1478                    }
1479                    result_nodes.push(node);
1480                    return Ok(result_nodes);
1481                }
1482                if visited.insert(node_name.clone()) {
1483                    let mut new_path = path.clone();
1484                    new_path.push(node_name.clone());
1485                    queue.push_back((node_name, new_path));
1486                }
1487            }
1488        }
1489        Ok(Vec::new())
1490    }
1491
1492    fn list_symbols_in_range(
1493        &self,
1494        branch: &str,
1495        file: &Path,
1496        start_line: u32,
1497        end_line: u32,
1498    ) -> Result<Vec<Node>> {
1499        self.ensure_branch(branch)?;
1500        let nt = db_schema::node_table(branch);
1501        let file_esc = esc(file.to_string_lossy().as_ref());
1502        let conn = self.conn()?;
1503
1504        let mut result = conn
1505            .query(&format!(
1506                "MATCH (n:{nt}) \
1507                 WHERE n.file = '{file_esc}' \
1508                 AND n.start_line <= {end_line} \
1509                 AND n.end_line >= {start_line} \
1510                 RETURN {NODE_COLS} ORDER BY n.start_line"
1511            ))
1512            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1513
1514        rows_to_nodes(&mut result)
1515    }
1516
1517    fn find_unused_symbols(&self, branch: &str, kind: Option<NodeKind>) -> Result<Vec<Node>> {
1518        self.ensure_branch(branch)?;
1519        let nt = db_schema::node_table(branch);
1520        let et = db_schema::edge_table(branch);
1521        let conn = self.conn()?;
1522
1523        let kind_filter = match &kind {
1524            Some(k) => format!("AND n.kind = '{k}'"),
1525            None => String::new(),
1526        };
1527
1528        let mut result = conn
1529            .query(&format!(
1530                "MATCH (n:{nt}) \
1531                 WHERE NOT EXISTS {{ MATCH (:{nt})-[:{et} {{kind: 'calls'}}]->(n) }} \
1532                 AND NOT EXISTS {{ MATCH (:{nt})-[:{et} {{kind: 'uses'}}]->(n) }} \
1533                 AND n.kind <> 'file' AND n.kind <> 'folder' AND n.kind <> 'module' \
1534                 {kind_filter} \
1535                 RETURN {NODE_COLS} ORDER BY n.file, n.start_line"
1536            ))
1537            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1538
1539        rows_to_nodes(&mut result)
1540    }
1541
1542    fn get_subgraph(
1543        &self,
1544        branch: &str,
1545        seed_name: &str,
1546        depth: u8,
1547        direction: &str,
1548    ) -> Result<SubGraph> {
1549        self.ensure_branch(branch)?;
1550        let depth = depth.min(5);
1551        let nt = db_schema::node_table(branch);
1552        let et = db_schema::edge_table(branch);
1553
1554        let seed_esc = esc(seed_name);
1555        let conn = self.conn()?;
1556        // Prefer code nodes over Section nodes: a class named "Gson" should be
1557        // the seed, not the README heading with the same name. Try code nodes
1558        // first; fall back to any match (including Section) only if nothing
1559        // else exists with that name.
1560        let mut seed_result = conn
1561            .query(&format!(
1562                "MATCH (n:{nt}) WHERE n.name = '{seed_esc}' AND n.kind <> 'section' \
1563                 RETURN {NODE_COLS} LIMIT 1"
1564            ))
1565            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1566        let mut seed_nodes = rows_to_nodes(&mut seed_result)?;
1567        if seed_nodes.is_empty() {
1568            // Fallback: accept any kind (covers seeds that are legitimately sections).
1569            let conn2 = self.conn()?;
1570            let mut fallback = conn2
1571                .query(&format!(
1572                    "MATCH (n:{nt}) WHERE n.name = '{seed_esc}' RETURN {NODE_COLS} LIMIT 1"
1573                ))
1574                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1575            seed_nodes = rows_to_nodes(&mut fallback)?;
1576        }
1577        if seed_nodes.is_empty() {
1578            return Ok(SubGraph {
1579                nodes: Vec::new(),
1580                edges: Vec::new(),
1581            });
1582        }
1583
1584        let mut all_node_ids: HashSet<String> = HashSet::new();
1585        let mut all_nodes: Vec<Node> = Vec::new();
1586        let mut frontier_names: Vec<String> = vec![seed_name.to_owned()];
1587
1588        for node in seed_nodes {
1589            all_node_ids.insert(node.id.as_str().to_owned());
1590            all_nodes.push(node);
1591        }
1592
1593        for _ in 0..depth {
1594            let mut next_frontier: Vec<String> = Vec::new();
1595            for name in &frontier_names {
1596                let name_esc = esc(name);
1597                // Outbound (callees): what this node calls
1598                if direction == "out" || direction == "both" {
1599                    let conn2 = self.conn()?;
1600                    let mut r = conn2
1601                        .query(&format!(
1602                            "MATCH (caller:{nt})-[:{et}]->(n:{nt}) \
1603                             WHERE caller.name = '{name_esc}' \
1604                             RETURN {NODE_COLS}"
1605                        ))
1606                        .map_err(|e| GitCortexError::Store(e.to_string()))?;
1607                    for node in rows_to_nodes(&mut r)? {
1608                        let id = node.id.as_str().to_owned();
1609                        if all_node_ids.insert(id) {
1610                            next_frontier.push(node.name.clone());
1611                            all_nodes.push(node);
1612                        }
1613                    }
1614                }
1615                // Inbound (callers): what calls this node
1616                if direction == "in" || direction == "both" {
1617                    let conn3 = self.conn()?;
1618                    let mut r = conn3
1619                        .query(&format!(
1620                            "MATCH (n:{nt})-[:{et}]->(target:{nt}) \
1621                             WHERE target.name = '{name_esc}' \
1622                             RETURN {NODE_COLS}"
1623                        ))
1624                        .map_err(|e| GitCortexError::Store(e.to_string()))?;
1625                    for node in rows_to_nodes(&mut r)? {
1626                        let id = node.id.as_str().to_owned();
1627                        if all_node_ids.insert(id) {
1628                            next_frontier.push(node.name.clone());
1629                            all_nodes.push(node);
1630                        }
1631                    }
1632                }
1633            }
1634            if next_frontier.is_empty() {
1635                break;
1636            }
1637            frontier_names = next_frontier;
1638        }
1639
1640        // Collect edges between the nodes in the subgraph
1641        let ids_list: Vec<String> = all_node_ids
1642            .iter()
1643            .map(|id| format!("'{}'", esc(id)))
1644            .collect();
1645        let ids_str = ids_list.join(", ");
1646        let all_edges = if ids_list.is_empty() {
1647            Vec::new()
1648        } else {
1649            let conn4 = self.conn()?;
1650            let result = conn4
1651                .query(&format!(
1652                    "MATCH (s:{nt})-[e:{et}]->(d:{nt}) \
1653                     WHERE s.id IN [{ids_str}] AND d.id IN [{ids_str}] \
1654                     RETURN s.id, d.id, e.kind, e.line, e.confidence"
1655                ))
1656                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1657            let mut edges = Vec::new();
1658            for row in result {
1659                let src_str = str_val(&row[0])?;
1660                let dst_str = str_val(&row[1])?;
1661                let kind_str = str_val(&row[2])?;
1662                let line = i64_val(&row[3]).ok().filter(|l| *l >= 0).map(|l| l as u32);
1663                let confidence = EdgeConfidence::from_label(&str_val(&row[4]).unwrap_or_default());
1664                edges.push(Edge {
1665                    src: NodeId::try_from(src_str.as_str())
1666                        .map_err(|e| GitCortexError::Store(format!("bad src id: {e}")))?,
1667                    dst: NodeId::try_from(dst_str.as_str())
1668                        .map_err(|e| GitCortexError::Store(format!("bad dst id: {e}")))?,
1669                    kind: edge_kind_from_str(&kind_str),
1670                    line,
1671                    confidence,
1672                });
1673            }
1674            edges
1675        };
1676
1677        Ok(SubGraph {
1678            nodes: all_nodes,
1679            edges: all_edges,
1680        })
1681    }
1682
1683    fn get_subgraph_by_id(
1684        &self,
1685        branch: &str,
1686        seed_id: &str,
1687        depth: u8,
1688        direction: &str,
1689    ) -> Result<SubGraph> {
1690        self.ensure_branch(branch)?;
1691        let depth = depth.min(5);
1692        let nt = db_schema::node_table(branch);
1693        let et = db_schema::edge_table(branch);
1694        let seed_esc = esc(seed_id);
1695        let conn = self.conn()?;
1696        let mut seed_result = conn
1697            .query(&format!(
1698                "MATCH (n:{nt}) WHERE n.id = '{seed_esc}' RETURN {NODE_COLS} LIMIT 1"
1699            ))
1700            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1701        let seed_nodes = rows_to_nodes(&mut seed_result)?;
1702        if seed_nodes.is_empty() {
1703            return Ok(SubGraph {
1704                nodes: Vec::new(),
1705                edges: Vec::new(),
1706            });
1707        }
1708
1709        let mut all_node_ids = HashSet::new();
1710        let mut all_nodes = Vec::new();
1711        for node in seed_nodes {
1712            all_node_ids.insert(node.id.as_str());
1713            all_nodes.push(node);
1714        }
1715        let mut frontier = vec![seed_id.to_owned()];
1716
1717        for _ in 0..depth {
1718            let mut next = Vec::new();
1719            for id in &frontier {
1720                let id_esc = esc(id);
1721                if direction == "out" || direction == "both" {
1722                    let conn2 = self.conn()?;
1723                    let mut result = conn2
1724                        .query(&format!(
1725                            "MATCH (seed:{nt})-[:{et}]->(n:{nt}) \
1726                             WHERE seed.id = '{id_esc}' RETURN {NODE_COLS}"
1727                        ))
1728                        .map_err(|e| GitCortexError::Store(e.to_string()))?;
1729                    for node in rows_to_nodes(&mut result)? {
1730                        let node_id = node.id.as_str();
1731                        if all_node_ids.insert(node_id.clone()) {
1732                            next.push(node_id);
1733                            all_nodes.push(node);
1734                        }
1735                    }
1736                }
1737                if direction == "in" || direction == "both" {
1738                    let conn3 = self.conn()?;
1739                    let mut result = conn3
1740                        .query(&format!(
1741                            "MATCH (n:{nt})-[:{et}]->(seed:{nt}) \
1742                             WHERE seed.id = '{id_esc}' RETURN {NODE_COLS}"
1743                        ))
1744                        .map_err(|e| GitCortexError::Store(e.to_string()))?;
1745                    for node in rows_to_nodes(&mut result)? {
1746                        let node_id = node.id.as_str();
1747                        if all_node_ids.insert(node_id.clone()) {
1748                            next.push(node_id);
1749                            all_nodes.push(node);
1750                        }
1751                    }
1752                }
1753            }
1754            if next.is_empty() {
1755                break;
1756            }
1757            frontier = next;
1758        }
1759
1760        let ids = all_node_ids
1761            .iter()
1762            .map(|id| format!("'{}'", esc(id)))
1763            .collect::<Vec<_>>()
1764            .join(", ");
1765        let conn4 = self.conn()?;
1766        let result = conn4
1767            .query(&format!(
1768                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) \
1769                 WHERE s.id IN [{ids}] AND d.id IN [{ids}] \
1770                 RETURN s.id, d.id, e.kind, e.line, e.confidence"
1771            ))
1772            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1773        let mut edges = Vec::new();
1774        for row in result {
1775            let src = str_val(&row[0])?;
1776            let dst = str_val(&row[1])?;
1777            let kind = str_val(&row[2])?;
1778            edges.push(Edge {
1779                src: NodeId::try_from(src.as_str())
1780                    .map_err(|e| GitCortexError::Store(format!("bad src id: {e}")))?,
1781                dst: NodeId::try_from(dst.as_str())
1782                    .map_err(|e| GitCortexError::Store(format!("bad dst id: {e}")))?,
1783                kind: edge_kind_from_str(&kind),
1784                line: i64_val(&row[3])
1785                    .ok()
1786                    .filter(|line| *line >= 0)
1787                    .map(|line| line as u32),
1788                confidence: EdgeConfidence::from_label(&str_val(&row[4]).unwrap_or_default()),
1789            });
1790        }
1791
1792        Ok(SubGraph {
1793            nodes: all_nodes,
1794            edges,
1795        })
1796    }
1797
1798    fn get_neighborhood_by_id(
1799        &self,
1800        branch: &str,
1801        seed_id: &str,
1802        direction: &str,
1803        limit: usize,
1804    ) -> Result<SubGraph> {
1805        self.ensure_branch(branch)?;
1806        let nt = db_schema::node_table(branch);
1807        let et = db_schema::edge_table(branch);
1808        let seed_esc = esc(seed_id);
1809        let condition = match direction {
1810            "in" => format!("d.id = '{seed_esc}'"),
1811            "out" => format!("s.id = '{seed_esc}'"),
1812            _ => format!("s.id = '{seed_esc}' OR d.id = '{seed_esc}'"),
1813        };
1814        let conn = self.conn()?;
1815        let result = conn
1816            .query(&format!(
1817                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) WHERE {condition} \
1818                 RETURN s.id, d.id, e.kind, e.line, e.confidence \
1819                 ORDER BY e.kind, s.id, d.id, e.line LIMIT {limit}"
1820            ))
1821            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1822        let edges = rows_to_edges(result)?;
1823        drop(conn);
1824
1825        let mut ids = HashSet::from([seed_id.to_owned()]);
1826        for edge in &edges {
1827            ids.insert(edge.src.as_str());
1828            ids.insert(edge.dst.as_str());
1829        }
1830        let mut id_list: Vec<String> = ids.into_iter().collect();
1831        id_list.sort();
1832        let nodes = self.get_nodes_by_ids(branch, &id_list)?;
1833        Ok(SubGraph { nodes, edges })
1834    }
1835
1836    // ── Indexing state ────────────────────────────────────────────────────────
1837
1838    fn last_indexed_sha(&self, branch_name: &str) -> Result<Option<String>> {
1839        branch::read_last_sha(&self.repo_id, branch_name)
1840    }
1841
1842    fn set_last_indexed_sha(&mut self, branch_name: &str, sha: &str) -> Result<()> {
1843        branch::write_last_sha(&self.repo_id, branch_name, sha)
1844    }
1845}