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