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/// Source-text capture for a node — signature, body slice, preceding doc-comment,
71/// and byte range into the original file. Filled during pass 1 from the
72/// tree-sitter node's byte range; cheap (no extra parsing).
73///
74/// Powers wiki rendering, tour narration, and future semantic search.
75/// Empty default means "not captured" — legacy rows return all-empty.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
77pub struct DefinitionText {
78    /// First line(s) of the definition up to (and excluding) the body block.
79    /// E.g. `pub fn apply_diff(&mut self, branch: &str, diff: &GraphDiff) -> Result<()>`.
80    pub signature: String,
81    /// Full source slice of the node, including signature and body.
82    pub body: String,
83    /// Doc-comment immediately preceding the node (`///`, `//!`, `/** */`, `"""`).
84    /// `None` when absent.
85    pub doc_comment: Option<String>,
86    /// Byte offsets into the parent file. `(0, 0)` if not captured.
87    pub start_byte: u32,
88    pub end_byte: u32,
89}
90
91/// Per-node metadata collected during pass-1 (structural) indexing.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
93pub struct NodeMetadata {
94    /// Lines of code for this node's body.
95    pub loc: u32,
96    pub visibility: Visibility,
97    pub is_async: bool,
98    pub is_unsafe: bool,
99    /// Java `static`, Python `@staticmethod`, Go package-level functions.
100    pub is_static: bool,
101    /// Java/TypeScript `abstract`, Python NotImplemented stubs, sealed traits.
102    pub is_abstract: bool,
103    /// Java `final` class/method, Rust sealed types, TypeScript `readonly`.
104    pub is_final: bool,
105    /// Python `@property`, TypeScript getter/setter, Rust associated `const`.
106    pub is_property: bool,
107    /// Python generators (`yield`), TypeScript `function*`, async generators.
108    pub is_generator: bool,
109    /// Rust `const fn`, TypeScript `const` assertion, Java `static final` fields.
110    pub is_const: bool,
111    /// Captured generic constraints, e.g. `["T: Send", "T: 'static"]` or
112    /// `["T extends Base", "K extends keyof T"]`.
113    pub generic_bounds: Vec<String>,
114    /// Pass-2 LLD annotations. Empty until pass 2 runs.
115    pub lld: LldLabels,
116    /// Raw source-text capture — signature, body, doc-comment, byte range.
117    pub definition: DefinitionText,
118}
119
120// ── Core graph types ──────────────────────────────────────────────────────────
121
122/// A single named entity in the knowledge graph.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct Node {
125    pub id: NodeId,
126    pub kind: NodeKind,
127    /// Short unqualified name (e.g. `"greet"`, not `"Person::greet"`).
128    pub name: String,
129    /// Qualified path within the module hierarchy (e.g. `"crate::person::Person::greet"`).
130    pub qualified_name: String,
131    /// Repo-relative path to the source file.
132    pub file: PathBuf,
133    pub span: Span,
134    pub metadata: NodeMetadata,
135}
136
137/// A directed relationship between two nodes.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Edge {
140    pub src: NodeId,
141    pub dst: NodeId,
142    pub kind: EdgeKind,
143}
144
145// ── Graph diff ────────────────────────────────────────────────────────────────
146
147/// Incremental change set produced by the indexer after each commit.
148/// Applying a `GraphDiff` to the store brings the persisted graph up to date.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
150pub struct GraphDiff {
151    pub added_nodes: Vec<Node>,
152    /// Explicit node IDs to remove (e.g. from a targeted replacement).
153    pub removed_node_ids: Vec<NodeId>,
154    /// Files that were deleted. The store removes all nodes whose `file`
155    /// field matches any path in this list. Preferred over `removed_node_ids`
156    /// when whole files are gone because the indexer does not need to know
157    /// prior node IDs (keeping indexer ↔ store decoupled).
158    pub removed_files: Vec<PathBuf>,
159    pub added_edges: Vec<Edge>,
160    pub removed_edges: Vec<(NodeId, NodeId, EdgeKind)>,
161    /// Cross-file calls that couldn't be resolved against the diff-local node
162    /// set (because the callee lives in an unchanged file). The store resolves
163    /// these after inserting the new nodes, using its full existing data.
164    pub deferred_calls: Vec<(NodeId, String)>,
165    /// Same for parameter/return-type Uses edges.
166    pub deferred_uses: Vec<(NodeId, String)>,
167    /// Same for struct→trait Implements edges.
168    pub deferred_implements: Vec<(NodeId, String)>,
169    /// Same for `extends` / inheritance edges.
170    pub deferred_inherits: Vec<(NodeId, String)>,
171    /// Same for `throws ExceptionType` edges.
172    pub deferred_throws: Vec<(NodeId, String)>,
173    /// Same for decorator/annotation references.
174    pub deferred_annotated: Vec<(NodeId, String)>,
175}
176
177impl GraphDiff {
178    pub fn is_empty(&self) -> bool {
179        self.added_nodes.is_empty()
180            && self.removed_node_ids.is_empty()
181            && self.removed_files.is_empty()
182            && self.added_edges.is_empty()
183            && self.removed_edges.is_empty()
184            && self.deferred_calls.is_empty()
185            && self.deferred_uses.is_empty()
186            && self.deferred_implements.is_empty()
187            && self.deferred_inherits.is_empty()
188            && self.deferred_throws.is_empty()
189            && self.deferred_annotated.is_empty()
190    }
191
192    /// Merge another diff into this one. Used when multiple files change
193    /// in parallel and their per-file diffs are combined before a single
194    /// store write.
195    pub fn merge(&mut self, other: GraphDiff) {
196        self.added_nodes.extend(other.added_nodes);
197        self.removed_node_ids.extend(other.removed_node_ids);
198        self.removed_files.extend(other.removed_files);
199        self.added_edges.extend(other.added_edges);
200        self.removed_edges.extend(other.removed_edges);
201        self.deferred_calls.extend(other.deferred_calls);
202        self.deferred_uses.extend(other.deferred_uses);
203        self.deferred_implements.extend(other.deferred_implements);
204        self.deferred_inherits.extend(other.deferred_inherits);
205        self.deferred_throws.extend(other.deferred_throws);
206        self.deferred_annotated.extend(other.deferred_annotated);
207    }
208}
209
210// ── Tests ────────────────────────────────────────────────────────────────────
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn node_id_is_unique() {
218        let a = NodeId::new();
219        let b = NodeId::new();
220        assert_ne!(a, b);
221    }
222
223    #[test]
224    fn graph_diff_merge() {
225        let node = Node {
226            id: NodeId::new(),
227            kind: NodeKind::Function,
228            name: "foo".into(),
229            qualified_name: "crate::foo".into(),
230            file: PathBuf::from("src/lib.rs"),
231            span: Span {
232                start_line: 1,
233                end_line: 3,
234            },
235            metadata: NodeMetadata::default(),
236        };
237        let mut base = GraphDiff::default();
238        let other = GraphDiff {
239            added_nodes: vec![node],
240            ..Default::default()
241        };
242        base.merge(other);
243        assert_eq!(base.added_nodes.len(), 1);
244    }
245
246    #[test]
247    fn graph_diff_is_empty_on_default() {
248        assert!(GraphDiff::default().is_empty());
249    }
250}