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
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 bulk_apply(conn: &Connection, nt: &str, et: &str, diff: &GraphDiff) -> Result<()> {
98 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 let _ = std::fs::remove_dir_all(&stage);
119
120 result.map(|_| ())
121}
122
123const DEFERRED_CHUNK: usize = 500;
124
125fn 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
179fn 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
224pub struct KuzuGraphStore {
232 db: Database,
233 repo_id: String,
234}
235
236impl KuzuGraphStore {
237 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 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
278impl GraphStore for KuzuGraphStore {
281 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 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 conn.query("BEGIN TRANSACTION")
322 .map_err(|e| GitCortexError::Store(format!("begin delete transaction: {e}")))?;
323
324 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 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 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 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 conn.query("BEGIN TRANSACTION")
404 .map_err(|e| GitCortexError::Store(format!("begin node insert transaction: {e}")))?;
405
406 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 .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 conn.query("COMMIT")
442 .map_err(|e| GitCortexError::Store(format!("commit nodes: {e}")))?;
443
444 conn.query("BEGIN TRANSACTION")
446 .map_err(|e| GitCortexError::Store(format!("begin edge transaction: {e}")))?;
447
448 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 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 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 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 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 let mut seen: HashSet<String> = HashSet::new();
627 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 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 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 let from_ids = collect_ids(&mut conn, &from_nt)?;
753 let to_ids = collect_ids(&mut conn, &to_nt)?;
754
755 let added_ids: Vec<&String> = to_ids.iter().filter(|id| !from_ids.contains(*id)).collect();
757
758 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 let q = esc(&query.to_ascii_lowercase());
798 let conn = self.conn()?;
799 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 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 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 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 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 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 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 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 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 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 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 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(¤t);
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 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 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 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 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 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 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 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}