Skip to main content

hedl_yaml/
lib.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18#![cfg_attr(not(test), warn(missing_docs))]
19
20//! HEDL YAML Conversion
21//!
22//! Provides bidirectional conversion between HEDL documents and YAML format, with comprehensive
23//! support for HEDL's rich type system and data structures.
24//!
25//! # Important Limitations
26//!
27//! When converting HEDL to YAML and back, certain HEDL-specific metadata is **not preserved**:
28//!
29//! - **ALIAS declarations**: Type aliases are lost (YAML has no equivalent concept)
30//! - **NEST definitions**: Entity hierarchy declarations are lost (only structure remains)
31//! - **STRUCT schemas**: Explicit field constraints and type declarations are lost
32//! - **Type constraints**: Validation rules (min/max/format) are not preserved
33//! - **Document metadata**: Version, author, license, etc. are not preserved
34//!
35//! YAML is an excellent format for data exchange and configuration, but it lacks the type system
36//! and schema capabilities of HEDL's native format. See the README for detailed examples and
37//! recommended workarounds.
38//!
39//! # What IS Preserved
40//!
41//! The following are preserved during YAML conversion:
42//! - Scalar values (strings, numbers, booleans, null)
43//! - References (local and qualified)
44//! - Expressions
45//! - Tensors (multi-dimensional arrays)
46//! - Objects and nested structures
47//! - Matrix lists (inferred schemas)
48//! - Hierarchical relationships (structural only)
49//!
50//! # Examples
51//!
52//! ## Converting HEDL to YAML
53//!
54//! ```rust
55//! use hedl_core::{Document, Item, Value};
56//! use hedl_yaml::{to_yaml, ToYamlConfig};
57//! use std::collections::BTreeMap;
58//!
59//! let mut doc = Document {
60//!     version: (1, 0),
61//!     schema_versions: BTreeMap::new(),
62//!     aliases: BTreeMap::new(),
63//!     structs: BTreeMap::new(),
64//!     nests: BTreeMap::new(),
65//!     root: BTreeMap::new(),
66//! };
67//! let mut root = BTreeMap::new();
68//! root.insert("name".to_string(), Item::Scalar(Value::String("example".to_string().into())));
69//! root.insert("count".to_string(), Item::Scalar(Value::Int(42)));
70//! doc.root = root;
71//!
72//! let config = ToYamlConfig::default();
73//! let yaml = to_yaml(&doc, &config).unwrap();
74//! println!("{}", yaml);
75//! ```
76//!
77//! ## Converting YAML to HEDL
78//!
79//! ```rust
80//! use hedl_yaml::{from_yaml, FromYamlConfig};
81//!
82//! let yaml = r#"
83//! name: example
84//! count: 42
85//! active: true
86//! "#;
87//!
88//! // Use default configuration with high limits (500MB / 10M / 10K)
89//! let config = FromYamlConfig::default();
90//! let doc = from_yaml(yaml, &config).unwrap();
91//! assert_eq!(doc.version, (2, 0));
92//! ```
93//!
94//! ## Customizing Resource Limits
95//!
96//! ```rust
97//! use hedl_yaml::{from_yaml, FromYamlConfig};
98//!
99//! // For untrusted input, use conservative limits
100//! let config = FromYamlConfig::builder()
101//!     .max_document_size(10 * 1024 * 1024)  // 10 MB
102//!     .max_array_length(100_000)             // 100K elements
103//!     .max_nesting_depth(100)                // 100 levels
104//!     .build();
105//!
106//! let yaml = "name: test\nvalue: 123\n";
107//! let doc = from_yaml(yaml, &config).unwrap();
108//! ```
109//!
110//! ## Round-trip Conversion (with Metadata Loss)
111//!
112//! ```rust
113//! use hedl_core::{Document, Item, Value};
114//! use hedl_yaml::{to_yaml, from_yaml, ToYamlConfig, FromYamlConfig};
115//! use std::collections::BTreeMap;
116//!
117//! // Create original document
118//! let mut doc = Document {
119//!     version: (1, 0),
120//!     schema_versions: BTreeMap::new(),
121//!     aliases: BTreeMap::new(),
122//!     structs: BTreeMap::new(),
123//!     nests: BTreeMap::new(),
124//!     root: BTreeMap::new(),
125//! };
126//! let mut root = BTreeMap::new();
127//! root.insert("test".to_string(), Item::Scalar(Value::String("value".to_string().into())));
128//! doc.root = root;
129//!
130//! // Convert to YAML and back
131//! let to_config = ToYamlConfig::default();
132//! let yaml = to_yaml(&doc, &to_config).unwrap();
133//!
134//! let from_config = FromYamlConfig::default();
135//! let restored = from_yaml(&yaml, &from_config).unwrap();
136//!
137//! // Data is preserved, but version defaults to v2.0 (not preserved in YAML)
138//! assert_eq!(restored.version, (2, 0));
139//! ```
140//!
141//! ## Preserving Metadata with Hints
142//!
143//! ```rust
144//! use hedl_core::{Document, Item, Value};
145//! use hedl_yaml::{to_yaml, ToYamlConfig};
146//! use std::collections::BTreeMap;
147//!
148//! let mut doc = Document {
149//!     version: (1, 0),
150//!     schema_versions: BTreeMap::new(),
151//!     aliases: BTreeMap::new(),
152//!     structs: BTreeMap::new(),
153//!     nests: BTreeMap::new(),
154//!     root: BTreeMap::new(),
155//! };
156//! let mut root = BTreeMap::new();
157//! root.insert("count".to_string(), Item::Scalar(Value::Int(42)));
158//! doc.root = root;
159//!
160//! // Enable metadata hints in YAML output
161//! let config = ToYamlConfig {
162//!     include_metadata: true,  // Adds __type__ and __schema__ hints
163//!     ..Default::default()
164//! };
165//! let yaml = to_yaml(&doc, &config).unwrap();
166//! // YAML includes type hints, but they won't prevent data-only schemas
167//! ```
168
169mod anchors;
170/// YAML error types.
171pub mod error;
172/// YAML to HEDL conversion.
173pub mod from_yaml;
174mod to_yaml;
175/// YAML token scanning.
176pub mod yaml_scanner;
177
178// Re-export the shared DEFAULT_SCHEMA from hedl-core for internal use
179pub(crate) use hedl_core::convert::DEFAULT_SCHEMA;
180
181pub use error::{ErrorContext, YamlError};
182pub use from_yaml::{
183    from_yaml, from_yaml_value, FromYamlConfig, FromYamlConfigBuilder, DEFAULT_MAX_ARRAY_LENGTH,
184    DEFAULT_MAX_DOCUMENT_SIZE, DEFAULT_MAX_NESTING_DEPTH,
185};
186pub use to_yaml::{to_yaml, to_yaml_value, ToYamlConfig};
187
188use hedl_core::Document;
189
190/// Convert HEDL document to YAML string with default configuration
191pub fn hedl_to_yaml(doc: &Document) -> Result<String, String> {
192    to_yaml(doc, &ToYamlConfig::default())
193}
194
195/// Convert YAML string to HEDL document with default configuration
196pub fn yaml_to_hedl(yaml: &str) -> Result<Document, String> {
197    from_yaml(yaml, &FromYamlConfig::default())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use hedl_core::lex::Tensor;
204    use hedl_core::{Document, Item, MatrixList, Node, Reference, Value};
205    use std::collections::BTreeMap;
206
207    #[test]
208    fn test_round_trip_scalars() {
209        let mut doc = Document {
210            version: (1, 0),
211            aliases: BTreeMap::new(),
212            root: BTreeMap::new(),
213            structs: BTreeMap::new(),
214            nests: BTreeMap::new(),
215            schema_versions: BTreeMap::new(),
216        };
217        let mut root = BTreeMap::new();
218
219        root.insert("null_val".to_string(), Item::Scalar(Value::Null));
220        root.insert("bool_val".to_string(), Item::Scalar(Value::Bool(true)));
221        root.insert("int_val".to_string(), Item::Scalar(Value::Int(42)));
222        root.insert("float_val".to_string(), Item::Scalar(Value::Float(3.25)));
223        root.insert(
224            "string_val".to_string(),
225            Item::Scalar(Value::String("hello".to_string().into())),
226        );
227
228        doc.root = root;
229
230        let yaml = hedl_to_yaml(&doc).unwrap();
231        let restored = yaml_to_hedl(&yaml).unwrap();
232
233        assert_eq!(restored.root.len(), 5);
234        assert_eq!(
235            restored.root.get("bool_val").unwrap().as_scalar().unwrap(),
236            &Value::Bool(true)
237        );
238        assert_eq!(
239            restored.root.get("int_val").unwrap().as_scalar().unwrap(),
240            &Value::Int(42)
241        );
242        assert_eq!(
243            restored
244                .root
245                .get("string_val")
246                .unwrap()
247                .as_scalar()
248                .unwrap(),
249            &Value::String("hello".to_string().into())
250        );
251    }
252
253    #[test]
254    fn test_round_trip_reference() {
255        let mut doc = Document {
256            version: (1, 0),
257            aliases: BTreeMap::new(),
258            root: BTreeMap::new(),
259            structs: BTreeMap::new(),
260            nests: BTreeMap::new(),
261            schema_versions: BTreeMap::new(),
262        };
263        let mut root = BTreeMap::new();
264
265        root.insert(
266            "local_ref".to_string(),
267            Item::Scalar(Value::Reference(Reference::local("item1"))),
268        );
269        root.insert(
270            "qualified_ref".to_string(),
271            Item::Scalar(Value::Reference(Reference::qualified("User", "user1"))),
272        );
273
274        doc.root = root;
275
276        let yaml = hedl_to_yaml(&doc).unwrap();
277        let restored = yaml_to_hedl(&yaml).unwrap();
278
279        let local_ref = restored.root.get("local_ref").unwrap().as_scalar().unwrap();
280        if let Value::Reference(r) = local_ref {
281            assert_eq!(r.type_name, None);
282            assert_eq!(r.id.as_ref(), "item1");
283        } else {
284            panic!("Expected reference");
285        }
286
287        let qualified_ref = restored
288            .root
289            .get("qualified_ref")
290            .unwrap()
291            .as_scalar()
292            .unwrap();
293        if let Value::Reference(r) = qualified_ref {
294            assert_eq!(r.type_name.as_deref(), Some("User"));
295            assert_eq!(r.id.as_ref(), "user1");
296        } else {
297            panic!("Expected qualified reference");
298        }
299    }
300
301    #[test]
302    fn test_round_trip_expression() {
303        use hedl_core::lex::{ExprLiteral, Expression, Span};
304
305        let mut doc = Document {
306            version: (1, 0),
307            root: BTreeMap::new(),
308            structs: BTreeMap::new(),
309            nests: BTreeMap::new(),
310            schema_versions: BTreeMap::new(),
311            aliases: BTreeMap::new(),
312        };
313        let mut root = BTreeMap::new();
314
315        // Create expression: add(x, 1)
316        let expr = Expression::Call {
317            name: "add".to_string(),
318            args: vec![
319                Expression::Identifier {
320                    name: "x".to_string(),
321                    span: Span::synthetic(),
322                },
323                Expression::Literal {
324                    value: ExprLiteral::Int(1),
325                    span: Span::synthetic(),
326                },
327            ],
328            span: Span::synthetic(),
329        };
330
331        root.insert(
332            "expr".to_string(),
333            Item::Scalar(Value::Expression(Box::new(expr))),
334        );
335        doc.root = root;
336
337        let yaml = hedl_to_yaml(&doc).unwrap();
338        let restored = yaml_to_hedl(&yaml).unwrap();
339
340        let restored_expr = restored.root.get("expr").unwrap().as_scalar().unwrap();
341        if let Value::Expression(e) = restored_expr {
342            assert_eq!(e.to_string(), "add(x, 1)");
343        } else {
344            panic!("Expected expression, got {restored_expr:?}");
345        }
346    }
347
348    #[test]
349    fn test_round_trip_tensor() {
350        let mut doc = Document {
351            version: (1, 0),
352            aliases: BTreeMap::new(),
353            root: BTreeMap::new(),
354            structs: BTreeMap::new(),
355            nests: BTreeMap::new(),
356            schema_versions: BTreeMap::new(),
357        };
358        let mut root = BTreeMap::new();
359
360        let tensor = Tensor::Array(vec![
361            Tensor::Scalar(1.0),
362            Tensor::Scalar(2.0),
363            Tensor::Scalar(3.0),
364        ]);
365        root.insert(
366            "tensor".to_string(),
367            Item::Scalar(Value::Tensor(Box::new(tensor))),
368        );
369
370        doc.root = root;
371
372        let yaml = hedl_to_yaml(&doc).unwrap();
373        let restored = yaml_to_hedl(&yaml).unwrap();
374
375        let restored_tensor = restored.root.get("tensor").unwrap().as_scalar().unwrap();
376        if let Value::Tensor(t) = restored_tensor {
377            if let Tensor::Array(ref items) = **t {
378                assert_eq!(items.len(), 3);
379            } else {
380                panic!("Expected tensor array");
381            }
382        } else {
383            panic!("Expected tensor");
384        }
385    }
386
387    #[test]
388    fn test_round_trip_nested_tensor() {
389        let mut doc = Document {
390            version: (1, 0),
391            aliases: BTreeMap::new(),
392            root: BTreeMap::new(),
393            structs: BTreeMap::new(),
394            nests: BTreeMap::new(),
395            schema_versions: BTreeMap::new(),
396        };
397        let mut root = BTreeMap::new();
398
399        let tensor = Tensor::Array(vec![
400            Tensor::Array(vec![Tensor::Scalar(1.0), Tensor::Scalar(2.0)]),
401            Tensor::Array(vec![Tensor::Scalar(3.0), Tensor::Scalar(4.0)]),
402        ]);
403        root.insert(
404            "matrix".to_string(),
405            Item::Scalar(Value::Tensor(Box::new(tensor))),
406        );
407
408        doc.root = root;
409
410        let yaml = hedl_to_yaml(&doc).unwrap();
411        let restored = yaml_to_hedl(&yaml).unwrap();
412
413        let restored_tensor = restored.root.get("matrix").unwrap().as_scalar().unwrap();
414        if let Value::Tensor(tensor_box) = restored_tensor {
415            let rows = match tensor_box.as_ref() {
416                Tensor::Array(r) => r,
417                _ => panic!("Expected tensor array"),
418            };
419            assert_eq!(rows.len(), 2);
420            if let Tensor::Array(cols) = &rows[0] {
421                assert_eq!(cols.len(), 2);
422            } else {
423                panic!("Expected nested array");
424            }
425        } else {
426            panic!("Expected nested tensor");
427        }
428    }
429
430    #[test]
431    fn test_round_trip_object() {
432        let mut doc = Document {
433            version: (1, 0),
434            aliases: BTreeMap::new(),
435            root: BTreeMap::new(),
436            structs: BTreeMap::new(),
437            nests: BTreeMap::new(),
438            schema_versions: BTreeMap::new(),
439        };
440        let mut root = BTreeMap::new();
441
442        let mut obj = BTreeMap::new();
443        obj.insert(
444            "name".to_string(),
445            Item::Scalar(Value::String("test".to_string().into())),
446        );
447        obj.insert("age".to_string(), Item::Scalar(Value::Int(30)));
448        root.insert("person".to_string(), Item::Object(obj));
449
450        doc.root = root;
451
452        let yaml = hedl_to_yaml(&doc).unwrap();
453        let restored = yaml_to_hedl(&yaml).unwrap();
454
455        let person_obj = restored.root.get("person").unwrap().as_object().unwrap();
456        assert_eq!(person_obj.len(), 2);
457        assert_eq!(
458            person_obj.get("name").unwrap().as_scalar().unwrap(),
459            &Value::String("test".to_string().into())
460        );
461        assert_eq!(
462            person_obj.get("age").unwrap().as_scalar().unwrap(),
463            &Value::Int(30)
464        );
465    }
466
467    #[test]
468    fn test_round_trip_matrix_list() {
469        let mut doc = Document {
470            version: (1, 0),
471            aliases: BTreeMap::new(),
472            root: BTreeMap::new(),
473            structs: BTreeMap::new(),
474            nests: BTreeMap::new(),
475            schema_versions: BTreeMap::new(),
476        };
477        let mut root = BTreeMap::new();
478
479        let mut list = MatrixList::new(
480            "User",
481            vec!["id".to_string(), "name".to_string(), "age".to_string()],
482        );
483
484        // Per SPEC: fields must include ALL schema columns including ID
485        let node1 = Node::new(
486            "User",
487            "user1",
488            vec![
489                Value::String("user1".to_string().into()),
490                Value::String("Alice".to_string().into()),
491                Value::Int(30),
492            ],
493        );
494        let node2 = Node::new(
495            "User",
496            "user2",
497            vec![
498                Value::String("user2".to_string().into()),
499                Value::String("Bob".to_string().into()),
500                Value::Int(25),
501            ],
502        );
503
504        list.add_row(node1);
505        list.add_row(node2);
506
507        root.insert("users".to_string(), Item::List(list));
508        doc.root = root;
509
510        let yaml = hedl_to_yaml(&doc).unwrap();
511        let restored = yaml_to_hedl(&yaml).unwrap();
512
513        let users_list = restored.root.get("users").unwrap().as_list().unwrap();
514        assert_eq!(users_list.rows.len(), 2);
515        assert_eq!(users_list.schema.len(), 3);
516        // Schema order is preserved via __schema__ metadata during round-trip
517        assert_eq!(
518            users_list.schema,
519            vec!["id".to_string(), "name".to_string(), "age".to_string()]
520        );
521
522        let first_row = &users_list.rows[0];
523        assert_eq!(first_row.id, "user1");
524        // Per SPEC: fields include ALL schema columns including ID
525        assert_eq!(first_row.fields.len(), 3);
526        assert_eq!(
527            first_row.fields[0],
528            Value::String("user1".to_string().into())
529        ); // id
530        assert_eq!(
531            first_row.fields[1],
532            Value::String("Alice".to_string().into())
533        ); // name (original order preserved)
534        assert_eq!(first_row.fields[2], Value::Int(30)); // age
535    }
536
537    #[test]
538    fn test_empty_document() {
539        let doc = Document {
540            version: (1, 0),
541            aliases: BTreeMap::new(),
542            root: BTreeMap::new(),
543            structs: BTreeMap::new(),
544            nests: BTreeMap::new(),
545            schema_versions: BTreeMap::new(),
546        };
547        let yaml = hedl_to_yaml(&doc).unwrap();
548        let restored = yaml_to_hedl(&yaml).unwrap();
549        // yaml_to_hedl creates new documents with v2.0 default
550        assert_eq!(restored.version, (2, 0));
551        assert_eq!(restored.root.len(), 0);
552    }
553
554    #[test]
555    fn test_nested_objects() {
556        let mut doc = Document {
557            version: (1, 0),
558            aliases: BTreeMap::new(),
559            root: BTreeMap::new(),
560            structs: BTreeMap::new(),
561            nests: BTreeMap::new(),
562            schema_versions: BTreeMap::new(),
563        };
564        let mut root = BTreeMap::new();
565
566        let mut inner = BTreeMap::new();
567        inner.insert("x".to_string(), Item::Scalar(Value::Int(10)));
568        inner.insert("y".to_string(), Item::Scalar(Value::Int(20)));
569
570        let mut outer = BTreeMap::new();
571        outer.insert("point".to_string(), Item::Object(inner));
572        outer.insert(
573            "label".to_string(),
574            Item::Scalar(Value::String("origin".to_string().into())),
575        );
576
577        root.insert("config".to_string(), Item::Object(outer));
578        doc.root = root;
579
580        let yaml = hedl_to_yaml(&doc).unwrap();
581        let restored = yaml_to_hedl(&yaml).unwrap();
582
583        let config_obj = restored.root.get("config").unwrap().as_object().unwrap();
584        let point_obj = config_obj.get("point").unwrap().as_object().unwrap();
585        assert_eq!(
586            point_obj.get("x").unwrap().as_scalar().unwrap(),
587            &Value::Int(10)
588        );
589        assert_eq!(
590            point_obj.get("y").unwrap().as_scalar().unwrap(),
591            &Value::Int(20)
592        );
593    }
594
595    #[test]
596    fn test_yaml_parsing_error() {
597        let invalid_yaml = "{ invalid yaml: [";
598        let result = yaml_to_hedl(invalid_yaml);
599        assert!(result.is_err());
600        assert!(result.unwrap_err().contains("YAML parse error"));
601    }
602
603    #[test]
604    fn test_yaml_non_mapping_root() {
605        let yaml = "- item1\n- item2\n";
606        let result = yaml_to_hedl(yaml);
607        assert!(result.is_err());
608        assert!(result.unwrap_err().contains("Root must be a YAML mapping"));
609    }
610
611    #[test]
612    fn test_yaml_with_anchors_and_aliases() {
613        // YAML anchors and aliases are automatically resolved by serde_yaml
614        let yaml = r"
615defaults: &defaults
616  timeout: 30
617  retries: 3
618
619production:
620  config: *defaults
621  host: prod.example.com
622";
623        let doc = yaml_to_hedl(yaml).unwrap();
624
625        // Verify that the anchor reference was resolved
626        let prod = doc.root.get("production").unwrap().as_object().unwrap();
627        let config = prod.get("config").unwrap().as_object().unwrap();
628        assert_eq!(
629            config.get("timeout").unwrap().as_scalar().unwrap(),
630            &Value::Int(30)
631        );
632        assert_eq!(
633            config.get("retries").unwrap().as_scalar().unwrap(),
634            &Value::Int(3)
635        );
636        assert_eq!(
637            prod.get("host").unwrap().as_scalar().unwrap(),
638            &Value::String("prod.example.com".to_string().into())
639        );
640    }
641}