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
30const NODE_INSERT_CHUNK: usize = 128;
34const EDGE_INSERT_CHUNK: usize = 1000;
35
36fn 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
81fn 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
119fn bulk_apply(conn: &Connection, nt: &str, et: &str, diff: &GraphDiff) -> Result<()> {
122 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 let _ = std::fs::remove_dir_all(&stage);
143
144 result.map(|_| ())
145}
146
147const DEFERRED_CHUNK: usize = 500;
148
149fn 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 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 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 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
247fn 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 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 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 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
341pub struct KuzuGraphStore {
349 db: Database,
350 repo_id: String,
351 _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 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 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 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 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
458impl GraphStore for KuzuGraphStore {
461 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 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 conn.query("BEGIN TRANSACTION")
502 .map_err(|e| GitCortexError::Store(format!("begin delete transaction: {e}")))?;
503
504 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 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 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 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 conn.query("BEGIN TRANSACTION")
584 .map_err(|e| GitCortexError::Store(format!("begin node insert transaction: {e}")))?;
585
586 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 .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 conn.query("COMMIT")
622 .map_err(|e| GitCortexError::Store(format!("commit nodes: {e}")))?;
623
624 conn.query("BEGIN TRANSACTION")
626 .map_err(|e| GitCortexError::Store(format!("begin edge transaction: {e}")))?;
627
628 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 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 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 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 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 let mut seen: HashSet<String> = HashSet::new();
887 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 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 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 let from_ids = collect_ids(&mut conn, &from_nt)?;
1013 let to_ids = collect_ids(&mut conn, &to_nt)?;
1014
1015 let added_ids: Vec<&String> = to_ids.iter().filter(|id| !from_ids.contains(*id)).collect();
1017
1018 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 let q = esc(&query.to_ascii_lowercase());
1090 let conn = self.conn()?;
1091 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 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 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 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 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 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 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 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 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 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 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 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(¤t);
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 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 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 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 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 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 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 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}