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// ── Tests ────────────────────────────────────────────────────────────────────
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn node_id_is_unique() {
286 let a = NodeId::new();
287 let b = NodeId::new();
288 assert_ne!(a, b);
289 }
290
291 #[test]
292 fn graph_diff_merge() {
293 let node = Node {
294 id: NodeId::new(),
295 kind: NodeKind::Function,
296 name: "foo".into(),
297 qualified_name: "crate::foo".into(),
298 file: PathBuf::from("src/lib.rs"),
299 span: Span {
300 start_line: 1,
301 end_line: 3,
302 },
303 metadata: NodeMetadata::default(),
304 };
305 let mut base = GraphDiff::default();
306 let other = GraphDiff {
307 added_nodes: vec![node],
308 ..Default::default()
309 };
310 base.merge(other);
311 assert_eq!(base.added_nodes.len(), 1);
312 }
313
314 #[test]
315 fn graph_diff_is_empty_on_default() {
316 assert!(GraphDiff::default().is_empty());
317 }
318}