Skip to main content

gitcortex_core/
store.rs

1use std::path::Path;
2
3use crate::{
4    error::Result,
5    graph::{Edge, GraphDiff, Node},
6    schema::{EdgeConfidence, EdgeKind, NodeKind, Visibility},
7};
8
9/// Structural predicate set for `search_by_attributes`. All fields are
10/// optional; a `None` field imposes no constraint. Set fields are ANDed.
11#[derive(Debug, Default, Clone)]
12pub struct AttributeFilter {
13    pub kind: Option<NodeKind>,
14    pub is_async: Option<bool>,
15    pub visibility: Option<Visibility>,
16    /// Inclusive lower bound on cyclomatic complexity. Nodes without a recorded
17    /// complexity never match a complexity bound.
18    pub min_complexity: Option<u32>,
19    /// Inclusive upper bound on cyclomatic complexity.
20    pub max_complexity: Option<u32>,
21    /// Case-insensitive substring the node name must contain.
22    pub name_contains: Option<String>,
23    /// Case-insensitive: the node must carry an annotation/decorator whose name
24    /// contains this string (e.g. "route" matches `@app.route`, "Test" matches
25    /// `@Test`).
26    pub annotation: Option<String>,
27}
28
29impl AttributeFilter {
30    /// True when every set predicate holds for `node`.
31    pub fn matches(&self, node: &Node) -> bool {
32        if let Some(k) = &self.kind {
33            if &node.kind != k {
34                return false;
35            }
36        }
37        if let Some(a) = self.is_async {
38            if node.metadata.is_async != a {
39                return false;
40            }
41        }
42        if let Some(v) = &self.visibility {
43            if &node.metadata.visibility != v {
44                return false;
45            }
46        }
47        if let Some(min) = self.min_complexity {
48            match node.metadata.lld.complexity {
49                Some(c) if c >= min => {}
50                _ => return false,
51            }
52        }
53        if let Some(max) = self.max_complexity {
54            match node.metadata.lld.complexity {
55                Some(c) if c <= max => {}
56                _ => return false,
57            }
58        }
59        if let Some(sub) = &self.name_contains {
60            if !node
61                .name
62                .to_ascii_lowercase()
63                .contains(&sub.to_ascii_lowercase())
64            {
65                return false;
66            }
67        }
68        if let Some(ann) = &self.annotation {
69            let needle = ann.to_ascii_lowercase();
70            if !node
71                .metadata
72                .annotations
73                .iter()
74                .any(|a| a.to_ascii_lowercase().contains(&needle))
75            {
76                return false;
77            }
78        }
79        true
80    }
81
82    /// True when no predicate is set — an unconstrained filter.
83    pub fn is_empty(&self) -> bool {
84        self.kind.is_none()
85            && self.is_async.is_none()
86            && self.visibility.is_none()
87            && self.min_complexity.is_none()
88            && self.max_complexity.is_none()
89            && self.name_contains.is_none()
90            && self.annotation.is_none()
91    }
92}
93
94/// A subgraph centred on a seed node, returned by `get_subgraph`.
95pub struct SubGraph {
96    pub nodes: Vec<Node>,
97    pub edges: Vec<Edge>,
98}
99
100/// Callers of a symbol grouped by hop distance.
101pub struct CallersDeep {
102    /// Groups indexed 0..depth-1. `hops[0]` = direct callers (hop 1).
103    pub hops: Vec<Vec<Node>>,
104    /// Risk score derived from total affected count and depth reached.
105    pub risk_level: &'static str,
106}
107
108/// Aggregate counts for a branch's graph, returned by `graph_stats`.
109/// First-call orientation for an agent: how big is the graph, what kinds of
110/// symbols dominate, how connected is it.
111pub struct GraphStats {
112    pub total_nodes: u64,
113    pub total_edges: u64,
114    /// `(kind, count)` pairs, sorted by count descending.
115    pub nodes_by_kind: Vec<(String, u64)>,
116    /// `(kind, count)` pairs, sorted by count descending.
117    pub edges_by_kind: Vec<(String, u64)>,
118}
119
120/// A single call site: the calling symbol and the source line of the call.
121pub struct CallSite {
122    pub caller: Node,
123    /// 1-indexed line of the call expression, when recorded.
124    pub line: Option<u32>,
125}
126
127/// Up-and-down type relationships for a named type, returned by `type_hierarchy`.
128pub struct TypeHierarchy {
129    /// Types this type implements or extends (its supertypes / interfaces).
130    pub supertypes: Vec<Node>,
131    /// Types that implement or extend this type (its subtypes / implementors).
132    pub subtypes: Vec<Node>,
133}
134
135/// 360-degree view of a single symbol.
136pub struct SymbolContext {
137    /// The node matching `name` (first match if multiple).
138    pub definition: Node,
139    /// Functions/methods that call this symbol (direct callers).
140    pub callers: Vec<Node>,
141    /// Functions/methods that this symbol calls (direct callees).
142    pub callees: Vec<Node>,
143    /// Functions/types that reference this symbol via `Uses` edges.
144    pub used_by: Vec<Node>,
145}
146
147/// Backend-agnostic interface for the knowledge graph store.
148///
149/// The v0.1 implementation is `KuzuGraphStore` (local embedded DB).
150/// A remote backend can be plugged in by implementing this trait without
151/// touching the indexer or MCP layers.
152pub trait GraphStore: Send + Sync {
153    // ── Write operations ─────────────────────────────────────────────────────
154
155    /// Apply an incremental diff to the named branch's graph.
156    fn apply_diff(&mut self, branch: &str, diff: &GraphDiff) -> Result<()>;
157
158    // ── Read operations ──────────────────────────────────────────────────────
159
160    /// Find all nodes matching `name` on `branch`.
161    /// When `fuzzy` is true, matches any node whose name *contains* `name`
162    /// (case-sensitive substring). When false, exact match only.
163    fn lookup_symbol(&self, branch: &str, name: &str, fuzzy: bool) -> Result<Vec<Node>>;
164
165    /// Find all call-site nodes whose outgoing `Calls` edge points to a node
166    /// named `function_name` on `branch` (single hop).
167    fn find_callers(&self, branch: &str, function_name: &str) -> Result<Vec<Node>>;
168
169    /// Like `find_callers` but also returns each caller's edge confidence.
170    /// The default conservatively marks all callers as `Inferred`; backends
171    /// should override with a query that reads `e.confidence` from the edge.
172    fn find_callers_with_confidence(
173        &self,
174        branch: &str,
175        function_name: &str,
176    ) -> Result<Vec<(Node, EdgeConfidence)>> {
177        Ok(self
178            .find_callers(branch, function_name)?
179            .into_iter()
180            .map(|n| (n, EdgeConfidence::Inferred))
181            .collect())
182    }
183
184    /// Find direct callers for one exact target node ID, including edge
185    /// confidence. Agent-facing queries use this after symbol disambiguation so
186    /// common names such as `get` cannot merge unrelated call graphs.
187    ///
188    /// The default implementation filters the full graph in memory. Stores
189    /// should override this with an indexed query.
190    fn find_callers_by_id_with_confidence(
191        &self,
192        branch: &str,
193        target_id: &str,
194    ) -> Result<Vec<(Node, EdgeConfidence)>> {
195        let callers: std::collections::HashMap<String, Node> = self
196            .list_all_nodes(branch)?
197            .into_iter()
198            .map(|n| (n.id.as_str(), n))
199            .collect();
200        let mut out = Vec::new();
201        for edge in self.list_all_edges(branch)? {
202            if edge.kind == EdgeKind::Calls && edge.dst.as_str() == target_id {
203                if let Some(node) = callers.get(&edge.src.as_str()) {
204                    out.push((node.clone(), edge.confidence));
205                }
206            }
207        }
208        Ok(out)
209    }
210
211    /// Multi-hop BFS: find callers up to `depth` hops away.
212    /// Returns callers grouped by hop distance (1..=depth).
213    /// `depth` is capped at 5 to prevent runaway queries.
214    fn find_callers_deep(
215        &self,
216        branch: &str,
217        function_name: &str,
218        depth: u8,
219    ) -> Result<CallersDeep>;
220
221    /// Return a 360° view of a symbol: its definition, direct callers,
222    /// direct callees, and nodes that reference it via `Uses` edges.
223    fn symbol_context(&self, branch: &str, name: &str) -> Result<SymbolContext>;
224
225    /// List all top-level definitions in `file` on `branch`.
226    fn list_definitions(&self, branch: &str, file: &Path) -> Result<Vec<Node>>;
227
228    /// Return all nodes in `branch`'s graph.
229    fn list_all_nodes(&self, branch: &str) -> Result<Vec<Node>>;
230
231    /// Return all edges in `branch`'s graph.
232    fn list_all_edges(&self, branch: &str) -> Result<Vec<Edge>>;
233
234    /// Return a deterministic page of nodes ordered by stable node ID.
235    ///
236    /// The default implementation slices `list_all_nodes` for compatibility.
237    /// Stores should override this with query-level `OFFSET`/`LIMIT` push-down
238    /// so visualization clients can progressively load very large graphs.
239    fn list_nodes_page(&self, branch: &str, offset: usize, limit: usize) -> Result<Vec<Node>> {
240        let mut nodes = self.list_all_nodes(branch)?;
241        nodes.sort_by_key(|node| node.id.as_str());
242        Ok(nodes.into_iter().skip(offset).take(limit).collect())
243    }
244
245    /// Return a deterministic page of edges ordered by source, destination,
246    /// kind, and source line. See [`GraphStore::list_nodes_page`].
247    fn list_edges_page(&self, branch: &str, offset: usize, limit: usize) -> Result<Vec<Edge>> {
248        let mut edges = self.list_all_edges(branch)?;
249        edges.sort_by_key(|edge| {
250            (
251                edge.src.as_str(),
252                edge.dst.as_str(),
253                edge.kind.to_string(),
254                edge.line,
255            )
256        });
257        Ok(edges.into_iter().skip(offset).take(limit).collect())
258    }
259
260    /// Return edges of a specific `kind` in `branch`'s graph.
261    /// The default filters `list_all_edges` in-memory; backends should override
262    /// with a `WHERE`-clause push-down for large graphs.
263    fn list_edges_by_kind(&self, branch: &str, kind: EdgeKind) -> Result<Vec<Edge>> {
264        Ok(self
265            .list_all_edges(branch)?
266            .into_iter()
267            .filter(|e| e.kind == kind)
268            .collect())
269    }
270
271    /// Find nodes matching a structural `filter` (kind, async, visibility,
272    /// complexity range, name substring), up to `limit` results.
273    ///
274    /// The default filters `list_all_nodes` in-memory; backends should override
275    /// with a `WHERE`-clause push-down.
276    fn search_by_attributes(
277        &self,
278        branch: &str,
279        filter: &AttributeFilter,
280        limit: usize,
281    ) -> Result<Vec<Node>> {
282        let mut nodes: Vec<Node> = self
283            .list_all_nodes(branch)?
284            .into_iter()
285            .filter(|n| filter.matches(n))
286            .collect();
287        nodes.truncate(limit);
288        Ok(nodes)
289    }
290
291    /// Aggregate node/edge counts (total + per-kind) for `branch`.
292    ///
293    /// The default counts in-memory from `list_all_nodes`/`list_all_edges`;
294    /// backends should override with a `COUNT` push-down.
295    fn graph_stats(&self, branch: &str) -> Result<GraphStats> {
296        use std::collections::HashMap;
297
298        fn tally<T, F>(items: &[T], key: F) -> Vec<(String, u64)>
299        where
300            F: Fn(&T) -> String,
301        {
302            let mut counts: HashMap<String, u64> = HashMap::new();
303            for item in items {
304                *counts.entry(key(item)).or_insert(0) += 1;
305            }
306            let mut pairs: Vec<(String, u64)> = counts.into_iter().collect();
307            // Sort by count desc, then kind name asc for deterministic output.
308            pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
309            pairs
310        }
311
312        let nodes = self.list_all_nodes(branch)?;
313        let edges = self.list_all_edges(branch)?;
314        Ok(GraphStats {
315            total_nodes: nodes.len() as u64,
316            total_edges: edges.len() as u64,
317            nodes_by_kind: tally(&nodes, |n| n.kind.to_string()),
318            edges_by_kind: tally(&edges, |e| e.kind.to_string()),
319        })
320    }
321
322    /// Return nodes whose `name` or `qualified_name` contains `query` (case-
323    /// sensitive substring), up to `limit` results. Implementations should push
324    /// the filter to the store rather than scanning all nodes in memory.
325    ///
326    /// The default falls back to `list_all_nodes` for stores that don't
327    /// override this method (e.g. the in-memory test stub).
328    fn search_nodes(&self, branch: &str, query: &str, limit: usize) -> Result<Vec<Node>> {
329        let q = query.to_ascii_lowercase();
330        let mut nodes: Vec<Node> = self
331            .list_all_nodes(branch)?
332            .into_iter()
333            .filter(|n| {
334                n.name.to_ascii_lowercase().contains(&q)
335                    || n.qualified_name.to_ascii_lowercase().contains(&q)
336            })
337            .collect();
338        nodes.truncate(limit);
339        Ok(nodes)
340    }
341
342    /// Resolve a set of node IDs to full nodes. Order is not guaranteed; IDs
343    /// that don't exist on `branch` are silently skipped.
344    ///
345    /// The default falls back to `list_all_nodes`; backends should override
346    /// with an indexed ID lookup.
347    fn get_nodes_by_ids(&self, branch: &str, ids: &[String]) -> Result<Vec<Node>> {
348        let idset: std::collections::HashSet<&str> = ids.iter().map(String::as_str).collect();
349        Ok(self
350            .list_all_nodes(branch)?
351            .into_iter()
352            .filter(|n| idset.contains(n.id.as_str().as_str()))
353            .collect())
354    }
355
356    /// Return the graph delta between two branches as a `GraphDiff`.
357    /// Nodes/edges present in `to` but not `from` are in `added_*`.
358    /// Nodes/edges present in `from` but not `to` are in `removed_*`.
359    fn branch_diff(&self, from: &str, to: &str) -> Result<GraphDiff>;
360
361    // ── Wave 2 tools ─────────────────────────────────────────────────────────
362
363    /// Find all functions/methods called by `function_name` up to `depth` hops.
364    /// Returns callees grouped by hop distance (1..=depth). Capped at 5.
365    fn find_callees(&self, branch: &str, function_name: &str, depth: u8) -> Result<CallersDeep>;
366
367    /// Find all structs/classes that implement/inherit `trait_or_interface_name`.
368    fn find_implementors(&self, branch: &str, trait_or_interface_name: &str) -> Result<Vec<Node>>;
369
370    /// Return the in-repo modules that a module named `module_name` depends on,
371    /// resolved by following its `Imports` edges to the defining module of each
372    /// imported symbol. Answers "what does this module depend on".
373    ///
374    /// External/stdlib imports are not graphed, so only intra-repo dependencies
375    /// appear. The default walks nodes + edges in-memory; backends should
376    /// override with a join query.
377    fn module_dependencies(&self, branch: &str, module_name: &str) -> Result<Vec<Node>> {
378        use crate::schema::{EdgeKind, NodeKind};
379        use std::collections::{HashMap, HashSet};
380
381        let nodes = self.list_all_nodes(branch)?;
382        let edges = self.list_all_edges(branch)?;
383
384        // Source module node ids (there may be several files with this stem).
385        let src_ids: HashSet<String> = nodes
386            .iter()
387            .filter(|n| n.kind == NodeKind::Module && n.name == module_name)
388            .map(|n| n.id.as_str())
389            .collect();
390        if src_ids.is_empty() {
391            return Ok(Vec::new());
392        }
393
394        // Map every node id to its file, and every file to its module node.
395        let id_to_file: HashMap<String, String> = nodes
396            .iter()
397            .map(|n| (n.id.as_str(), n.file.to_string_lossy().into_owned()))
398            .collect();
399        let file_to_module: HashMap<String, &Node> = nodes
400            .iter()
401            .filter(|n| n.kind == NodeKind::Module)
402            .map(|n| (n.file.to_string_lossy().into_owned(), n))
403            .collect();
404
405        let src_files: HashSet<&String> =
406            src_ids.iter().filter_map(|id| id_to_file.get(id)).collect();
407
408        let mut seen: HashSet<String> = HashSet::new();
409        let mut deps: Vec<Node> = Vec::new();
410        for e in &edges {
411            if !matches!(e.kind, EdgeKind::Imports) {
412                continue;
413            }
414            if !src_ids.contains(&e.src.as_str()) {
415                continue;
416            }
417            // Resolve the imported symbol's defining module.
418            let Some(sym_file) = id_to_file.get(&e.dst.as_str()) else {
419                continue;
420            };
421            // Skip self-imports within the same module file.
422            if src_files.contains(sym_file) {
423                continue;
424            }
425            if let Some(dst_mod) = file_to_module.get(sym_file) {
426                if seen.insert(dst_mod.id.as_str()) {
427                    deps.push((*dst_mod).clone());
428                }
429            }
430        }
431        Ok(deps)
432    }
433
434    /// Find functions/methods that reference a type named `type_name` as a
435    /// parameter or return type (following `Uses` edges). Answers "where is
436    /// type T used in a signature" — the type-level analogue of find_callers.
437    ///
438    /// The default walks `list_all_edges`; backends should override with a
439    /// directed Cypher match.
440    fn find_type_usages(&self, branch: &str, type_name: &str) -> Result<Vec<Node>> {
441        use crate::schema::EdgeKind;
442        use std::collections::HashSet;
443
444        let nodes = self.list_all_nodes(branch)?;
445        let edges = self.list_all_edges(branch)?;
446
447        let target_ids: HashSet<String> = nodes
448            .iter()
449            .filter(|n| n.name == type_name)
450            .map(|n| n.id.as_str())
451            .collect();
452        if target_ids.is_empty() {
453            return Ok(Vec::new());
454        }
455
456        let user_ids: Vec<String> = edges
457            .iter()
458            .filter(|e| matches!(e.kind, EdgeKind::Uses) && target_ids.contains(&e.dst.as_str()))
459            .map(|e| e.src.as_str())
460            .collect();
461        self.get_nodes_by_ids(branch, &user_ids)
462    }
463
464    /// Find every call site of the function named `function_name`: the calling
465    /// symbol plus the source line of each call expression (following `Calls`
466    /// edges). Where `find_callers` returns only the calling symbols, this also
467    /// pinpoints the line each call happens on.
468    ///
469    /// The default walks `list_all_edges`; backends should override with a
470    /// directed Cypher match that returns the edge line.
471    fn find_call_sites(&self, branch: &str, function_name: &str) -> Result<Vec<CallSite>> {
472        use crate::schema::EdgeKind;
473        use std::collections::HashMap;
474
475        let nodes = self.list_all_nodes(branch)?;
476        let edges = self.list_all_edges(branch)?;
477
478        let target_ids: std::collections::HashSet<String> = nodes
479            .iter()
480            .filter(|n| n.name == function_name)
481            .map(|n| n.id.as_str())
482            .collect();
483        if target_ids.is_empty() {
484            return Ok(Vec::new());
485        }
486
487        let by_id: HashMap<String, &Node> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
488
489        let mut sites = Vec::new();
490        for e in &edges {
491            if matches!(e.kind, EdgeKind::Calls) && target_ids.contains(&e.dst.as_str()) {
492                if let Some(caller) = by_id.get(&e.src.as_str()) {
493                    sites.push(CallSite {
494                        caller: (*caller).clone(),
495                        line: e.line,
496                    });
497                }
498            }
499        }
500        Ok(sites)
501    }
502
503    /// Find the module/file nodes that import a symbol named `symbol_name`
504    /// (following `Imports` edges). Answers "who imports X".
505    ///
506    /// The default walks `list_all_edges`; backends should override with a
507    /// directed Cypher match.
508    fn find_importers(&self, branch: &str, symbol_name: &str) -> Result<Vec<Node>> {
509        use crate::schema::EdgeKind;
510        use std::collections::HashSet;
511
512        let nodes = self.list_all_nodes(branch)?;
513        let edges = self.list_all_edges(branch)?;
514
515        let target_ids: HashSet<String> = nodes
516            .iter()
517            .filter(|n| n.name == symbol_name)
518            .map(|n| n.id.as_str())
519            .collect();
520        if target_ids.is_empty() {
521            return Ok(Vec::new());
522        }
523
524        let importer_ids: Vec<String> = edges
525            .iter()
526            .filter(|e| matches!(e.kind, EdgeKind::Imports) && target_ids.contains(&e.dst.as_str()))
527            .map(|e| e.src.as_str())
528            .collect();
529        self.get_nodes_by_ids(branch, &importer_ids)
530    }
531
532    /// Return both directions of the type relation for `name`: the types it
533    /// implements/extends (supertypes) and the types that implement/extend it
534    /// (subtypes), following `Implements` and `Inherits` edges.
535    ///
536    /// The default walks `list_all_edges`; backends should override with a
537    /// directed Cypher match.
538    fn type_hierarchy(&self, branch: &str, name: &str) -> Result<TypeHierarchy> {
539        use crate::schema::EdgeKind;
540        use std::collections::HashSet;
541
542        let nodes = self.list_all_nodes(branch)?;
543        let edges = self.list_all_edges(branch)?;
544
545        let self_ids: HashSet<String> = nodes
546            .iter()
547            .filter(|n| n.name == name)
548            .map(|n| n.id.as_str())
549            .collect();
550        if self_ids.is_empty() {
551            return Ok(TypeHierarchy {
552                supertypes: Vec::new(),
553                subtypes: Vec::new(),
554            });
555        }
556
557        let is_hierarchy = |k: &EdgeKind| matches!(k, EdgeKind::Implements | EdgeKind::Inherits);
558        let mut super_ids: Vec<String> = Vec::new();
559        let mut sub_ids: Vec<String> = Vec::new();
560        for e in &edges {
561            if !is_hierarchy(&e.kind) {
562                continue;
563            }
564            // self → super
565            if self_ids.contains(&e.src.as_str()) {
566                super_ids.push(e.dst.as_str());
567            }
568            // sub → self
569            if self_ids.contains(&e.dst.as_str()) {
570                sub_ids.push(e.src.as_str());
571            }
572        }
573
574        Ok(TypeHierarchy {
575            supertypes: self.get_nodes_by_ids(branch, &super_ids)?,
576            subtypes: self.get_nodes_by_ids(branch, &sub_ids)?,
577        })
578    }
579
580    /// Find all call paths between `from` and `to` using BFS.
581    /// Returns at most one path (the shortest), as a sequence of nodes.
582    fn trace_path(&self, branch: &str, from: &str, to: &str) -> Result<Vec<Node>>;
583
584    /// Find all nodes in `file` whose span overlaps `[start_line, end_line]`.
585    fn list_symbols_in_range(
586        &self,
587        branch: &str,
588        file: &Path,
589        start_line: u32,
590        end_line: u32,
591    ) -> Result<Vec<Node>>;
592
593    /// Find symbols with no incoming Calls or Uses edges (potential dead code).
594    /// If `kind` is provided, filters to only that NodeKind.
595    fn find_unused_symbols(&self, branch: &str, kind: Option<NodeKind>) -> Result<Vec<Node>>;
596
597    /// Return a subgraph centred on `seed_name` up to `depth` hops.
598    /// `direction`: "in" (callers), "out" (callees), or "both".
599    fn get_subgraph(
600        &self,
601        branch: &str,
602        seed_name: &str,
603        depth: u8,
604        direction: &str,
605    ) -> Result<SubGraph>;
606
607    /// Return a subgraph around one exact seed node ID. Agent-facing queries
608    /// use this after disambiguation so repeated short names cannot merge
609    /// unrelated neighborhoods.
610    ///
611    /// The default scans the graph in memory. Stores should override this with
612    /// indexed traversal.
613    fn get_subgraph_by_id(
614        &self,
615        branch: &str,
616        seed_id: &str,
617        depth: u8,
618        direction: &str,
619    ) -> Result<SubGraph> {
620        let nodes = self.list_all_nodes(branch)?;
621        let edges = self.list_all_edges(branch)?;
622        let by_id: std::collections::HashMap<String, Node> = nodes
623            .into_iter()
624            .map(|node| (node.id.as_str(), node))
625            .collect();
626        if !by_id.contains_key(seed_id) {
627            return Ok(SubGraph {
628                nodes: Vec::new(),
629                edges: Vec::new(),
630            });
631        }
632
633        let mut selected: std::collections::HashSet<String> =
634            [seed_id.to_owned()].into_iter().collect();
635        let mut frontier = vec![seed_id.to_owned()];
636        for _ in 0..depth.min(5) {
637            let mut next = Vec::new();
638            for edge in &edges {
639                let src = edge.src.as_str();
640                let dst = edge.dst.as_str();
641                let neighbour = if (direction == "out" || direction == "both")
642                    && frontier.contains(&src)
643                {
644                    Some(dst)
645                } else if (direction == "in" || direction == "both") && frontier.contains(&dst) {
646                    Some(src)
647                } else {
648                    None
649                };
650                if let Some(id) = neighbour {
651                    if selected.insert(id.clone()) {
652                        next.push(id);
653                    }
654                }
655            }
656            if next.is_empty() {
657                break;
658            }
659            frontier = next;
660        }
661
662        let selected_edges = edges
663            .into_iter()
664            .filter(|edge| {
665                selected.contains(&edge.src.as_str()) && selected.contains(&edge.dst.as_str())
666            })
667            .collect();
668        let selected_nodes = selected
669            .into_iter()
670            .filter_map(|id| by_id.get(&id).cloned())
671            .collect();
672        Ok(SubGraph {
673            nodes: selected_nodes,
674            edges: selected_edges,
675        })
676    }
677
678    /// Return one bounded hop around an exact seed ID. Visualization clients
679    /// use repeated calls to expand large graphs without materializing an
680    /// unbounded multi-hop neighborhood.
681    fn get_neighborhood_by_id(
682        &self,
683        branch: &str,
684        seed_id: &str,
685        direction: &str,
686        limit: usize,
687    ) -> Result<SubGraph> {
688        let all_nodes = self.list_all_nodes(branch)?;
689        let by_id: std::collections::HashMap<String, Node> = all_nodes
690            .into_iter()
691            .map(|node| (node.id.as_str(), node))
692            .collect();
693        if !by_id.contains_key(seed_id) {
694            return Ok(SubGraph {
695                nodes: Vec::new(),
696                edges: Vec::new(),
697            });
698        }
699        let mut edges = Vec::new();
700        for edge in self.list_all_edges(branch)? {
701            let incoming = edge.dst.as_str() == seed_id;
702            let outgoing = edge.src.as_str() == seed_id;
703            if ((direction == "in" && incoming)
704                || (direction == "out" && outgoing)
705                || (direction == "both" && (incoming || outgoing)))
706                && edges.len() < limit
707            {
708                edges.push(edge);
709            }
710        }
711        let mut ids = std::collections::HashSet::from([seed_id.to_owned()]);
712        for edge in &edges {
713            ids.insert(edge.src.as_str());
714            ids.insert(edge.dst.as_str());
715        }
716        let nodes = ids
717            .into_iter()
718            .filter_map(|id| by_id.get(&id).cloned())
719            .collect();
720        Ok(SubGraph { nodes, edges })
721    }
722
723    // ── Indexing state ───────────────────────────────────────────────────────
724
725    /// Last commit SHA successfully indexed for `branch`. `None` if the branch
726    /// has never been indexed.
727    fn last_indexed_sha(&self, branch: &str) -> Result<Option<String>>;
728
729    /// Persist the commit SHA after a successful index run.
730    fn set_last_indexed_sha(&mut self, branch: &str, sha: &str) -> Result<()>;
731}