Skip to main content

grapha_core/
graph.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum NodeKind {
8    Function,
9    Class,
10    Struct,
11    Enum,
12    Trait,
13    Impl,
14    Module,
15    Field,
16    Variant,
17    Property,
18    Constant,
19    TypeAlias,
20    Protocol,  // Swift protocols
21    Extension, // Swift extensions
22    View,      // Synthetic SwiftUI view tree node
23    Branch,    // Synthetic SwiftUI branch node
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum EdgeKind {
29    Calls,
30    Uses,
31    Implements,
32    Contains,
33    TypeRef,
34    Inherits,
35    Reads,
36    Writes,
37    Publishes,
38    Subscribes,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Visibility {
44    Public,
45    Crate,
46    Private,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum TerminalKind {
52    Network,
53    Persistence,
54    Cache,
55    Event,
56    Keychain,
57    Search,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case", tag = "type")]
62pub enum NodeRole {
63    EntryPoint,
64    Terminal { kind: TerminalKind },
65    Internal,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum FlowDirection {
71    Read,
72    Write,
73    ReadWrite,
74    Pure,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Span {
79    pub start: [usize; 2],
80    pub end: [usize; 2],
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct EdgeProvenance {
85    pub file: PathBuf,
86    pub span: Span,
87    pub symbol_id: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Node {
92    pub id: String,
93    pub kind: NodeKind,
94    pub name: String,
95    pub file: PathBuf,
96    pub span: Span,
97    pub visibility: Visibility,
98    pub metadata: HashMap<String, String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub role: Option<NodeRole>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub signature: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub doc_comment: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub module: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub snippet: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct Edge {
113    pub source: String,
114    pub target: String,
115    pub kind: EdgeKind,
116    pub confidence: f64,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub direction: Option<FlowDirection>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub operation: Option<String>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub condition: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub async_boundary: Option<bool>,
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub provenance: Vec<EdgeProvenance>,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct Graph {
131    pub version: String,
132    pub nodes: Vec<Node>,
133    pub edges: Vec<Edge>,
134}
135
136impl Default for Graph {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl Graph {
143    pub fn new() -> Self {
144        Self {
145            version: "0.1.0".to_string(),
146            nodes: Vec::new(),
147            edges: Vec::new(),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn node_kind_serializes_as_snake_case() {
158        let json = serde_json::to_string(&NodeKind::Function).unwrap();
159        assert_eq!(json, "\"function\"");
160
161        let json = serde_json::to_string(&NodeKind::Class).unwrap();
162        assert_eq!(json, "\"class\"");
163
164        let json = serde_json::to_string(&NodeKind::Struct).unwrap();
165        assert_eq!(json, "\"struct\"");
166    }
167
168    #[test]
169    fn edge_kind_serializes_as_snake_case() {
170        let json = serde_json::to_string(&EdgeKind::TypeRef).unwrap();
171        assert_eq!(json, "\"type_ref\"");
172    }
173
174    #[test]
175    fn visibility_serializes_as_snake_case() {
176        let json = serde_json::to_string(&Visibility::Public).unwrap();
177        assert_eq!(json, "\"public\"");
178    }
179
180    #[test]
181    fn span_serializes_as_arrays() {
182        let span = Span {
183            start: [10, 0],
184            end: [15, 1],
185        };
186        let json = serde_json::to_string(&span).unwrap();
187        assert_eq!(json, r#"{"start":[10,0],"end":[15,1]}"#);
188    }
189
190    #[test]
191    fn graph_serializes_with_version() {
192        let graph = Graph::new();
193        let json = serde_json::to_string_pretty(&graph).unwrap();
194        assert!(json.contains("\"version\": \"0.1.0\""));
195        assert!(json.contains("\"nodes\": []"));
196        assert!(json.contains("\"edges\": []"));
197    }
198
199    #[test]
200    fn edge_serializes_with_confidence() {
201        let edge = Edge {
202            source: "a".to_string(),
203            target: "b".to_string(),
204            kind: EdgeKind::Calls,
205            confidence: 0.95,
206            direction: None,
207            operation: None,
208            condition: None,
209            async_boundary: None,
210            provenance: Vec::new(),
211        };
212        let json = serde_json::to_string(&edge).unwrap();
213        assert!(json.contains("\"confidence\":0.95"));
214    }
215
216    #[test]
217    fn new_node_kinds_serialize_correctly() {
218        assert_eq!(
219            serde_json::to_string(&NodeKind::Property).unwrap(),
220            "\"property\""
221        );
222        assert_eq!(
223            serde_json::to_string(&NodeKind::Constant).unwrap(),
224            "\"constant\""
225        );
226        assert_eq!(
227            serde_json::to_string(&NodeKind::TypeAlias).unwrap(),
228            "\"type_alias\""
229        );
230        assert_eq!(
231            serde_json::to_string(&NodeKind::Protocol).unwrap(),
232            "\"protocol\""
233        );
234        assert_eq!(
235            serde_json::to_string(&NodeKind::Extension).unwrap(),
236            "\"extension\""
237        );
238        assert_eq!(serde_json::to_string(&NodeKind::View).unwrap(), "\"view\"");
239        assert_eq!(
240            serde_json::to_string(&NodeKind::Branch).unwrap(),
241            "\"branch\""
242        );
243    }
244
245    #[test]
246    fn full_graph_round_trips() {
247        let graph = Graph {
248            version: "0.1.0".to_string(),
249            nodes: vec![Node {
250                id: "src/main.rs::main".to_string(),
251                kind: NodeKind::Function,
252                name: "main".to_string(),
253                file: PathBuf::from("src/main.rs"),
254                span: Span {
255                    start: [0, 0],
256                    end: [3, 1],
257                },
258                visibility: Visibility::Private,
259                metadata: HashMap::new(),
260                role: None,
261                signature: None,
262                doc_comment: None,
263                module: None,
264                snippet: None,
265            }],
266            edges: vec![Edge {
267                source: "src/main.rs::main".to_string(),
268                target: "src/main.rs::helper".to_string(),
269                kind: EdgeKind::Calls,
270                confidence: 0.8,
271                direction: None,
272                operation: None,
273                condition: None,
274                async_boundary: None,
275                provenance: Vec::new(),
276            }],
277        };
278        let json = serde_json::to_string(&graph).unwrap();
279        let deserialized: Graph = serde_json::from_str(&json).unwrap();
280        assert_eq!(graph, deserialized);
281    }
282
283    #[test]
284    fn terminal_kind_serializes_as_snake_case() {
285        assert_eq!(
286            serde_json::to_string(&TerminalKind::Network).unwrap(),
287            "\"network\""
288        );
289        assert_eq!(
290            serde_json::to_string(&TerminalKind::Persistence).unwrap(),
291            "\"persistence\""
292        );
293        assert_eq!(
294            serde_json::to_string(&TerminalKind::Cache).unwrap(),
295            "\"cache\""
296        );
297        assert_eq!(
298            serde_json::to_string(&TerminalKind::Keychain).unwrap(),
299            "\"keychain\""
300        );
301    }
302
303    #[test]
304    fn node_role_serializes_with_tag() {
305        let entry = NodeRole::EntryPoint;
306        let json = serde_json::to_string(&entry).unwrap();
307        assert_eq!(json, r#"{"type":"entry_point"}"#);
308
309        let terminal = NodeRole::Terminal {
310            kind: TerminalKind::Network,
311        };
312        let json = serde_json::to_string(&terminal).unwrap();
313        assert!(json.contains(r#""type":"terminal""#));
314        assert!(json.contains(r#""kind":"network""#));
315
316        let internal = NodeRole::Internal;
317        let json = serde_json::to_string(&internal).unwrap();
318        assert_eq!(json, r#"{"type":"internal"}"#);
319    }
320
321    #[test]
322    fn node_role_round_trips() {
323        let roles = vec![
324            NodeRole::EntryPoint,
325            NodeRole::Terminal {
326                kind: TerminalKind::Persistence,
327            },
328            NodeRole::Internal,
329        ];
330        for role in roles {
331            let json = serde_json::to_string(&role).unwrap();
332            let deserialized: NodeRole = serde_json::from_str(&json).unwrap();
333            assert_eq!(role, deserialized);
334        }
335    }
336
337    #[test]
338    fn flow_direction_serializes_as_snake_case() {
339        assert_eq!(
340            serde_json::to_string(&FlowDirection::Read).unwrap(),
341            "\"read\""
342        );
343        assert_eq!(
344            serde_json::to_string(&FlowDirection::Write).unwrap(),
345            "\"write\""
346        );
347        assert_eq!(
348            serde_json::to_string(&FlowDirection::ReadWrite).unwrap(),
349            "\"read_write\""
350        );
351        assert_eq!(
352            serde_json::to_string(&FlowDirection::Pure).unwrap(),
353            "\"pure\""
354        );
355    }
356
357    #[test]
358    fn new_edge_kinds_serialize_correctly() {
359        assert_eq!(
360            serde_json::to_string(&EdgeKind::Reads).unwrap(),
361            "\"reads\""
362        );
363        assert_eq!(
364            serde_json::to_string(&EdgeKind::Writes).unwrap(),
365            "\"writes\""
366        );
367        assert_eq!(
368            serde_json::to_string(&EdgeKind::Publishes).unwrap(),
369            "\"publishes\""
370        );
371        assert_eq!(
372            serde_json::to_string(&EdgeKind::Subscribes).unwrap(),
373            "\"subscribes\""
374        );
375    }
376
377    #[test]
378    fn optional_node_fields_skipped_when_none() {
379        let node = Node {
380            id: "test::foo".to_string(),
381            kind: NodeKind::Function,
382            name: "foo".to_string(),
383            file: PathBuf::from("test.rs"),
384            span: Span {
385                start: [0, 0],
386                end: [1, 0],
387            },
388            visibility: Visibility::Public,
389            metadata: HashMap::new(),
390            role: None,
391            signature: None,
392            doc_comment: None,
393            module: None,
394            snippet: None,
395        };
396        let json = serde_json::to_string(&node).unwrap();
397        assert!(!json.contains("role"));
398        assert!(!json.contains("signature"));
399        assert!(!json.contains("doc_comment"));
400        assert!(!json.contains("module"));
401    }
402
403    #[test]
404    fn optional_node_fields_present_when_set() {
405        let node = Node {
406            id: "test::foo".to_string(),
407            kind: NodeKind::Function,
408            name: "foo".to_string(),
409            file: PathBuf::from("test.rs"),
410            span: Span {
411                start: [0, 0],
412                end: [1, 0],
413            },
414            visibility: Visibility::Public,
415            metadata: HashMap::new(),
416            role: Some(NodeRole::EntryPoint),
417            signature: Some("fn foo(x: i32) -> bool".to_string()),
418            doc_comment: Some("Does foo things".to_string()),
419            module: Some("my_module".to_string()),
420            snippet: None,
421        };
422        let json = serde_json::to_string(&node).unwrap();
423        let deserialized: Node = serde_json::from_str(&json).unwrap();
424        assert_eq!(node, deserialized);
425        assert!(json.contains("entry_point"));
426        assert!(json.contains("fn foo(x: i32) -> bool"));
427        assert!(json.contains("Does foo things"));
428        assert!(json.contains("my_module"));
429    }
430
431    #[test]
432    fn optional_edge_fields_skipped_when_none() {
433        let edge = Edge {
434            source: "a".to_string(),
435            target: "b".to_string(),
436            kind: EdgeKind::Reads,
437            confidence: 0.9,
438            direction: None,
439            operation: None,
440            condition: None,
441            async_boundary: None,
442            provenance: Vec::new(),
443        };
444        let json = serde_json::to_string(&edge).unwrap();
445        assert!(!json.contains("direction"));
446        assert!(!json.contains("operation"));
447        assert!(!json.contains("condition"));
448        assert!(!json.contains("async_boundary"));
449        assert!(!json.contains("provenance"));
450    }
451
452    #[test]
453    fn optional_edge_fields_present_when_set() {
454        let edge = Edge {
455            source: "a".to_string(),
456            target: "b".to_string(),
457            kind: EdgeKind::Writes,
458            confidence: 0.85,
459            direction: Some(FlowDirection::Write),
460            operation: Some("INSERT".to_string()),
461            condition: Some("user.isAdmin".to_string()),
462            async_boundary: Some(true),
463            provenance: vec![EdgeProvenance {
464                file: PathBuf::from("main.rs"),
465                span: Span {
466                    start: [1, 0],
467                    end: [1, 12],
468                },
469                symbol_id: "main.rs::main".to_string(),
470            }],
471        };
472        let json = serde_json::to_string(&edge).unwrap();
473        let deserialized: Edge = serde_json::from_str(&json).unwrap();
474        assert_eq!(edge, deserialized);
475        assert!(json.contains("\"write\""));
476        assert!(json.contains("INSERT"));
477        assert!(json.contains("user.isAdmin"));
478        assert!(json.contains("true"));
479        assert!(json.contains("provenance"));
480    }
481
482    #[test]
483    fn extended_graph_round_trips() {
484        let graph = Graph {
485            version: "0.1.0".to_string(),
486            nodes: vec![Node {
487                id: "api::handler".to_string(),
488                kind: NodeKind::Function,
489                name: "handler".to_string(),
490                file: PathBuf::from("api.rs"),
491                span: Span {
492                    start: [0, 0],
493                    end: [10, 0],
494                },
495                visibility: Visibility::Public,
496                metadata: HashMap::new(),
497                role: Some(NodeRole::Terminal {
498                    kind: TerminalKind::Network,
499                }),
500                signature: Some("async fn handler(req: Request) -> Response".to_string()),
501                doc_comment: Some("Handles HTTP requests".to_string()),
502                module: Some("api".to_string()),
503                snippet: None,
504            }],
505            edges: vec![Edge {
506                source: "api::handler".to_string(),
507                target: "db::query".to_string(),
508                kind: EdgeKind::Reads,
509                confidence: 0.9,
510                direction: Some(FlowDirection::Read),
511                operation: Some("SELECT".to_string()),
512                condition: None,
513                async_boundary: Some(true),
514                provenance: vec![EdgeProvenance {
515                    file: PathBuf::from("api.rs"),
516                    span: Span {
517                        start: [2, 4],
518                        end: [2, 18],
519                    },
520                    symbol_id: "api::handler".to_string(),
521                }],
522            }],
523        };
524        let json = serde_json::to_string(&graph).unwrap();
525        let deserialized: Graph = serde_json::from_str(&json).unwrap();
526        assert_eq!(graph, deserialized);
527    }
528}