Skip to main content

dendryform_parse/
lib.rs

1//! # dendryform-parse
2//!
3//! YAML and JSON parser for dendryform diagram definitions.
4//!
5//! Reads a diagram string or file, deserializes it into a validated
6//! [`dendryform_core::Diagram`]. Validation (duplicate IDs,
7//! dangling edges, empty tiers, nesting depth) happens automatically
8//! during deserialization.
9//!
10//! ## Quick Start
11//!
12//! ```no_run
13//! use dendryform_parse::parse_yaml_file;
14//!
15//! let diagram = parse_yaml_file("examples/taproot/architecture.yaml").unwrap();
16//! println!("Diagram: {}", diagram.header().title().text());
17//! ```
18
19mod error;
20
21use std::path::Path;
22
23use dendryform_core::Diagram;
24
25pub use error::ParseError;
26
27/// Parses a YAML string into a validated [`Diagram`].
28pub fn parse_yaml(input: &str) -> Result<Diagram, ParseError> {
29    let diagram: Diagram = serde_yml::from_str(input)?;
30    Ok(diagram)
31}
32
33/// Parses a YAML file into a validated [`Diagram`].
34pub fn parse_yaml_file(path: impl AsRef<Path>) -> Result<Diagram, ParseError> {
35    let content = std::fs::read_to_string(path)?;
36    parse_yaml(&content)
37}
38
39/// Parses a JSON string into a validated [`Diagram`].
40pub fn parse_json(input: &str) -> Result<Diagram, ParseError> {
41    let diagram: Diagram = serde_json::from_str(input)?;
42    Ok(diagram)
43}
44
45/// Parses a JSON file into a validated [`Diagram`].
46pub fn parse_json_file(path: impl AsRef<Path>) -> Result<Diagram, ParseError> {
47    let content = std::fs::read_to_string(path)?;
48    parse_json(&content)
49}
50
51/// Returns the version of the dendryform-parse crate.
52pub fn version() -> &'static str {
53    env!("CARGO_PKG_VERSION")
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn test_version_is_set() {
62        assert_eq!(version(), "0.1.0");
63    }
64
65    #[test]
66    fn test_parse_taproot_yaml() {
67        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
68        let diagram = parse_yaml(yaml).expect("taproot.yml should parse successfully");
69        assert_eq!(diagram.header().title().text(), "system architecture");
70        assert_eq!(diagram.header().title().accent(), "taproot");
71        assert_eq!(
72            diagram.header().subtitle(),
73            "natural language analytics over BigQuery via MCP"
74        );
75        assert_eq!(diagram.header().theme(), "dark");
76    }
77
78    #[test]
79    fn test_parse_taproot_yaml_layers() {
80        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
81        let diagram = parse_yaml(yaml).unwrap();
82        // taproot.yml has: tier, connector, tier, flow_labels, tier = 5 top-level layers
83        assert_eq!(diagram.layers().len(), 5);
84    }
85
86    #[test]
87    fn test_parse_taproot_yaml_edges() {
88        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
89        let diagram = parse_yaml(yaml).unwrap();
90        // taproot.yml has 18 edges
91        assert_eq!(diagram.edges().len(), 18);
92    }
93
94    #[test]
95    fn test_parse_taproot_yaml_legend() {
96        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
97        let diagram = parse_yaml(yaml).unwrap();
98        assert_eq!(diagram.legend().len(), 6);
99    }
100
101    #[test]
102    fn test_parse_ai_kasu_yaml() {
103        let yaml = include_str!("../../../examples/ai-kasu/architecture.yaml");
104        let diagram = parse_yaml(yaml).expect("ai-kasu architecture.yaml should parse");
105        assert_eq!(diagram.header().title().accent(), "ai-kasu");
106        assert_eq!(diagram.header().title().text(), "MCP server architecture");
107        assert_eq!(diagram.layers().len(), 7);
108        assert_eq!(diagram.legend().len(), 6);
109        assert!(diagram.edges().len() > 20);
110    }
111
112    #[test]
113    fn test_parse_oxur_lisp_yaml() {
114        let yaml = include_str!("../../../examples/oxur-lisp/architecture.yaml");
115        let diagram = parse_yaml(yaml).expect("oxur-lisp architecture.yaml should parse");
116        assert_eq!(diagram.header().title().accent(), "oxur");
117        assert_eq!(diagram.header().title().text(), "language architecture");
118        assert_eq!(diagram.layers().len(), 9);
119        assert_eq!(diagram.legend().len(), 6);
120        assert!(diagram.edges().len() > 15);
121    }
122
123    #[test]
124    fn test_parse_yaml_malformed() {
125        let yaml = "this: is: not: valid: yaml: [";
126        let err = parse_yaml(yaml).unwrap_err();
127        assert!(matches!(err, ParseError::Yaml(_)));
128        let msg = format!("{err}");
129        assert!(msg.contains("YAML parse error"));
130    }
131
132    #[test]
133    fn test_parse_yaml_missing_required_field() {
134        let yaml = r#"
135diagram:
136  title:
137    text: "test"
138    accent: "test"
139  subtitle: "test"
140layers: []
141edges: []
142"#;
143        // Missing `theme` field in diagram header
144        let err = parse_yaml(yaml).unwrap_err();
145        assert!(matches!(err, ParseError::Yaml(_)));
146    }
147
148    #[test]
149    fn test_parse_yaml_duplicate_node_id() {
150        let yaml = r#"
151diagram:
152  title:
153    text: "test"
154    accent: "t"
155  subtitle: "sub"
156  theme: dark
157layers:
158  - tier:
159      id: main
160      nodes:
161        - id: app
162          kind: system
163          color: blue
164          icon: "◇"
165          title: "App"
166          description: "The app"
167        - id: app
168          kind: system
169          color: blue
170          icon: "◇"
171          title: "App duplicate"
172          description: "Same ID"
173"#;
174        let err = parse_yaml(yaml).unwrap_err();
175        let msg = format!("{err}");
176        assert!(msg.contains("duplicate node ID"));
177    }
178
179    #[test]
180    fn test_parse_yaml_dangling_edge() {
181        let yaml = r#"
182diagram:
183  title:
184    text: "test"
185    accent: "t"
186  subtitle: "sub"
187  theme: dark
188layers:
189  - tier:
190      id: main
191      nodes:
192        - id: app
193          kind: system
194          color: blue
195          icon: "◇"
196          title: "App"
197          description: "The app"
198edges:
199  - from: app
200    to: ghost
201    kind: uses
202"#;
203        let err = parse_yaml(yaml).unwrap_err();
204        let msg = format!("{err}");
205        assert!(msg.contains("ghost"));
206    }
207
208    #[test]
209    fn test_parse_json_round_trip() {
210        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
211        let diagram = parse_yaml(yaml).unwrap();
212        let json = serde_json::to_string_pretty(&diagram).unwrap();
213        let from_json = parse_json(&json).unwrap();
214        assert_eq!(diagram, from_json);
215    }
216
217    #[test]
218    fn test_parse_json_malformed() {
219        let err = parse_json("{invalid json").unwrap_err();
220        assert!(matches!(err, ParseError::Json(_)));
221        let msg = format!("{err}");
222        assert!(msg.contains("JSON parse error"));
223    }
224
225    #[test]
226    fn test_parse_error_io() {
227        let err = parse_yaml_file("/nonexistent/path/to/file.yml").unwrap_err();
228        assert!(matches!(err, ParseError::Io(_)));
229        let msg = format!("{err}");
230        assert!(msg.contains("I/O error"));
231    }
232
233    #[test]
234    fn test_parse_error_display_and_source() {
235        let err = parse_yaml("not valid yaml [").unwrap_err();
236        // Verify Error trait source chain works
237        let source = std::error::Error::source(&err);
238        assert!(source.is_some());
239    }
240
241    #[test]
242    fn test_parse_yaml_empty_tier_rejected() {
243        let yaml = r#"
244diagram:
245  title:
246    text: "test"
247    accent: "t"
248  subtitle: "sub"
249  theme: dark
250layers:
251  - tier:
252      id: empty
253      nodes: []
254"#;
255        let err = parse_yaml(yaml).unwrap_err();
256        let msg = format!("{err}");
257        assert!(msg.contains("empty"));
258    }
259
260    #[test]
261    fn test_parse_yaml_minimal_valid() {
262        let yaml = r#"
263diagram:
264  title:
265    text: "test"
266    accent: "t"
267  subtitle: "sub"
268  theme: dark
269layers:
270  - tier:
271      id: main
272      nodes:
273        - id: app
274          kind: system
275          color: blue
276          icon: "◇"
277          title: "App"
278          description: "The app"
279"#;
280        let diagram = parse_yaml(yaml).unwrap();
281        assert_eq!(diagram.header().title().text(), "test");
282        assert_eq!(diagram.layers().len(), 1);
283        assert!(diagram.edges().is_empty());
284        assert!(diagram.legend().is_empty());
285    }
286
287    #[test]
288    fn test_parse_json_file_nonexistent() {
289        let err = parse_json_file("/nonexistent/path/to/file.json").unwrap_err();
290        assert!(matches!(err, ParseError::Io(_)));
291        let msg = format!("{err}");
292        assert!(msg.contains("I/O error"));
293    }
294
295    #[test]
296    fn test_parse_json_file_round_trip() {
297        // First parse YAML, serialize to JSON, write to temp file, parse JSON file
298        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
299        let diagram = parse_yaml(yaml).unwrap();
300        let json = serde_json::to_string_pretty(&diagram).unwrap();
301
302        let tmp_dir = std::env::temp_dir();
303        let tmp_file = tmp_dir.join("dendryform_test_parse_json_file.json");
304        std::fs::write(&tmp_file, &json).unwrap();
305
306        let from_file = parse_json_file(&tmp_file).unwrap();
307        assert_eq!(diagram, from_file);
308
309        // Clean up
310        let _ = std::fs::remove_file(&tmp_file);
311    }
312
313    #[test]
314    fn test_parse_yaml_file_valid() {
315        // Write YAML to a temp file, then parse it
316        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
317        let tmp_dir = std::env::temp_dir();
318        let tmp_file = tmp_dir.join("dendryform_test_parse_yaml_file.yaml");
319        std::fs::write(&tmp_file, yaml).unwrap();
320
321        let diagram = parse_yaml_file(&tmp_file).unwrap();
322        assert_eq!(diagram.header().title().accent(), "taproot");
323        assert_eq!(diagram.layers().len(), 5);
324
325        // Clean up
326        let _ = std::fs::remove_file(&tmp_file);
327    }
328
329    #[test]
330    fn test_parse_json_malformed_missing_field() {
331        let json = r#"{"diagram": {"title": {"text": "t", "accent": "a"}, "subtitle": "s"}}"#;
332        let err = parse_json(json).unwrap_err();
333        assert!(matches!(err, ParseError::Json(_)));
334    }
335}