Skip to main content

gitcortex_core/
graph.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::{
7    error::GitCortexError,
8    schema::{CodeSmell, DesignPattern, EdgeKind, NodeKind, SolidHint, Visibility},
9};
10
11// ── Identifiers ──────────────────────────────────────────────────────────────
12
13/// Stable, globally unique node identifier. UUID v4 assigned at parse time.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct NodeId(Uuid);
16
17impl NodeId {
18    pub fn new() -> Self {
19        Self(Uuid::new_v4())
20    }
21
22    pub fn as_str(&self) -> String {
23        self.0.to_string()
24    }
25}
26
27impl Default for NodeId {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl std::fmt::Display for NodeId {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        self.0.fmt(f)
36    }
37}
38
39impl TryFrom<&str> for NodeId {
40    type Error = GitCortexError;
41
42    fn try_from(s: &str) -> Result<Self, Self::Error> {
43        Uuid::parse_str(s)
44            .map(NodeId)
45            .map_err(|e| GitCortexError::Store(format!("invalid NodeId '{s}': {e}")))
46    }
47}
48
49// ── Source location ───────────────────────────────────────────────────────────
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Span {
53    pub start_line: u32,
54    pub end_line: u32,
55}
56
57// ── LLD metadata ──────────────────────────────────────────────────────────────
58
59/// LLD annotations added during pass-2 analysis. All fields are optional because
60/// pass 2 runs asynchronously — nodes are queryable before annotations arrive.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
62pub struct LldLabels {
63    pub solid_hints: Vec<SolidHint>,
64    pub patterns: Vec<DesignPattern>,
65    pub smells: Vec<CodeSmell>,
66    /// Cyclomatic complexity (functions/methods only).
67    pub complexity: Option<u32>,
68}
69
70/// Per-node metadata collected during pass-1 (structural) indexing.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
72pub struct NodeMetadata {
73    /// Lines of code for this node's body.
74    pub loc: u32,
75    pub visibility: Visibility,
76    pub is_async: bool,
77    pub is_unsafe: bool,
78    /// Pass-2 LLD annotations. Empty until pass 2 runs.
79    pub lld: LldLabels,
80}
81
82// ── Core graph types ──────────────────────────────────────────────────────────
83
84/// A single named entity in the knowledge graph.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Node {
87    pub id: NodeId,
88    pub kind: NodeKind,
89    /// Short unqualified name (e.g. `"greet"`, not `"Person::greet"`).
90    pub name: String,
91    /// Qualified path within the module hierarchy (e.g. `"crate::person::Person::greet"`).
92    pub qualified_name: String,
93    /// Repo-relative path to the source file.
94    pub file: PathBuf,
95    pub span: Span,
96    pub metadata: NodeMetadata,
97}
98
99/// A directed relationship between two nodes.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct Edge {
102    pub src: NodeId,
103    pub dst: NodeId,
104    pub kind: EdgeKind,
105}
106
107// ── Graph diff ────────────────────────────────────────────────────────────────
108
109/// Incremental change set produced by the indexer after each commit.
110/// Applying a `GraphDiff` to the store brings the persisted graph up to date.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
112pub struct GraphDiff {
113    pub added_nodes: Vec<Node>,
114    /// Explicit node IDs to remove (e.g. from a targeted replacement).
115    pub removed_node_ids: Vec<NodeId>,
116    /// Files that were deleted. The store removes all nodes whose `file`
117    /// field matches any path in this list. Preferred over `removed_node_ids`
118    /// when whole files are gone because the indexer does not need to know
119    /// prior node IDs (keeping indexer ↔ store decoupled).
120    pub removed_files: Vec<PathBuf>,
121    pub added_edges: Vec<Edge>,
122    pub removed_edges: Vec<(NodeId, NodeId, EdgeKind)>,
123    /// Cross-file calls that couldn't be resolved against the diff-local node
124    /// set (because the callee lives in an unchanged file). The store resolves
125    /// these after inserting the new nodes, using its full existing data.
126    pub deferred_calls: Vec<(NodeId, String)>,
127    /// Same for parameter/return-type Uses edges.
128    pub deferred_uses: Vec<(NodeId, String)>,
129    /// Same for struct→trait Implements edges.
130    pub deferred_implements: Vec<(NodeId, String)>,
131}
132
133impl GraphDiff {
134    pub fn is_empty(&self) -> bool {
135        self.added_nodes.is_empty()
136            && self.removed_node_ids.is_empty()
137            && self.removed_files.is_empty()
138            && self.added_edges.is_empty()
139            && self.removed_edges.is_empty()
140            && self.deferred_calls.is_empty()
141            && self.deferred_uses.is_empty()
142            && self.deferred_implements.is_empty()
143    }
144
145    /// Merge another diff into this one. Used when multiple files change
146    /// in parallel and their per-file diffs are combined before a single
147    /// store write.
148    pub fn merge(&mut self, other: GraphDiff) {
149        self.added_nodes.extend(other.added_nodes);
150        self.removed_node_ids.extend(other.removed_node_ids);
151        self.removed_files.extend(other.removed_files);
152        self.added_edges.extend(other.added_edges);
153        self.removed_edges.extend(other.removed_edges);
154        self.deferred_calls.extend(other.deferred_calls);
155        self.deferred_uses.extend(other.deferred_uses);
156        self.deferred_implements.extend(other.deferred_implements);
157    }
158}
159
160// ── Tests ────────────────────────────────────────────────────────────────────
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn node_id_is_unique() {
168        let a = NodeId::new();
169        let b = NodeId::new();
170        assert_ne!(a, b);
171    }
172
173    #[test]
174    fn graph_diff_merge() {
175        let node = Node {
176            id: NodeId::new(),
177            kind: NodeKind::Function,
178            name: "foo".into(),
179            qualified_name: "crate::foo".into(),
180            file: PathBuf::from("src/lib.rs"),
181            span: Span {
182                start_line: 1,
183                end_line: 3,
184            },
185            metadata: NodeMetadata::default(),
186        };
187        let mut base = GraphDiff::default();
188        let other = GraphDiff {
189            added_nodes: vec![node],
190            ..Default::default()
191        };
192        base.merge(other);
193        assert_eq!(base.added_nodes.len(), 1);
194    }
195
196    #[test]
197    fn graph_diff_is_empty_on_default() {
198        assert!(GraphDiff::default().is_empty());
199    }
200}