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