Skip to main content

gitcortex_core/
graph.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::{
8    error::GitCortexError,
9    schema::{CodeSmell, DesignPattern, EdgeConfidence, EdgeKind, NodeKind, SolidHint, Visibility},
10};
11
12// ── Identifiers ──────────────────────────────────────────────────────────────
13
14/// Stable, globally unique node identifier. UUID v4 assigned at parse time.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct NodeId(Uuid);
17
18impl NodeId {
19    pub fn new() -> Self {
20        Self(Uuid::new_v4())
21    }
22
23    pub fn as_str(&self) -> String {
24        self.0.to_string()
25    }
26}
27
28impl Default for NodeId {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl std::fmt::Display for NodeId {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        self.0.fmt(f)
37    }
38}
39
40impl TryFrom<&str> for NodeId {
41    type Error = GitCortexError;
42
43    fn try_from(s: &str) -> Result<Self, Self::Error> {
44        Uuid::parse_str(s)
45            .map(NodeId)
46            .map_err(|e| GitCortexError::Store(format!("invalid NodeId '{s}': {e}")))
47    }
48}
49
50// ── Source location ───────────────────────────────────────────────────────────
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Span {
54    pub start_line: u32,
55    pub end_line: u32,
56}
57
58// ── LLD metadata ──────────────────────────────────────────────────────────────
59
60/// LLD annotations added during pass-2 analysis. All fields are optional because
61/// pass 2 runs asynchronously — nodes are queryable before annotations arrive.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
63pub struct LldLabels {
64    pub solid_hints: Vec<SolidHint>,
65    pub patterns: Vec<DesignPattern>,
66    pub smells: Vec<CodeSmell>,
67    /// Cyclomatic complexity (functions/methods only).
68    pub complexity: Option<u32>,
69}
70
71/// Source-text capture for a node — signature, body slice, preceding doc-comment,
72/// and byte range into the original file. Filled during pass 1 from the
73/// tree-sitter node's byte range; cheap (no extra parsing).
74///
75/// Powers wiki rendering, tour narration, and future semantic search.
76/// Empty default means "not captured" — legacy rows return all-empty.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub struct DefinitionText {
79    /// First line(s) of the definition up to (and excluding) the body block.
80    /// E.g. `pub fn apply_diff(&mut self, branch: &str, diff: &GraphDiff) -> Result<()>`.
81    pub signature: String,
82    /// Full source slice of the node, including signature and body.
83    pub body: String,
84    /// Doc-comment immediately preceding the node (`///`, `//!`, `/** */`, `"""`).
85    /// `None` when absent.
86    pub doc_comment: Option<String>,
87    /// Byte offsets into the parent file. `(0, 0)` if not captured.
88    pub start_byte: u32,
89    pub end_byte: u32,
90}
91
92/// Per-node metadata collected during pass-1 (structural) indexing.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
94pub struct NodeMetadata {
95    /// Lines of code for this node's body.
96    pub loc: u32,
97    pub visibility: Visibility,
98    pub is_async: bool,
99    pub is_unsafe: bool,
100    /// Java `static`, Python `@staticmethod`, Go package-level functions.
101    pub is_static: bool,
102    /// Java/TypeScript `abstract`, Python NotImplemented stubs, sealed traits.
103    pub is_abstract: bool,
104    /// Java `final` class/method, Rust sealed types, TypeScript `readonly`.
105    pub is_final: bool,
106    /// Python `@property`, TypeScript getter/setter, Rust associated `const`.
107    pub is_property: bool,
108    /// Python generators (`yield`), TypeScript `function*`, async generators.
109    pub is_generator: bool,
110    /// Rust `const fn`, TypeScript `const` assertion, Java `static final` fields.
111    pub is_const: bool,
112    /// Captured generic constraints, e.g. `["T: Send", "T: 'static"]` or
113    /// `["T extends Base", "K extends keyof T"]`.
114    pub generic_bounds: Vec<String>,
115    /// Decorator / annotation names applied to this symbol, e.g.
116    /// `["dataclass"]`, `["Override"]`, `["derive", "Serialize"]`. Captured
117    /// regardless of whether the decorator is defined in-repo, so framework
118    /// decorators (`@app.route`, `@Test`) remain queryable even though their
119    /// `Annotated` edge target is external and dropped.
120    pub annotations: Vec<String>,
121    /// Pass-2 LLD annotations. Empty until pass 2 runs.
122    pub lld: LldLabels,
123    /// Raw source-text capture — signature, body, doc-comment, byte range.
124    pub definition: DefinitionText,
125}
126
127// ── Core graph types ──────────────────────────────────────────────────────────
128
129/// A single named entity in the knowledge graph.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Node {
132    pub id: NodeId,
133    pub kind: NodeKind,
134    /// Short unqualified name (e.g. `"greet"`, not `"Person::greet"`).
135    pub name: String,
136    /// Qualified path within the module hierarchy (e.g. `"crate::person::Person::greet"`).
137    pub qualified_name: String,
138    /// Repo-relative path to the source file.
139    pub file: PathBuf,
140    pub span: Span,
141    pub metadata: NodeMetadata,
142}
143
144/// A directed relationship between two nodes.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct Edge {
147    pub src: NodeId,
148    pub dst: NodeId,
149    pub kind: EdgeKind,
150    /// Source line of the relationship's origin, when meaningful. Set for
151    /// `Calls` edges (the line of the call expression) so call sites can be
152    /// pinpointed; `None` for structural edges (Contains, Implements, …).
153    #[serde(default)]
154    pub line: Option<u32>,
155    /// How confident the indexer is this edge is real (see [`EdgeConfidence`]).
156    #[serde(default)]
157    pub confidence: EdgeConfidence,
158}
159
160impl Edge {
161    /// Construct an edge with no associated source line (structural edges).
162    /// Defaults to `Extracted` confidence.
163    pub fn new(src: NodeId, dst: NodeId, kind: EdgeKind) -> Self {
164        Self {
165            src,
166            dst,
167            kind,
168            line: None,
169            confidence: EdgeConfidence::Extracted,
170        }
171    }
172
173    /// Construct a `Calls` edge carrying the call-expression line.
174    pub fn call(src: NodeId, dst: NodeId, line: u32) -> Self {
175        Self {
176            src,
177            dst,
178            kind: EdgeKind::Calls,
179            line: Some(line),
180            confidence: EdgeConfidence::Extracted,
181        }
182    }
183
184    /// Set the edge's confidence (builder-style), e.g. mark a cross-file
185    /// name-resolved edge as `Inferred`.
186    pub fn with_confidence(mut self, confidence: EdgeConfidence) -> Self {
187        self.confidence = confidence;
188        self
189    }
190}
191
192// ── Graph diff ────────────────────────────────────────────────────────────────
193
194/// Incremental change set produced by the indexer after each commit.
195/// Applying a `GraphDiff` to the store brings the persisted graph up to date.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
197pub struct GraphDiff {
198    pub added_nodes: Vec<Node>,
199    /// Explicit node IDs to remove (e.g. from a targeted replacement).
200    pub removed_node_ids: Vec<NodeId>,
201    /// Files that were deleted. The store removes all nodes whose `file`
202    /// field matches any path in this list. Preferred over `removed_node_ids`
203    /// when whole files are gone because the indexer does not need to know
204    /// prior node IDs (keeping indexer ↔ store decoupled).
205    pub removed_files: Vec<PathBuf>,
206    pub added_edges: Vec<Edge>,
207    pub removed_edges: Vec<(NodeId, NodeId, EdgeKind)>,
208    /// Cross-file calls that couldn't be resolved against the diff-local node
209    /// set (because the callee lives in an unchanged file). The store resolves
210    /// these after inserting the new nodes, using its full existing data.
211    /// Tuple: `(caller_id, callee_name, call_line)`.
212    pub deferred_calls: Vec<(NodeId, String, u32)>,
213    /// Same for parameter/return-type Uses edges.
214    pub deferred_uses: Vec<(NodeId, String)>,
215    /// Same for struct→trait Implements edges.
216    pub deferred_implements: Vec<(NodeId, String)>,
217    /// Same for `extends` / inheritance edges.
218    pub deferred_inherits: Vec<(NodeId, String)>,
219    /// Same for `throws ExceptionType` edges.
220    pub deferred_throws: Vec<(NodeId, String)>,
221    /// Same for decorator/annotation references.
222    pub deferred_annotated: Vec<(NodeId, String)>,
223    /// Markdown section/file → referenced code symbol name. Intentionally
224    /// unscoped by language — a doc can reference a symbol in any language.
225    pub deferred_doc_refs: Vec<(NodeId, String)>,
226}
227
228impl GraphDiff {
229    pub fn is_empty(&self) -> bool {
230        self.added_nodes.is_empty()
231            && self.removed_node_ids.is_empty()
232            && self.removed_files.is_empty()
233            && self.added_edges.is_empty()
234            && self.removed_edges.is_empty()
235            && self.deferred_calls.is_empty()
236            && self.deferred_uses.is_empty()
237            && self.deferred_implements.is_empty()
238            && self.deferred_inherits.is_empty()
239            && self.deferred_throws.is_empty()
240            && self.deferred_annotated.is_empty()
241            && self.deferred_doc_refs.is_empty()
242    }
243
244    /// Merge another diff into this one. Used when multiple files change
245    /// in parallel and their per-file diffs are combined before a single
246    /// store write.
247    pub fn merge(&mut self, other: GraphDiff) {
248        self.added_nodes.extend(other.added_nodes);
249        self.removed_node_ids.extend(other.removed_node_ids);
250        self.removed_files.extend(other.removed_files);
251        self.added_edges.extend(other.added_edges);
252        self.removed_edges.extend(other.removed_edges);
253        self.deferred_calls.extend(other.deferred_calls);
254        self.deferred_uses.extend(other.deferred_uses);
255        self.deferred_implements.extend(other.deferred_implements);
256        self.deferred_inherits.extend(other.deferred_inherits);
257        self.deferred_throws.extend(other.deferred_throws);
258        self.deferred_annotated.extend(other.deferred_annotated);
259        self.deferred_doc_refs.extend(other.deferred_doc_refs);
260    }
261}
262
263// ── Graph algorithms (pure, no I/O) ──────────────────────────────────────────
264
265/// Count inbound `Calls` edges per destination node id.
266/// Shared by `gitcortex-mcp` (centrality/clustering/tour) and `gitcortex-viz`
267/// so both surfaces always use the same algorithm and cannot drift.
268pub fn in_degree_by_calls(edges: &[Edge]) -> HashMap<String, u32> {
269    let mut in_degree: HashMap<String, u32> = HashMap::new();
270    for e in edges {
271        if matches!(e.kind, EdgeKind::Calls) {
272            *in_degree.entry(e.dst.as_str()).or_insert(0) += 1;
273        }
274    }
275    in_degree
276}
277
278/// Find import cycles via Tarjan's SCC over `EdgeKind::Imports` edges.
279/// Returns one `Vec<String>` (node IDs) per cycle; cycles of size 1 (self-loops)
280/// are excluded.
281pub fn find_import_cycles(edges: &[Edge]) -> Result<Vec<Vec<String>>, GitCortexError> {
282    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
283    for e in edges {
284        if matches!(e.kind, EdgeKind::Imports) {
285            adj.entry(e.src.as_str()).or_default().push(e.dst.as_str());
286        }
287    }
288
289    let nodes: Vec<String> = adj.keys().cloned().collect();
290    let mut index_counter = 0usize;
291    let mut stack: Vec<String> = Vec::new();
292    let mut on_stack: HashMap<String, bool> = HashMap::new();
293    let mut index: HashMap<String, usize> = HashMap::new();
294    let mut lowlink: HashMap<String, usize> = HashMap::new();
295    let mut result: Vec<Vec<String>> = Vec::new();
296
297    #[allow(clippy::too_many_arguments)]
298    fn strongconnect(
299        v: &str,
300        adj: &HashMap<String, Vec<String>>,
301        counter: &mut usize,
302        stack: &mut Vec<String>,
303        on_stack: &mut HashMap<String, bool>,
304        index: &mut HashMap<String, usize>,
305        lowlink: &mut HashMap<String, usize>,
306        result: &mut Vec<Vec<String>>,
307    ) -> Result<(), GitCortexError> {
308        index.insert(v.to_owned(), *counter);
309        lowlink.insert(v.to_owned(), *counter);
310        *counter += 1;
311        stack.push(v.to_owned());
312        on_stack.insert(v.to_owned(), true);
313
314        if let Some(neighbours) = adj.get(v) {
315            for w in neighbours.iter() {
316                if !index.contains_key(w.as_str()) {
317                    strongconnect(w, adj, counter, stack, on_stack, index, lowlink, result)?;
318                    let ll_w = lowlink[w.as_str()];
319                    let ll_v = lowlink[v];
320                    lowlink.insert(v.to_owned(), ll_v.min(ll_w));
321                } else if *on_stack.get(w.as_str()).unwrap_or(&false) {
322                    let idx_w = index[w.as_str()];
323                    let ll_v = lowlink[v];
324                    lowlink.insert(v.to_owned(), ll_v.min(idx_w));
325                }
326            }
327        }
328
329        if lowlink[v] == index[v] {
330            let mut scc: Vec<String> = Vec::new();
331            loop {
332                let w = stack.pop().ok_or_else(|| {
333                    GitCortexError::Store("SCC stack underflow: Tarjan invariant violated".into())
334                })?;
335                on_stack.insert(w.clone(), false);
336                scc.push(w.clone());
337                if w == v {
338                    break;
339                }
340            }
341            if scc.len() > 1 {
342                result.push(scc);
343            }
344        }
345        Ok(())
346    }
347
348    for v in &nodes {
349        if !index.contains_key(v.as_str()) {
350            strongconnect(
351                v,
352                &adj,
353                &mut index_counter,
354                &mut stack,
355                &mut on_stack,
356                &mut index,
357                &mut lowlink,
358                &mut result,
359            )?;
360        }
361    }
362
363    Ok(result)
364}
365
366// ── Tests ────────────────────────────────────────────────────────────────────
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn node_id_is_unique() {
374        let a = NodeId::new();
375        let b = NodeId::new();
376        assert_ne!(a, b);
377    }
378
379    #[test]
380    fn graph_diff_merge() {
381        let node = Node {
382            id: NodeId::new(),
383            kind: NodeKind::Function,
384            name: "foo".into(),
385            qualified_name: "crate::foo".into(),
386            file: PathBuf::from("src/lib.rs"),
387            span: Span {
388                start_line: 1,
389                end_line: 3,
390            },
391            metadata: NodeMetadata::default(),
392        };
393        let mut base = GraphDiff::default();
394        let other = GraphDiff {
395            added_nodes: vec![node],
396            ..Default::default()
397        };
398        base.merge(other);
399        assert_eq!(base.added_nodes.len(), 1);
400    }
401
402    #[test]
403    fn graph_diff_is_empty_on_default() {
404        assert!(GraphDiff::default().is_empty());
405    }
406
407    fn import_edge(src: &NodeId, dst: &NodeId) -> Edge {
408        Edge::new(src.clone(), dst.clone(), EdgeKind::Imports)
409    }
410
411    #[test]
412    fn cycles_empty_when_imports_are_acyclic() {
413        let (a, b, c) = (NodeId::new(), NodeId::new(), NodeId::new());
414        // a → b → c, no back edge.
415        let edges = vec![import_edge(&a, &b), import_edge(&b, &c)];
416        assert!(find_import_cycles(&edges).unwrap().is_empty());
417    }
418
419    #[test]
420    fn cycles_detects_two_node_cycle() {
421        let (a, b) = (NodeId::new(), NodeId::new());
422        let edges = vec![import_edge(&a, &b), import_edge(&b, &a)];
423        let cycles = find_import_cycles(&edges).unwrap();
424        assert_eq!(cycles.len(), 1);
425        let members: std::collections::HashSet<&String> = cycles[0].iter().collect();
426        assert_eq!(members.len(), 2);
427        assert!(members.contains(&a.as_str()));
428        assert!(members.contains(&b.as_str()));
429    }
430
431    #[test]
432    fn cycles_ignores_non_import_edges() {
433        let (a, b) = (NodeId::new(), NodeId::new());
434        // A calls-cycle must not register as an import cycle.
435        let edges = vec![
436            Edge::new(a.clone(), b.clone(), EdgeKind::Calls),
437            Edge::new(b.clone(), a.clone(), EdgeKind::Calls),
438        ];
439        assert!(find_import_cycles(&edges).unwrap().is_empty());
440    }
441
442    #[test]
443    fn cycles_detects_three_node_cycle() {
444        let (a, b, c) = (NodeId::new(), NodeId::new(), NodeId::new());
445        let edges = vec![
446            import_edge(&a, &b),
447            import_edge(&b, &c),
448            import_edge(&c, &a),
449        ];
450        let cycles = find_import_cycles(&edges).unwrap();
451        assert_eq!(cycles.len(), 1);
452        assert_eq!(cycles[0].len(), 3);
453    }
454}