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};
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, rows_to_nodes, NODE_COLS, 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
95/// Bulk-load a full-index diff via CSV `COPY`. Stages CSVs in a unique temp
96/// dir, loads them, then removes the dir. See [`bulk`] for the rationale.
97fn bulk_apply(conn: &Connection, nt: &str, et: &str, diff: &GraphDiff) -> Result<()> {
98    // Unique staging dir per call: pid + nanos + a process-wide atomic counter,
99    // so concurrent `apply_diff`s (e.g. parallel tests in one binary) never
100    // share a directory.
101    use std::sync::atomic::{AtomicU64, Ordering};
102    static SEQ: AtomicU64 = AtomicU64::new(0);
103    let stage = std::env::temp_dir().join(format!(
104        "gcx-bulk-{}-{}-{}",
105        std::process::id(),
106        std::time::SystemTime::now()
107            .duration_since(std::time::UNIX_EPOCH)
108            .map(|d| d.as_nanos())
109            .unwrap_or(0),
110        SEQ.fetch_add(1, Ordering::Relaxed),
111    ));
112    std::fs::create_dir_all(&stage)
113        .map_err(|e| GitCortexError::Store(format!("create staging dir: {e}")))?;
114
115    let result = bulk::bulk_load(conn, nt, et, &stage, &diff.added_nodes, &diff.added_edges);
116
117    // Best-effort cleanup regardless of load outcome.
118    let _ = std::fs::remove_dir_all(&stage);
119
120    result.map(|_| ())
121}
122
123const DEFERRED_CHUNK: usize = 500;
124
125/// Resolve a batch of deferred cross-file edges via one UNWIND query per
126/// language-scope group instead of one query per pair.
127///
128/// Pairs are grouped by the caller's language family so the scope clause is
129/// uniform across all rows in a chunk. Each group is split into chunks of at
130/// most [`DEFERRED_CHUNK`] pairs to keep query strings bounded.
131fn resolve_deferred_batch(
132    conn: &Connection,
133    nt: &str,
134    et: &str,
135    pairs: &[(NodeId, String)],
136    caller_file: &HashMap<String, String>,
137    edge_kind: &str,
138    kind_filter: &str,
139) -> Result<()> {
140    if pairs.is_empty() {
141        return Ok(());
142    }
143    let mut by_scope: HashMap<String, Vec<(String, String)>> = HashMap::new();
144    for (src_id, tgt_name) in pairs {
145        let src_str = src_id.as_str();
146        let scope = caller_file
147            .get(src_str.as_str())
148            .map(|f| lang_scope_clause(f, "tgt"))
149            .unwrap_or_default();
150        by_scope
151            .entry(scope)
152            .or_default()
153            .push((src_str, tgt_name.clone()));
154    }
155    for (scope_clause, group) in &by_scope {
156        for chunk in group.chunks(DEFERRED_CHUNK) {
157            let list = chunk
158                .iter()
159                .map(|(src, tgt)| format!("{{s:'{}',t:'{}'}}", esc(src), esc(tgt)))
160                .collect::<Vec<_>>()
161                .join(",");
162            let kind_and = if kind_filter.is_empty() {
163                String::new()
164            } else {
165                format!(" AND ({kind_filter})")
166            };
167            conn.query(&format!(
168                "UNWIND [{list}] AS r \
169                 MATCH (src:{nt} {{id: r.s}}), (tgt:{nt}) \
170                 WHERE tgt.name = r.t{kind_and}{scope_clause} \
171                 CREATE (src)-[:{et} {{kind: '{edge_kind}', line: -1, confidence: 'inferred'}}]->(tgt)"
172            ))
173            .map_err(|e| GitCortexError::Store(format!("batch deferred {edge_kind}: {e}")))?;
174        }
175    }
176    Ok(())
177}
178
179/// Like [`resolve_deferred_batch`] but for `Calls` edges, carrying each call's
180/// source line onto the created edge. Tuples are `(caller_id, callee_name, line)`.
181fn resolve_calls_batch(
182    conn: &Connection,
183    nt: &str,
184    et: &str,
185    triples: &[(NodeId, String, u32)],
186    caller_file: &HashMap<String, String>,
187) -> Result<()> {
188    if triples.is_empty() {
189        return Ok(());
190    }
191    let mut by_scope: HashMap<String, Vec<(String, String, u32)>> = HashMap::new();
192    for (src_id, tgt_name, line) in triples {
193        let src_str = src_id.as_str();
194        let scope = caller_file
195            .get(src_str.as_str())
196            .map(|f| lang_scope_clause(f, "tgt"))
197            .unwrap_or_default();
198        by_scope
199            .entry(scope)
200            .or_default()
201            .push((src_str, tgt_name.clone(), *line));
202    }
203    for (scope_clause, group) in &by_scope {
204        for chunk in group.chunks(DEFERRED_CHUNK) {
205            let list = chunk
206                .iter()
207                .map(|(src, tgt, line)| {
208                    format!("{{s:'{}',t:'{}',ln:{}}}", esc(src), esc(tgt), line)
209                })
210                .collect::<Vec<_>>()
211                .join(",");
212            conn.query(&format!(
213                "UNWIND [{list}] AS r \
214                 MATCH (src:{nt} {{id: r.s}}), (tgt:{nt}) \
215                 WHERE tgt.name = r.t AND (tgt.kind = 'function' OR tgt.kind = 'method'){scope_clause} \
216                 CREATE (src)-[:{et} {{kind: 'calls', line: r.ln, confidence: 'inferred'}}]->(tgt)"
217            ))
218            .map_err(|e| GitCortexError::Store(format!("batch deferred calls: {e}")))?;
219        }
220    }
221    Ok(())
222}
223
224// ── KuzuGraphStore ────────────────────────────────────────────────────────────
225
226/// Local KuzuDB-backed implementation of [`GraphStore`].
227///
228/// One database file per repo (`graph.kuzu`), with per-branch node/edge tables
229/// inside it. A fresh `Connection` is created for each operation so we avoid
230/// the self-referential lifetime that `Mutex<Connection<'db>>` would require.
231pub struct KuzuGraphStore {
232    db: Database,
233    repo_id: String,
234}
235
236impl KuzuGraphStore {
237    /// Open (or create) the graph database for the repo at `repo_root`.
238    ///
239    /// If the persisted schema version doesn't match [`SCHEMA_VERSION`], the
240    /// entire repo data directory is wiped so a fresh full index runs on next
241    /// hook invocation.
242    pub fn open(repo_root: &Path) -> Result<Self> {
243        let repo_id = branch::repo_id(repo_root);
244
245        if branch::read_schema_version(&repo_id) != SCHEMA_VERSION {
246            eprintln!(
247                "gitcortex: schema version mismatch (expected {}); wiping graph store for re-index",
248                SCHEMA_VERSION
249            );
250            branch::wipe_repo_data(&repo_id);
251            branch::write_schema_version(&repo_id, SCHEMA_VERSION)?;
252        }
253
254        let db_path = branch::db_path(&repo_id);
255        if let Some(parent) = db_path.parent() {
256            std::fs::create_dir_all(parent)?;
257        }
258
259        let db = Database::new(&db_path, SystemConfig::default())
260            .map_err(|e| GitCortexError::Store(format!("open db: {e}")))?;
261
262        Ok(Self { db, repo_id })
263    }
264
265    // ── Private helpers ───────────────────────────────────────────────────────
266
267    fn conn(&self) -> Result<Connection<'_>> {
268        Connection::new(&self.db)
269            .map_err(|e| GitCortexError::Store(format!("open connection: {e}")))
270    }
271
272    fn ensure_branch(&self, branch: &str) -> Result<()> {
273        let mut conn = self.conn()?;
274        db_schema::ensure_branch(&mut conn, branch)
275    }
276}
277
278// ── GraphStore impl ───────────────────────────────────────────────────────────
279
280impl GraphStore for KuzuGraphStore {
281    // ── Write path ────────────────────────────────────────────────────────────
282
283    fn apply_diff(&mut self, branch: &str, diff: &GraphDiff) -> Result<()> {
284        if diff.is_empty() {
285            return Ok(());
286        }
287
288        self.ensure_branch(branch)?;
289        let nt = db_schema::node_table(branch);
290        let et = db_schema::edge_table(branch);
291        let conn = self.conn()?;
292
293        // ── Fast path: bulk COPY load for a fresh full index ───────────────────
294        // When the branch's node table is empty this is a first full index.
295        // Stage the nodes/edges as CSV and `COPY` them in — ~100× faster than
296        // per-row MATCH/CREATE on large repos.
297        //
298        // The diff's `removed_*` fields are ignored on this path: the indexer
299        // emits a `removed_files` entry for every parsed file + its ancestor
300        // folders (so an incremental re-parse first clears the old nodes), but
301        // against an empty table those deletes are vacuous. Deferred cross-file
302        // resolution is likewise skipped — on a full index every in-repo name
303        // is already in `added_edges`; the only `deferred_*` left are external
304        // (stdlib) names the store couldn't resolve anyway.
305        let empty = node_table_is_empty(&conn, &nt)?;
306        if std::env::var_os("GCX_TIMING").is_some() {
307            eprintln!(
308                "[gcx-timing] apply_diff path: table_empty={empty} nodes={} edges={}",
309                diff.added_nodes.len(),
310                diff.added_edges.len()
311            );
312        }
313        if empty {
314            return bulk_apply(&conn, &nt, &et, diff);
315        }
316
317        // Transaction 1: commit all deletes first.
318        // KuzuDB has a quirk where DETACH DELETE + CREATE in the same transaction
319        // can produce NULL for the last STRING column in newly created nodes.
320        // Splitting into separate transactions avoids this.
321        conn.query("BEGIN TRANSACTION")
322            .map_err(|e| GitCortexError::Store(format!("begin delete transaction: {e}")))?;
323
324        // 1. Remove nodes for deleted/replaced files.
325        //    Skip directory paths (no extension) — folder nodes are reused across
326        //    incremental updates to preserve their Contains edges to sibling files.
327        for file in &diff.removed_files {
328            if file.extension().is_none() {
329                continue;
330            }
331            let file_str = esc(file.to_string_lossy().as_ref());
332            conn.query(&format!(
333                "MATCH (n:{nt}) WHERE n.file = '{file_str}' DETACH DELETE n"
334            ))
335            .map_err(|e| GitCortexError::Store(format!("delete file nodes: {e}")))?;
336        }
337
338        // 2. Remove explicit node IDs.
339        for id in &diff.removed_node_ids {
340            let id_str = esc(&id.as_str());
341            conn.query(&format!(
342                "MATCH (n:{nt}) WHERE n.id = '{id_str}' DETACH DELETE n"
343            ))
344            .map_err(|e| GitCortexError::Store(format!("delete node: {e}")))?;
345        }
346
347        // 3. Remove explicit edges.
348        for (src, dst, kind) in &diff.removed_edges {
349            let s = esc(&src.as_str());
350            let d = esc(&dst.as_str());
351            let k = esc(&kind.to_string());
352            conn.query(&format!(
353                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) \
354                 WHERE s.id = '{s}' AND d.id = '{d}' AND e.kind = '{k}' \
355                 DELETE e"
356            ))
357            .map_err(|e| GitCortexError::Store(format!("delete edge: {e}")))?;
358        }
359
360        conn.query("COMMIT")
361            .map_err(|e| GitCortexError::Store(format!("commit deletes: {e}")))?;
362
363        // Build a remap table: for each Folder node in the diff, if a folder at
364        // that path already exists in the DB, reuse its ID so that existing
365        // Contains edges to sibling files are preserved.
366        // One batch query instead of one query per folder.
367        let mut id_remap: HashMap<String, String> = HashMap::new();
368        let folder_nodes: Vec<&Node> = diff
369            .added_nodes
370            .iter()
371            .filter(|n| n.kind == NodeKind::Folder)
372            .collect();
373        if !folder_nodes.is_empty() {
374            let path_list = folder_nodes
375                .iter()
376                .map(|n| format!("'{}'", esc(n.file.to_string_lossy().as_ref())))
377                .collect::<Vec<_>>()
378                .join(", ");
379            let mut rows = conn
380                .query(&format!(
381                    "MATCH (n:{nt}) WHERE n.file IN [{path_list}] AND n.kind = 'folder' \
382                     RETURN n.file, n.id"
383                ))
384                .map_err(|e| GitCortexError::Store(e.to_string()))?;
385            let mut existing_by_path: HashMap<String, String> = HashMap::new();
386            for row in rows.by_ref() {
387                if let (Ok(file), Ok(id)) = (str_val(&row[0]), str_val(&row[1])) {
388                    existing_by_path.insert(file, id);
389                }
390            }
391            for node in &folder_nodes {
392                let path_str = node.file.to_string_lossy().into_owned();
393                if let Some(existing_id) = existing_by_path.get(&path_str) {
394                    tracing::debug!("folder remap: {} → {}", node.file.display(), existing_id);
395                    id_remap.insert(node.id.as_str().to_owned(), existing_id.clone());
396                }
397            }
398        }
399
400        // Transaction 2: insert new nodes. Deduplicate by ID first so a rename
401        // delta (or any other case producing the same NodeId twice) never hits a
402        // PK violation. Folder nodes remapped to existing DB nodes are skipped.
403        conn.query("BEGIN TRANSACTION")
404            .map_err(|e| GitCortexError::Store(format!("begin node insert transaction: {e}")))?;
405
406        // Batch node inserts via `UNWIND [<struct>, …] CREATE`. One query per
407        // chunk instead of one per node — a ~100× cut in round-trips on a full
408        // index of a large repo. Chunk size is kept modest because each row
409        // carries the (truncated) def_body, so a chunk can still be a few MB.
410        let mut seen_node_ids: HashSet<String> = HashSet::new();
411        let rows: Vec<String> = diff
412            .added_nodes
413            .iter()
414            .filter(|n| seen_node_ids.insert(n.id.as_str().to_owned()))
415            // Folder node remapped to an existing DB node — skip INSERT.
416            .filter(|n| !id_remap.contains_key(&n.id.as_str()))
417            .map(node_struct_literal)
418            .collect();
419
420        for chunk in rows.chunks(NODE_INSERT_CHUNK) {
421            let list = chunk.join(", ");
422            conn.query(&format!(
423                "UNWIND [{list}] AS r \
424                 CREATE (:{nt} {{\
425                    id: r.id, kind: r.kind, name: r.name, \
426                    qualified_name: r.qualified_name, file: r.file, \
427                    start_line: r.start_line, end_line: r.end_line, loc: r.loc, \
428                    visibility: r.visibility, is_async: r.is_async, is_unsafe: r.is_unsafe, \
429                    is_static: r.is_static, is_abstract: r.is_abstract, is_final: r.is_final, \
430                    is_property: r.is_property, is_generator: r.is_generator, is_const: r.is_const, \
431                    generic_bounds: r.generic_bounds, \
432                    def_signature: r.def_signature, def_body: r.def_body, def_doc: r.def_doc, \
433                    def_start_byte: r.def_start_byte, def_end_byte: r.def_end_byte, \
434                    complexity: r.complexity, annotations: r.annotations\
435                 }})"
436            ))
437            .map_err(|e| GitCortexError::Store(format!("batch insert nodes: {e}")))?;
438        }
439
440        // Commit node inserts so the edge MATCH queries in step 3 see them.
441        conn.query("COMMIT")
442            .map_err(|e| GitCortexError::Store(format!("commit nodes: {e}")))?;
443
444        // Transaction 3: insert edges and resolve deferred references.
445        conn.query("BEGIN TRANSACTION")
446            .map_err(|e| GitCortexError::Store(format!("begin edge transaction: {e}")))?;
447
448        // 4. Insert new edges. Deduplicate by (src,dst,kind) to avoid creating
449        //    parallel edges. Remap folder IDs to existing DB nodes where applicable.
450        //    MATCH yields nothing for missing endpoints → skip silently.
451        let mut seen_edges: HashSet<(String, String, String)> = HashSet::new();
452        let edge_rows: Vec<String> = diff
453            .added_edges
454            .iter()
455            .filter(|e| {
456                seen_edges.insert((
457                    e.src.as_str().to_owned(),
458                    e.dst.as_str().to_owned(),
459                    e.kind.to_string(),
460                ))
461            })
462            .map(|edge| {
463                let src_raw = edge.src.as_str();
464                let dst_raw = edge.dst.as_str();
465                let s = esc(id_remap
466                    .get(&src_raw)
467                    .map(String::as_str)
468                    .unwrap_or(&src_raw));
469                let d = esc(id_remap
470                    .get(&dst_raw)
471                    .map(String::as_str)
472                    .unwrap_or(&dst_raw));
473                let k = esc(&edge.kind.to_string());
474                let line = edge.line.map(|l| l as i64).unwrap_or(-1);
475                let conf = esc(&edge.confidence.to_string());
476                format!("{{s:'{s}', d:'{d}', k:'{k}', ln:{line}, cf:'{conf}'}}")
477            })
478            .collect();
479
480        // Batch edge inserts via `UNWIND … MATCH … CREATE`. Edge rows are tiny
481        // (three ids), so a larger chunk than nodes is fine. Endpoints missing
482        // from the store yield no MATCH row and are skipped silently — same
483        // semantics as the per-edge version.
484        for chunk in edge_rows.chunks(EDGE_INSERT_CHUNK) {
485            let list = chunk.join(", ");
486            conn.query(&format!(
487                "UNWIND [{list}] AS r \
488                 MATCH (s:{nt} {{id: r.s}}), (d:{nt} {{id: r.d}}) \
489                 CREATE (s)-[:{et} {{kind: r.k, line: r.ln, confidence: r.cf}}]->(d)"
490            ))
491            .map_err(|e| GitCortexError::Store(format!("batch insert edges: {e}")))?;
492        }
493
494        // 6. Resolve cross-file deferred edges against the full store.
495        //    The diff-local pass couldn't find these callees/types because they
496        //    live in unchanged files. Batched by language scope: one UNWIND query
497        //    per language per edge kind instead of one query per pair.
498        let caller_file: HashMap<String, String> = diff
499            .added_nodes
500            .iter()
501            .map(|n| {
502                (
503                    n.id.as_str().to_owned(),
504                    n.file.to_string_lossy().into_owned(),
505                )
506            })
507            .collect();
508
509        resolve_calls_batch(&conn, &nt, &et, &diff.deferred_calls, &caller_file)?;
510        resolve_deferred_batch(
511            &conn,
512            &nt,
513            &et,
514            &diff.deferred_uses,
515            &caller_file,
516            "uses",
517            "tgt.kind = 'struct' OR tgt.kind = 'enum' OR tgt.kind = 'trait' \
518             OR tgt.kind = 'interface' OR tgt.kind = 'type_alias'",
519        )?;
520        resolve_deferred_batch(
521            &conn,
522            &nt,
523            &et,
524            &diff.deferred_implements,
525            &caller_file,
526            "implements",
527            "tgt.kind = 'trait' OR tgt.kind = 'interface'",
528        )?;
529        resolve_deferred_batch(
530            &conn,
531            &nt,
532            &et,
533            &diff.deferred_inherits,
534            &caller_file,
535            "inherits",
536            "tgt.kind = 'struct' OR tgt.kind = 'interface' OR tgt.kind = 'trait'",
537        )?;
538        resolve_deferred_batch(
539            &conn,
540            &nt,
541            &et,
542            &diff.deferred_throws,
543            &caller_file,
544            "throws",
545            "",
546        )?;
547        resolve_deferred_batch(
548            &conn,
549            &nt,
550            &et,
551            &diff.deferred_annotated,
552            &caller_file,
553            "annotated",
554            "tgt.kind = 'annotation' OR tgt.kind = 'macro' OR tgt.kind = 'function'",
555        )?;
556        // No kind_filter: a doc reference can point at any code symbol kind.
557        // No language scoping happens here either — `caller_file` maps to a
558        // `.md` path, which `lang_scope_clause` doesn't recognise, so the
559        // scope clause it builds is empty (cross-language by design).
560        resolve_deferred_batch(
561            &conn,
562            &nt,
563            &et,
564            &diff.deferred_doc_refs,
565            &caller_file,
566            "references",
567            "",
568        )?;
569
570        conn.query("COMMIT")
571            .map_err(|e| GitCortexError::Store(format!("commit edges: {e}")))?;
572
573        Ok(())
574    }
575
576    // ── Read path ─────────────────────────────────────────────────────────────
577
578    fn lookup_symbol(&self, branch: &str, name: &str, fuzzy: bool) -> Result<Vec<Node>> {
579        self.ensure_branch(branch)?;
580        let nt = db_schema::node_table(branch);
581        let name_esc = esc(name);
582        let conn = self.conn()?;
583
584        let condition = if fuzzy {
585            format!("contains(n.name, '{name_esc}')")
586        } else {
587            format!("n.name = '{name_esc}'")
588        };
589
590        let mut result = conn
591            .query(&format!(
592                "MATCH (n:{nt}) WHERE {condition} RETURN {NODE_COLS} ORDER BY {SYMBOL_RANK}"
593            ))
594            .map_err(|e| GitCortexError::Store(e.to_string()))?;
595
596        rows_to_nodes(&mut result)
597    }
598
599    fn find_callers(&self, branch: &str, function_name: &str) -> Result<Vec<Node>> {
600        self.ensure_branch(branch)?;
601        let nt = db_schema::node_table(branch);
602        let et = db_schema::edge_table(branch);
603        let name_esc = esc(function_name);
604        let conn = self.conn()?;
605
606        let mut result = conn
607            .query(&format!(
608                "MATCH (n:{nt})-[:{et} {{kind: 'calls'}}]->(callee:{nt}) \
609                 WHERE callee.name = '{name_esc}' \
610                 RETURN DISTINCT {NODE_COLS}"
611            ))
612            .map_err(|e| GitCortexError::Store(e.to_string()))?;
613
614        rows_to_nodes(&mut result)
615    }
616
617    fn find_callers_deep(
618        &self,
619        branch: &str,
620        function_name: &str,
621        depth: u8,
622    ) -> Result<CallersDeep> {
623        let depth = depth.min(5);
624        let mut hops: Vec<Vec<Node>> = Vec::new();
625        // Track seen node IDs to avoid cycles.
626        let mut seen: HashSet<String> = HashSet::new();
627        // The frontier holds the *names* of nodes whose callers we search next.
628        let mut frontier: Vec<String> = vec![function_name.to_owned()];
629        seen.insert(function_name.to_owned());
630
631        for _ in 0..depth {
632            if frontier.is_empty() {
633                break;
634            }
635            let mut hop_nodes: Vec<Node> = Vec::new();
636            let mut next_frontier: Vec<String> = Vec::new();
637            for target in &frontier {
638                for caller in self.find_callers(branch, target)? {
639                    let id = caller.id.as_str().to_owned();
640                    if seen.insert(id) {
641                        next_frontier.push(caller.name.clone());
642                        hop_nodes.push(caller);
643                    }
644                }
645            }
646            hops.push(hop_nodes);
647            frontier = next_frontier;
648        }
649
650        let total_affected: usize = hops.iter().map(|h| h.len()).sum();
651        let risk_level = match total_affected {
652            0..=2 => "LOW",
653            3..=10 => "MEDIUM",
654            11..=30 => "HIGH",
655            _ => "CRITICAL",
656        };
657
658        Ok(CallersDeep { hops, risk_level })
659    }
660
661    fn symbol_context(&self, branch: &str, name: &str) -> Result<SymbolContext> {
662        self.ensure_branch(branch)?;
663        let nt = db_schema::node_table(branch);
664        let et = db_schema::edge_table(branch);
665        let name_esc = esc(name);
666        let conn = self.conn()?;
667
668        // Definition — best match by kind priority (type decl > fn/method >
669        // … > module/file), so `wiki Echo` resolves to `type Echo` not a
670        // same-named method.
671        let mut def_result = conn
672            .query(&format!(
673                "MATCH (n:{nt}) WHERE n.name = '{name_esc}' \
674                 RETURN {NODE_COLS} ORDER BY {SYMBOL_RANK} LIMIT 1"
675            ))
676            .map_err(|e| GitCortexError::Store(e.to_string()))?;
677        let mut defs = rows_to_nodes(&mut def_result)?;
678        if defs.is_empty() {
679            return Err(GitCortexError::Store(format!(
680                "symbol '{name}' not found on branch '{branch}'"
681            )));
682        }
683        let definition = defs.remove(0);
684
685        // Scope callers/callees/used-by to THIS specific definition (by id),
686        // not by name. Otherwise a Java `welcome` would pull in callees from
687        // a Python `welcome` that happens to share the name. `find_callers`
688        // as a standalone tool remains name-based — callers without a specific
689        // definition node have no other handle.
690        let def_id = esc(&definition.id.as_str());
691
692        let mut caller_result = conn
693            .query(&format!(
694                "MATCH (n:{nt})-[:{et} {{kind: 'calls'}}]->(callee:{nt}) \
695                 WHERE callee.id = '{def_id}' \
696                 RETURN DISTINCT {NODE_COLS}"
697            ))
698            .map_err(|e| GitCortexError::Store(e.to_string()))?;
699        let callers = rows_to_nodes(&mut caller_result)?;
700
701        let mut callee_result = conn
702            .query(&format!(
703                "MATCH (caller:{nt})-[:{et} {{kind: 'calls'}}]->(n:{nt}) \
704                 WHERE caller.id = '{def_id}' \
705                 RETURN {NODE_COLS}"
706            ))
707            .map_err(|e| GitCortexError::Store(e.to_string()))?;
708        let callees = rows_to_nodes(&mut callee_result)?;
709
710        let mut used_result = conn
711            .query(&format!(
712                "MATCH (n:{nt})-[:{et} {{kind: 'uses'}}]->(ty:{nt}) \
713                 WHERE ty.id = '{def_id}' \
714                 RETURN {NODE_COLS}"
715            ))
716            .map_err(|e| GitCortexError::Store(e.to_string()))?;
717        let used_by = rows_to_nodes(&mut used_result)?;
718
719        Ok(SymbolContext {
720            definition,
721            callers,
722            callees,
723            used_by,
724        })
725    }
726
727    fn list_definitions(&self, branch: &str, file: &Path) -> Result<Vec<Node>> {
728        self.ensure_branch(branch)?;
729        let nt = db_schema::node_table(branch);
730        let file_esc = esc(file.to_string_lossy().as_ref());
731        let conn = self.conn()?;
732
733        let mut result = conn
734            .query(&format!(
735                "MATCH (n:{nt}) WHERE n.file = '{file_esc}' \
736                 RETURN {NODE_COLS} ORDER BY n.start_line"
737            ))
738            .map_err(|e| GitCortexError::Store(e.to_string()))?;
739
740        rows_to_nodes(&mut result)
741    }
742
743    fn branch_diff(&self, from: &str, to: &str) -> Result<GraphDiff> {
744        self.ensure_branch(from)?;
745        self.ensure_branch(to)?;
746
747        let from_nt = db_schema::node_table(from);
748        let to_nt = db_schema::node_table(to);
749        let mut conn = self.conn()?;
750
751        // Collect node IDs from each branch.
752        let from_ids = collect_ids(&mut conn, &from_nt)?;
753        let to_ids = collect_ids(&mut conn, &to_nt)?;
754
755        // Nodes in `to` but not in `from` → added.
756        let added_ids: Vec<&String> = to_ids.iter().filter(|id| !from_ids.contains(*id)).collect();
757
758        // Nodes in `from` but not in `to` → removed.
759        let removed_ids: Vec<&String> =
760            from_ids.iter().filter(|id| !to_ids.contains(*id)).collect();
761
762        let mut diff = GraphDiff::default();
763
764        for id in added_ids {
765            let id_esc = esc(id);
766            let mut r = conn
767                .query(&format!(
768                    "MATCH (n:{to_nt}) WHERE n.id = '{id_esc}' RETURN {NODE_COLS}"
769                ))
770                .map_err(|e| GitCortexError::Store(e.to_string()))?;
771            diff.added_nodes.extend(rows_to_nodes(&mut r)?);
772        }
773
774        for id in removed_ids {
775            if let Ok(node_id) = NodeId::try_from(id.as_str()) {
776                diff.removed_node_ids.push(node_id);
777            }
778        }
779
780        Ok(diff)
781    }
782
783    fn list_all_nodes(&self, branch: &str) -> Result<Vec<Node>> {
784        self.ensure_branch(branch)?;
785        let nt = db_schema::node_table(branch);
786        let conn = self.conn()?;
787        let mut result = conn
788            .query(&format!("MATCH (n:{nt}) RETURN {NODE_COLS}"))
789            .map_err(|e| GitCortexError::Store(e.to_string()))?;
790        rows_to_nodes(&mut result)
791    }
792
793    fn search_nodes(&self, branch: &str, query: &str, limit: usize) -> Result<Vec<Node>> {
794        self.ensure_branch(branch)?;
795        let nt = db_schema::node_table(branch);
796        // Lowercase both sides for case-insensitive substring matching.
797        let q = esc(&query.to_ascii_lowercase());
798        let conn = self.conn()?;
799        // Push substring filter into Cypher so only matching rows cross the FFI
800        // boundary. A 500-candidate cap keeps scoring overhead bounded even on
801        // very large repos. The in-process scorer in search.rs re-ranks and
802        // truncates to the caller-supplied limit.
803        let cap = (limit * 50).max(500);
804        let mut result = conn
805            .query(&format!(
806                "MATCH (n:{nt}) \
807                 WHERE contains(lower(n.name), '{q}') OR contains(lower(n.qualified_name), '{q}') \
808                 RETURN {NODE_COLS} \
809                 LIMIT {cap}"
810            ))
811            .map_err(|e| GitCortexError::Store(e.to_string()))?;
812        rows_to_nodes(&mut result)
813    }
814
815    fn get_nodes_by_ids(&self, branch: &str, ids: &[String]) -> Result<Vec<Node>> {
816        if ids.is_empty() {
817            return Ok(Vec::new());
818        }
819        self.ensure_branch(branch)?;
820        let nt = db_schema::node_table(branch);
821        let conn = self.conn()?;
822        let id_list = ids
823            .iter()
824            .map(|id| format!("'{}'", esc(id)))
825            .collect::<Vec<_>>()
826            .join(", ");
827        let mut result = conn
828            .query(&format!(
829                "MATCH (n:{nt}) WHERE n.id IN [{id_list}] RETURN {NODE_COLS}"
830            ))
831            .map_err(|e| GitCortexError::Store(e.to_string()))?;
832        rows_to_nodes(&mut result)
833    }
834
835    fn list_all_edges(&self, branch: &str) -> Result<Vec<Edge>> {
836        self.ensure_branch(branch)?;
837        let nt = db_schema::node_table(branch);
838        let et = db_schema::edge_table(branch);
839        let conn = self.conn()?;
840        let result = conn
841            .query(&format!(
842                "MATCH (s:{nt})-[e:{et}]->(d:{nt}) RETURN s.id, d.id, e.kind, e.line, e.confidence"
843            ))
844            .map_err(|e| GitCortexError::Store(e.to_string()))?;
845
846        let mut out = Vec::new();
847        for row in result {
848            let src_str = str_val(&row[0])?;
849            let dst_str = str_val(&row[1])?;
850            let kind_str = str_val(&row[2])?;
851            let line = i64_val(&row[3]).ok().filter(|l| *l >= 0).map(|l| l as u32);
852            let confidence = EdgeConfidence::from_label(&str_val(&row[4]).unwrap_or_default());
853            out.push(Edge {
854                src: NodeId::try_from(src_str.as_str())
855                    .map_err(|e| GitCortexError::Store(format!("bad src id: {e}")))?,
856                dst: NodeId::try_from(dst_str.as_str())
857                    .map_err(|e| GitCortexError::Store(format!("bad dst id: {e}")))?,
858                kind: edge_kind_from_str(&kind_str),
859                line,
860                confidence,
861            });
862        }
863        Ok(out)
864    }
865
866    fn search_by_attributes(
867        &self,
868        branch: &str,
869        filter: &AttributeFilter,
870        limit: usize,
871    ) -> Result<Vec<Node>> {
872        self.ensure_branch(branch)?;
873        let nt = db_schema::node_table(branch);
874        let conn = self.conn()?;
875
876        // Build AND-joined WHERE clauses from the set predicates.
877        let mut clauses: Vec<String> = Vec::new();
878        if let Some(k) = &filter.kind {
879            clauses.push(format!("n.kind = '{}'", esc(&k.to_string())));
880        }
881        if let Some(a) = filter.is_async {
882            clauses.push(format!("n.is_async = {a}"));
883        }
884        if let Some(v) = &filter.visibility {
885            clauses.push(format!("n.visibility = '{}'", esc(&vis_str(v))));
886        }
887        // complexity is stored as -1 when absent; a bound must also exclude -1.
888        if let Some(min) = filter.min_complexity {
889            clauses.push(format!("n.complexity >= {min} AND n.complexity >= 0"));
890        }
891        if let Some(max) = filter.max_complexity {
892            clauses.push(format!("n.complexity <= {max} AND n.complexity >= 0"));
893        }
894        if let Some(sub) = &filter.name_contains {
895            clauses.push(format!(
896                "contains(lower(n.name), '{}')",
897                esc(&sub.to_ascii_lowercase())
898            ));
899        }
900        if let Some(ann) = &filter.annotation {
901            // annotations stored pipe-joined; substring match finds the name.
902            clauses.push(format!(
903                "contains(lower(n.annotations), '{}')",
904                esc(&ann.to_ascii_lowercase())
905            ));
906        }
907
908        let where_clause = if clauses.is_empty() {
909            String::new()
910        } else {
911            format!("WHERE {}", clauses.join(" AND "))
912        };
913
914        let mut result = conn
915            .query(&format!(
916                "MATCH (n:{nt}) {where_clause} \
917                 RETURN {NODE_COLS} ORDER BY {SYMBOL_RANK} LIMIT {limit}"
918            ))
919            .map_err(|e| GitCortexError::Store(e.to_string()))?;
920        rows_to_nodes(&mut result)
921    }
922
923    fn graph_stats(&self, branch: &str) -> Result<GraphStats> {
924        self.ensure_branch(branch)?;
925        let nt = db_schema::node_table(branch);
926        let et = db_schema::edge_table(branch);
927        let conn = self.conn()?;
928
929        // Per-kind counts pushed into Cypher so only aggregate rows cross FFI.
930        let read_counts = |query: &str| -> Result<Vec<(String, u64)>> {
931            let result = conn
932                .query(query)
933                .map_err(|e| GitCortexError::Store(e.to_string()))?;
934            let mut pairs: Vec<(String, u64)> = Vec::new();
935            for row in result {
936                let kind = str_val(&row[0])?;
937                let count = i64_val(&row[1])?.max(0) as u64;
938                pairs.push((kind, count));
939            }
940            // Count desc, then kind asc — deterministic, matches trait default.
941            pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
942            Ok(pairs)
943        };
944
945        let nodes_by_kind = read_counts(&format!("MATCH (n:{nt}) RETURN n.kind, count(*) AS c"))?;
946        let edges_by_kind = read_counts(&format!(
947            "MATCH (:{nt})-[e:{et}]->(:{nt}) RETURN e.kind, count(*) AS c"
948        ))?;
949
950        Ok(GraphStats {
951            total_nodes: nodes_by_kind.iter().map(|(_, c)| c).sum(),
952            total_edges: edges_by_kind.iter().map(|(_, c)| c).sum(),
953            nodes_by_kind,
954            edges_by_kind,
955        })
956    }
957
958    fn find_callees(&self, branch: &str, function_name: &str, depth: u8) -> Result<CallersDeep> {
959        let depth = depth.min(5);
960        let mut hops: Vec<Vec<Node>> = Vec::new();
961        let mut seen: HashSet<String> = HashSet::new();
962        let mut frontier: Vec<String> = vec![function_name.to_owned()];
963        seen.insert(function_name.to_owned());
964
965        for _ in 0..depth {
966            if frontier.is_empty() {
967                break;
968            }
969            let mut hop_nodes: Vec<Node> = Vec::new();
970            let mut next_frontier: Vec<String> = Vec::new();
971            for caller_name in &frontier {
972                let nt = db_schema::node_table(branch);
973                let et = db_schema::edge_table(branch);
974                let name_esc = esc(caller_name);
975                let conn = self.conn()?;
976                let mut result = conn
977                    .query(&format!(
978                        "MATCH (caller:{nt})-[:{et} {{kind: 'calls'}}]->(n:{nt}) \
979                         WHERE caller.name = '{name_esc}' \
980                         RETURN {NODE_COLS}"
981                    ))
982                    .map_err(|e| GitCortexError::Store(e.to_string()))?;
983                for node in rows_to_nodes(&mut result)? {
984                    let id = node.id.as_str().to_owned();
985                    if seen.insert(id) {
986                        next_frontier.push(node.name.clone());
987                        hop_nodes.push(node);
988                    }
989                }
990            }
991            hops.push(hop_nodes);
992            frontier = next_frontier;
993        }
994
995        let total: usize = hops.iter().map(|h| h.len()).sum();
996        let risk_level = match total {
997            0..=2 => "LOW",
998            3..=10 => "MEDIUM",
999            11..=30 => "HIGH",
1000            _ => "CRITICAL",
1001        };
1002        Ok(CallersDeep { hops, risk_level })
1003    }
1004
1005    fn find_implementors(&self, branch: &str, trait_or_interface_name: &str) -> Result<Vec<Node>> {
1006        self.ensure_branch(branch)?;
1007        let nt = db_schema::node_table(branch);
1008        let et = db_schema::edge_table(branch);
1009        let name_esc = esc(trait_or_interface_name);
1010        let conn = self.conn()?;
1011        let mut result = conn
1012            .query(&format!(
1013                "MATCH (n:{nt})-[e:{et}]->(trait_node:{nt}) \
1014                 WHERE trait_node.name = '{name_esc}' \
1015                 AND (e.kind = 'implements' OR e.kind = 'inherits') \
1016                 RETURN DISTINCT {NODE_COLS} ORDER BY {SYMBOL_RANK}"
1017            ))
1018            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1019        rows_to_nodes(&mut result)
1020    }
1021
1022    fn find_type_usages(&self, branch: &str, type_name: &str) -> Result<Vec<Node>> {
1023        self.ensure_branch(branch)?;
1024        let nt = db_schema::node_table(branch);
1025        let et = db_schema::edge_table(branch);
1026        let name_esc = esc(type_name);
1027        let conn = self.conn()?;
1028        let mut result = conn
1029            .query(&format!(
1030                "MATCH (n:{nt})-[e:{et} {{kind: 'uses'}}]->(ty:{nt}) \
1031                 WHERE ty.name = '{name_esc}' \
1032                 RETURN DISTINCT {NODE_COLS} ORDER BY {SYMBOL_RANK}"
1033            ))
1034            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1035        rows_to_nodes(&mut result)
1036    }
1037
1038    fn find_call_sites(&self, branch: &str, function_name: &str) -> Result<Vec<CallSite>> {
1039        self.ensure_branch(branch)?;
1040        let nt = db_schema::node_table(branch);
1041        let et = db_schema::edge_table(branch);
1042        let name_esc = esc(function_name);
1043        let conn = self.conn()?;
1044        // Return the caller columns plus the call edge's line. Alias caller as
1045        // `n` so NODE_COLS maps positionally; append e.line as the last column.
1046        let mut result = conn
1047            .query(&format!(
1048                "MATCH (n:{nt})-[e:{et} {{kind: 'calls'}}]->(callee:{nt}) \
1049                 WHERE callee.name = '{name_esc}' \
1050                 RETURN {NODE_COLS}, e.line ORDER BY {SYMBOL_RANK}"
1051            ))
1052            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1053
1054        let mut sites = Vec::new();
1055        for row in result.by_ref() {
1056            // NODE_COLS is 25 columns; e.line is the 26th (index 25).
1057            let line = row.get(25).and_then(|v| match v {
1058                kuzu::Value::Int64(n) if *n >= 0 => Some(*n as u32),
1059                _ => None,
1060            });
1061            match queries::row_to_node(row) {
1062                Ok(caller) => sites.push(CallSite { caller, line }),
1063                Err(e) => tracing::debug!("skipping malformed call-site row: {e}"),
1064            }
1065        }
1066        Ok(sites)
1067    }
1068
1069    fn find_importers(&self, branch: &str, symbol_name: &str) -> Result<Vec<Node>> {
1070        self.ensure_branch(branch)?;
1071        let nt = db_schema::node_table(branch);
1072        let et = db_schema::edge_table(branch);
1073        let name_esc = esc(symbol_name);
1074        let conn = self.conn()?;
1075        let mut result = conn
1076            .query(&format!(
1077                "MATCH (n:{nt})-[e:{et} {{kind: 'imports'}}]->(target:{nt}) \
1078                 WHERE target.name = '{name_esc}' \
1079                 RETURN DISTINCT {NODE_COLS} ORDER BY {SYMBOL_RANK}"
1080            ))
1081            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1082        rows_to_nodes(&mut result)
1083    }
1084
1085    fn type_hierarchy(&self, branch: &str, name: &str) -> Result<TypeHierarchy> {
1086        self.ensure_branch(branch)?;
1087        let nt = db_schema::node_table(branch);
1088        let et = db_schema::edge_table(branch);
1089        let name_esc = esc(name);
1090        let conn = self.conn()?;
1091
1092        // Supertypes: types this type implements or extends (self → super).
1093        let mut super_result = conn
1094            .query(&format!(
1095                "MATCH (n:{nt})-[e:{et}]->(super:{nt}) \
1096                 WHERE n.name = '{name_esc}' \
1097                 AND (e.kind = 'implements' OR e.kind = 'inherits') \
1098                 RETURN DISTINCT {} ORDER BY {}",
1099                NODE_COLS.replace("n.", "super."),
1100                SYMBOL_RANK.replace("n.", "super.")
1101            ))
1102            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1103        let supertypes = rows_to_nodes(&mut super_result)?;
1104
1105        // Subtypes: types that implement or extend this type (sub → self).
1106        let mut sub_result = conn
1107            .query(&format!(
1108                "MATCH (sub:{nt})-[e:{et}]->(n:{nt}) \
1109                 WHERE n.name = '{name_esc}' \
1110                 AND (e.kind = 'implements' OR e.kind = 'inherits') \
1111                 RETURN DISTINCT {} ORDER BY {}",
1112                NODE_COLS.replace("n.", "sub."),
1113                SYMBOL_RANK.replace("n.", "sub.")
1114            ))
1115            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1116        let subtypes = rows_to_nodes(&mut sub_result)?;
1117
1118        Ok(TypeHierarchy {
1119            supertypes,
1120            subtypes,
1121        })
1122    }
1123
1124    fn trace_path(&self, branch: &str, from: &str, to: &str) -> Result<Vec<Node>> {
1125        self.ensure_branch(branch)?;
1126        let nt = db_schema::node_table(branch);
1127        let et = db_schema::edge_table(branch);
1128
1129        // BFS from `from` to `to` following Calls edges.
1130        let from_esc = esc(from);
1131        let conn = self.conn()?;
1132        let mut start_result = conn
1133            .query(&format!(
1134                "MATCH (n:{nt}) WHERE n.name = '{from_esc}' RETURN {NODE_COLS} LIMIT 1"
1135            ))
1136            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1137        let start_nodes = rows_to_nodes(&mut start_result)?;
1138        if start_nodes.is_empty() {
1139            return Ok(Vec::new());
1140        }
1141
1142        // BFS: queue of (current_name, path_so_far)
1143        let mut queue: std::collections::VecDeque<(String, Vec<String>)> =
1144            std::collections::VecDeque::new();
1145        queue.push_back((from.to_owned(), vec![from.to_owned()]));
1146        let mut visited: HashSet<String> = HashSet::new();
1147        visited.insert(from.to_owned());
1148
1149        const MAX_HOPS: usize = 6;
1150        while let Some((current, path)) = queue.pop_front() {
1151            if path.len() > MAX_HOPS {
1152                continue;
1153            }
1154            let cur_esc = esc(&current);
1155            let conn2 = self.conn()?;
1156            let mut callee_result = conn2
1157                .query(&format!(
1158                    "MATCH (caller:{nt})-[:{et} {{kind: 'calls'}}]->(n:{nt}) \
1159                     WHERE caller.name = '{cur_esc}' \
1160                     RETURN {NODE_COLS}"
1161                ))
1162                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1163            for node in rows_to_nodes(&mut callee_result)? {
1164                let node_name = node.name.clone();
1165                if node_name == to {
1166                    // Found — resolve full path names to nodes
1167                    let mut result_nodes = Vec::new();
1168                    for name in &path {
1169                        let conn3 = self.conn()?;
1170                        let n_esc = esc(name);
1171                        let mut r = conn3
1172                            .query(&format!(
1173                                "MATCH (n:{nt}) WHERE n.name = '{n_esc}' RETURN {NODE_COLS} LIMIT 1"
1174                            ))
1175                            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1176                        result_nodes.extend(rows_to_nodes(&mut r)?);
1177                    }
1178                    result_nodes.push(node);
1179                    return Ok(result_nodes);
1180                }
1181                if visited.insert(node_name.clone()) {
1182                    let mut new_path = path.clone();
1183                    new_path.push(node_name.clone());
1184                    queue.push_back((node_name, new_path));
1185                }
1186            }
1187        }
1188        Ok(Vec::new())
1189    }
1190
1191    fn list_symbols_in_range(
1192        &self,
1193        branch: &str,
1194        file: &Path,
1195        start_line: u32,
1196        end_line: u32,
1197    ) -> Result<Vec<Node>> {
1198        self.ensure_branch(branch)?;
1199        let nt = db_schema::node_table(branch);
1200        let file_esc = esc(file.to_string_lossy().as_ref());
1201        let conn = self.conn()?;
1202
1203        let mut result = conn
1204            .query(&format!(
1205                "MATCH (n:{nt}) \
1206                 WHERE n.file = '{file_esc}' \
1207                 AND n.start_line <= {end_line} \
1208                 AND n.end_line >= {start_line} \
1209                 RETURN {NODE_COLS} ORDER BY n.start_line"
1210            ))
1211            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1212
1213        rows_to_nodes(&mut result)
1214    }
1215
1216    fn find_unused_symbols(&self, branch: &str, kind: Option<NodeKind>) -> Result<Vec<Node>> {
1217        self.ensure_branch(branch)?;
1218        let nt = db_schema::node_table(branch);
1219        let et = db_schema::edge_table(branch);
1220        let conn = self.conn()?;
1221
1222        let kind_filter = match &kind {
1223            Some(k) => format!("AND n.kind = '{k}'"),
1224            None => String::new(),
1225        };
1226
1227        let mut result = conn
1228            .query(&format!(
1229                "MATCH (n:{nt}) \
1230                 WHERE NOT EXISTS {{ MATCH (:{nt})-[:{et} {{kind: 'calls'}}]->(n) }} \
1231                 AND NOT EXISTS {{ MATCH (:{nt})-[:{et} {{kind: 'uses'}}]->(n) }} \
1232                 AND n.kind <> 'file' AND n.kind <> 'folder' AND n.kind <> 'module' \
1233                 {kind_filter} \
1234                 RETURN {NODE_COLS} ORDER BY n.file, n.start_line"
1235            ))
1236            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1237
1238        rows_to_nodes(&mut result)
1239    }
1240
1241    fn get_subgraph(
1242        &self,
1243        branch: &str,
1244        seed_name: &str,
1245        depth: u8,
1246        direction: &str,
1247    ) -> Result<SubGraph> {
1248        self.ensure_branch(branch)?;
1249        let depth = depth.min(5);
1250        let nt = db_schema::node_table(branch);
1251        let et = db_schema::edge_table(branch);
1252
1253        let seed_esc = esc(seed_name);
1254        let conn = self.conn()?;
1255        // Prefer code nodes over Section nodes: a class named "Gson" should be
1256        // the seed, not the README heading with the same name. Try code nodes
1257        // first; fall back to any match (including Section) only if nothing
1258        // else exists with that name.
1259        let mut seed_result = conn
1260            .query(&format!(
1261                "MATCH (n:{nt}) WHERE n.name = '{seed_esc}' AND n.kind <> 'section' \
1262                 RETURN {NODE_COLS} LIMIT 1"
1263            ))
1264            .map_err(|e| GitCortexError::Store(e.to_string()))?;
1265        let mut seed_nodes = rows_to_nodes(&mut seed_result)?;
1266        if seed_nodes.is_empty() {
1267            // Fallback: accept any kind (covers seeds that are legitimately sections).
1268            let conn2 = self.conn()?;
1269            let mut fallback = conn2
1270                .query(&format!(
1271                    "MATCH (n:{nt}) WHERE n.name = '{seed_esc}' RETURN {NODE_COLS} LIMIT 1"
1272                ))
1273                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1274            seed_nodes = rows_to_nodes(&mut fallback)?;
1275        }
1276        if seed_nodes.is_empty() {
1277            return Ok(SubGraph {
1278                nodes: Vec::new(),
1279                edges: Vec::new(),
1280            });
1281        }
1282
1283        let mut all_node_ids: HashSet<String> = HashSet::new();
1284        let mut all_nodes: Vec<Node> = Vec::new();
1285        let mut frontier_names: Vec<String> = vec![seed_name.to_owned()];
1286
1287        for node in seed_nodes {
1288            all_node_ids.insert(node.id.as_str().to_owned());
1289            all_nodes.push(node);
1290        }
1291
1292        for _ in 0..depth {
1293            let mut next_frontier: Vec<String> = Vec::new();
1294            for name in &frontier_names {
1295                let name_esc = esc(name);
1296                // Outbound (callees): what this node calls
1297                if direction == "out" || direction == "both" {
1298                    let conn2 = self.conn()?;
1299                    let mut r = conn2
1300                        .query(&format!(
1301                            "MATCH (caller:{nt})-[:{et}]->(n:{nt}) \
1302                             WHERE caller.name = '{name_esc}' \
1303                             RETURN {NODE_COLS}"
1304                        ))
1305                        .map_err(|e| GitCortexError::Store(e.to_string()))?;
1306                    for node in rows_to_nodes(&mut r)? {
1307                        let id = node.id.as_str().to_owned();
1308                        if all_node_ids.insert(id) {
1309                            next_frontier.push(node.name.clone());
1310                            all_nodes.push(node);
1311                        }
1312                    }
1313                }
1314                // Inbound (callers): what calls this node
1315                if direction == "in" || direction == "both" {
1316                    let conn3 = self.conn()?;
1317                    let mut r = conn3
1318                        .query(&format!(
1319                            "MATCH (n:{nt})-[:{et}]->(target:{nt}) \
1320                             WHERE target.name = '{name_esc}' \
1321                             RETURN {NODE_COLS}"
1322                        ))
1323                        .map_err(|e| GitCortexError::Store(e.to_string()))?;
1324                    for node in rows_to_nodes(&mut r)? {
1325                        let id = node.id.as_str().to_owned();
1326                        if all_node_ids.insert(id) {
1327                            next_frontier.push(node.name.clone());
1328                            all_nodes.push(node);
1329                        }
1330                    }
1331                }
1332            }
1333            if next_frontier.is_empty() {
1334                break;
1335            }
1336            frontier_names = next_frontier;
1337        }
1338
1339        // Collect edges between the nodes in the subgraph
1340        let ids_list: Vec<String> = all_node_ids
1341            .iter()
1342            .map(|id| format!("'{}'", esc(id)))
1343            .collect();
1344        let ids_str = ids_list.join(", ");
1345        let all_edges = if ids_list.is_empty() {
1346            Vec::new()
1347        } else {
1348            let conn4 = self.conn()?;
1349            let result = conn4
1350                .query(&format!(
1351                    "MATCH (s:{nt})-[e:{et}]->(d:{nt}) \
1352                     WHERE s.id IN [{ids_str}] AND d.id IN [{ids_str}] \
1353                     RETURN s.id, d.id, e.kind, e.line, e.confidence"
1354                ))
1355                .map_err(|e| GitCortexError::Store(e.to_string()))?;
1356            let mut edges = Vec::new();
1357            for row in result {
1358                let src_str = str_val(&row[0])?;
1359                let dst_str = str_val(&row[1])?;
1360                let kind_str = str_val(&row[2])?;
1361                let line = i64_val(&row[3]).ok().filter(|l| *l >= 0).map(|l| l as u32);
1362                let confidence = EdgeConfidence::from_label(&str_val(&row[4]).unwrap_or_default());
1363                edges.push(Edge {
1364                    src: NodeId::try_from(src_str.as_str())
1365                        .map_err(|e| GitCortexError::Store(format!("bad src id: {e}")))?,
1366                    dst: NodeId::try_from(dst_str.as_str())
1367                        .map_err(|e| GitCortexError::Store(format!("bad dst id: {e}")))?,
1368                    kind: edge_kind_from_str(&kind_str),
1369                    line,
1370                    confidence,
1371                });
1372            }
1373            edges
1374        };
1375
1376        Ok(SubGraph {
1377            nodes: all_nodes,
1378            edges: all_edges,
1379        })
1380    }
1381
1382    // ── Indexing state ────────────────────────────────────────────────────────
1383
1384    fn last_indexed_sha(&self, branch_name: &str) -> Result<Option<String>> {
1385        branch::read_last_sha(&self.repo_id, branch_name)
1386    }
1387
1388    fn set_last_indexed_sha(&mut self, branch_name: &str, sha: &str) -> Result<()> {
1389        branch::write_last_sha(&self.repo_id, branch_name, sha)
1390    }
1391}