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, Extension, View, Branch, }
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}