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, EdgeConfidence, 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 /// Decorator / annotation names applied to this symbol, e.g.
115 /// `["dataclass"]`, `["Override"]`, `["derive", "Serialize"]`. Captured
116 /// regardless of whether the decorator is defined in-repo, so framework
117 /// decorators (`@app.route`, `@Test`) remain queryable even though their
118 /// `Annotated` edge target is external and dropped.
119 pub annotations: Vec<String>,
120 /// Pass-2 LLD annotations. Empty until pass 2 runs.
121 pub lld: LldLabels,
122 /// Raw source-text capture — signature, body, doc-comment, byte range.
123 pub definition: DefinitionText,
124}
125
126// ── Core graph types ──────────────────────────────────────────────────────────
127
128/// A single named entity in the knowledge graph.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Node {
131 pub id: NodeId,
132 pub kind: NodeKind,
133 /// Short unqualified name (e.g. `"greet"`, not `"Person::greet"`).
134 pub name: String,
135 /// Qualified path within the module hierarchy (e.g. `"crate::person::Person::greet"`).
136 pub qualified_name: String,
137 /// Repo-relative path to the source file.
138 pub file: PathBuf,
139 pub span: Span,
140 pub metadata: NodeMetadata,
141}
142
143/// A directed relationship between two nodes.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Edge {
146 pub src: NodeId,
147 pub dst: NodeId,
148 pub kind: EdgeKind,
149 /// Source line of the relationship's origin, when meaningful. Set for
150 /// `Calls` edges (the line of the call expression) so call sites can be
151 /// pinpointed; `None` for structural edges (Contains, Implements, …).
152 #[serde(default)]
153 pub line: Option<u32>,
154 /// How confident the indexer is this edge is real (see [`EdgeConfidence`]).
155 #[serde(default)]
156 pub confidence: EdgeConfidence,
157}
158
159impl Edge {
160 /// Construct an edge with no associated source line (structural edges).
161 /// Defaults to `Extracted` confidence.
162 pub fn new(src: NodeId, dst: NodeId, kind: EdgeKind) -> Self {
163 Self {
164 src,
165 dst,
166 kind,
167 line: None,
168 confidence: EdgeConfidence::Extracted,
169 }
170 }
171
172 /// Construct a `Calls` edge carrying the call-expression line.
173 pub fn call(src: NodeId, dst: NodeId, line: u32) -> Self {
174 Self {
175 src,
176 dst,
177 kind: EdgeKind::Calls,
178 line: Some(line),
179 confidence: EdgeConfidence::Extracted,
180 }
181 }
182
183 /// Set the edge's confidence (builder-style), e.g. mark a cross-file
184 /// name-resolved edge as `Inferred`.
185 pub fn with_confidence(mut self, confidence: EdgeConfidence) -> Self {
186 self.confidence = confidence;
187 self
188 }
189}
190
191// ── Graph diff ────────────────────────────────────────────────────────────────
192
193/// Incremental change set produced by the indexer after each commit.
194/// Applying a `GraphDiff` to the store brings the persisted graph up to date.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
196pub struct GraphDiff {
197 pub added_nodes: Vec<Node>,
198 /// Explicit node IDs to remove (e.g. from a targeted replacement).
199 pub removed_node_ids: Vec<NodeId>,
200 /// Files that were deleted. The store removes all nodes whose `file`
201 /// field matches any path in this list. Preferred over `removed_node_ids`
202 /// when whole files are gone because the indexer does not need to know
203 /// prior node IDs (keeping indexer ↔ store decoupled).
204 pub removed_files: Vec<PathBuf>,
205 pub added_edges: Vec<Edge>,
206 pub removed_edges: Vec<(NodeId, NodeId, EdgeKind)>,
207 /// Cross-file calls that couldn't be resolved against the diff-local node
208 /// set (because the callee lives in an unchanged file). The store resolves
209 /// these after inserting the new nodes, using its full existing data.
210 /// Tuple: `(caller_id, callee_name, call_line)`.
211 pub deferred_calls: Vec<(NodeId, String, u32)>,
212 /// Same for parameter/return-type Uses edges.
213 pub deferred_uses: Vec<(NodeId, String)>,
214 /// Same for struct→trait Implements edges.
215 pub deferred_implements: Vec<(NodeId, String)>,
216 /// Same for `extends` / inheritance edges.
217 pub deferred_inherits: Vec<(NodeId, String)>,
218 /// Same for `throws ExceptionType` edges.
219 pub deferred_throws: Vec<(NodeId, String)>,
220 /// Same for decorator/annotation references.
221 pub deferred_annotated: Vec<(NodeId, String)>,
222}
223
224impl GraphDiff {
225 pub fn is_empty(&self) -> bool {
226 self.added_nodes.is_empty()
227 && self.removed_node_ids.is_empty()
228 && self.removed_files.is_empty()
229 && self.added_edges.is_empty()
230 && self.removed_edges.is_empty()
231 && self.deferred_calls.is_empty()
232 && self.deferred_uses.is_empty()
233 && self.deferred_implements.is_empty()
234 && self.deferred_inherits.is_empty()
235 && self.deferred_throws.is_empty()
236 && self.deferred_annotated.is_empty()
237 }
238
239 /// Merge another diff into this one. Used when multiple files change
240 /// in parallel and their per-file diffs are combined before a single
241 /// store write.
242 pub fn merge(&mut self, other: GraphDiff) {
243 self.added_nodes.extend(other.added_nodes);
244 self.removed_node_ids.extend(other.removed_node_ids);
245 self.removed_files.extend(other.removed_files);
246 self.added_edges.extend(other.added_edges);
247 self.removed_edges.extend(other.removed_edges);
248 self.deferred_calls.extend(other.deferred_calls);
249 self.deferred_uses.extend(other.deferred_uses);
250 self.deferred_implements.extend(other.deferred_implements);
251 self.deferred_inherits.extend(other.deferred_inherits);
252 self.deferred_throws.extend(other.deferred_throws);
253 self.deferred_annotated.extend(other.deferred_annotated);
254 }
255}
256
257// ── Tests ────────────────────────────────────────────────────────────────────
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn node_id_is_unique() {
265 let a = NodeId::new();
266 let b = NodeId::new();
267 assert_ne!(a, b);
268 }
269
270 #[test]
271 fn graph_diff_merge() {
272 let node = Node {
273 id: NodeId::new(),
274 kind: NodeKind::Function,
275 name: "foo".into(),
276 qualified_name: "crate::foo".into(),
277 file: PathBuf::from("src/lib.rs"),
278 span: Span {
279 start_line: 1,
280 end_line: 3,
281 },
282 metadata: NodeMetadata::default(),
283 };
284 let mut base = GraphDiff::default();
285 let other = GraphDiff {
286 added_nodes: vec![node],
287 ..Default::default()
288 };
289 base.merge(other);
290 assert_eq!(base.added_nodes.len(), 1);
291 }
292
293 #[test]
294 fn graph_diff_is_empty_on_default() {
295 assert!(GraphDiff::default().is_empty());
296 }
297}