Skip to main content

eure_schema/
validate.rs

1//! Document schema validation
2//!
3//! # Architecture
4//!
5//! Validation is built on `DocumentParser` composition:
6//! - `SchemaValidator`: Dispatches to type-specific validators based on `SchemaNodeContent`
7//! - Type validators: Implement `DocumentParser<Output = (), Error = ValidatorError>`
8//! - `ValidationContext`: Manages shared state (errors, warnings, path)
9//!
10//! # Error Handling
11//!
12//! Two categories of errors:
13//! - `ValidationError`: Type mismatches accumulated in `ValidationContext` (non-fatal)
14//! - `ValidatorError`: Internal validator errors causing fail-fast (e.g., undefined references)
15//!
16//! # Hole Values
17//!
18//! The hole value (`!`) represents an unfilled placeholder:
19//! - Type checking: Holes match any schema (always pass)
20//! - Completeness: Documents containing holes are valid but not complete
21
22mod compound;
23mod context;
24mod error;
25mod key;
26mod primitive;
27mod record;
28mod reference;
29mod trace;
30mod union;
31
32pub use context::{ValidationContext, ValidationOutput, ValidationState};
33pub use error::{ValidationError, ValidationWarning, ValidatorError};
34pub use trace::resolve_node_type_traces;
35
36use eure_document::document::node::NodeValue;
37use eure_document::document::{EureDocument, NodeId};
38use eure_document::parse::{DocumentParser, ParseContext};
39
40use crate::type_path_trace::{NodeTypeTraceMap, SchemaNodePathMap};
41use crate::{SchemaDocument, SchemaNodeContent, SchemaNodeId, identifiers};
42
43use compound::{ArrayValidator, MapValidator, TupleValidator};
44use primitive::{
45    AnyValidator, BooleanValidator, FloatValidator, IntegerValidator, LiteralValidator,
46    NullValidator, TextValidator,
47};
48use record::RecordValidator;
49use reference::ReferenceValidator;
50use union::UnionValidator;
51
52// =============================================================================
53// Public API
54// =============================================================================
55
56/// Validate a document against a schema.
57///
58/// # Example
59///
60/// ```ignore
61/// let output = validate(&document, &schema);
62/// if output.is_valid {
63///     println!("Document is valid!");
64/// } else {
65///     for error in &output.errors {
66///         println!("Error: {}", error);
67///     }
68/// }
69/// ```
70pub fn validate(document: &EureDocument, schema: &SchemaDocument) -> ValidationOutput {
71    let root_id = document.get_root_id();
72    validate_node(document, schema, root_id, schema.root)
73}
74
75/// Validation output with node-level schema trace mapping.
76#[derive(Debug, Clone, Default)]
77pub struct ValidationTraceOutput {
78    pub output: ValidationOutput,
79    pub node_type_traces: NodeTypeTraceMap,
80}
81
82/// Validate with node-level schema trace mapping.
83///
84/// `schema_node_paths` maps schema node IDs to their concrete paths in the source schema document.
85pub fn validate_with_trace(
86    document: &EureDocument,
87    schema: &SchemaDocument,
88    schema_node_paths: &SchemaNodePathMap,
89) -> ValidationTraceOutput {
90    let output = validate(document, schema);
91    let node_type_traces = resolve_node_type_traces(document, schema, schema_node_paths);
92    ValidationTraceOutput {
93        output,
94        node_type_traces,
95    }
96}
97
98/// Validate a specific node against a schema node.
99pub fn validate_node(
100    document: &EureDocument,
101    schema: &SchemaDocument,
102    node_id: NodeId,
103    schema_id: SchemaNodeId,
104) -> ValidationOutput {
105    let ctx = ValidationContext::new(document, schema);
106    let parse_ctx = ctx.parse_context(node_id);
107
108    let validator = SchemaValidator {
109        ctx: &ctx,
110        schema_node_id: schema_id,
111    };
112
113    // Errors are accumulated in ctx, result is always Ok unless internal error
114    let _ = parse_ctx.parse_with(validator);
115
116    ctx.finish()
117}
118
119// =============================================================================
120// SchemaValidator (main dispatcher)
121// =============================================================================
122
123/// Main validator that dispatches to type-specific validators.
124///
125/// Implements `DocumentParser` to enable composition with other parsers.
126pub struct SchemaValidator<'a, 'doc> {
127    pub ctx: &'a ValidationContext<'doc>,
128    pub schema_node_id: SchemaNodeId,
129}
130
131impl<'a, 'doc> DocumentParser<'doc> for SchemaValidator<'a, 'doc> {
132    type Output = ();
133    type Error = ValidatorError;
134
135    fn parse(&mut self, parse_ctx: &ParseContext<'doc>) -> Result<(), ValidatorError> {
136        let node = parse_ctx.node();
137
138        if node.get_extension(&identifiers::TYPE).is_some() {
139            // Inline schema validation are performed on other path.
140            return Ok(());
141        }
142
143        // Check for hole - holes match any schema
144        if matches!(&node.content, NodeValue::Hole(_)) {
145            self.ctx.mark_has_holes();
146            return Ok(());
147        }
148
149        let schema_node = self.ctx.schema.node(self.schema_node_id);
150
151        // Validate extensions first so later unknown-extension checks see accessed state.
152        self.validate_extensions(parse_ctx)?;
153
154        // Dispatch to type-specific validator
155        match &schema_node.content {
156            SchemaNodeContent::Any => {
157                self.warn_unknown_extensions(parse_ctx);
158                let mut v = AnyValidator;
159                v.parse(parse_ctx)
160            }
161            SchemaNodeContent::Text(s) => {
162                self.warn_unknown_extensions(parse_ctx);
163                let mut v = TextValidator {
164                    ctx: self.ctx,
165                    schema: s,
166                    schema_node_id: self.schema_node_id,
167                };
168                v.parse(parse_ctx)
169            }
170            SchemaNodeContent::Integer(s) => {
171                self.warn_unknown_extensions(parse_ctx);
172                let mut v = IntegerValidator {
173                    ctx: self.ctx,
174                    schema: s,
175                    schema_node_id: self.schema_node_id,
176                };
177                v.parse(parse_ctx)
178            }
179            SchemaNodeContent::Float(s) => {
180                self.warn_unknown_extensions(parse_ctx);
181                let mut v = FloatValidator {
182                    ctx: self.ctx,
183                    schema: s,
184                    schema_node_id: self.schema_node_id,
185                };
186                v.parse(parse_ctx)
187            }
188            SchemaNodeContent::Boolean => {
189                self.warn_unknown_extensions(parse_ctx);
190                let mut v = BooleanValidator {
191                    ctx: self.ctx,
192                    schema_node_id: self.schema_node_id,
193                };
194                v.parse(parse_ctx)
195            }
196            SchemaNodeContent::Null => {
197                self.warn_unknown_extensions(parse_ctx);
198                let mut v = NullValidator {
199                    ctx: self.ctx,
200                    schema_node_id: self.schema_node_id,
201                };
202                v.parse(parse_ctx)
203            }
204            SchemaNodeContent::Literal(expected) => {
205                self.warn_unknown_extensions(parse_ctx);
206                let mut v = LiteralValidator {
207                    ctx: self.ctx,
208                    expected,
209                    schema_node_id: self.schema_node_id,
210                };
211                v.parse(parse_ctx)
212            }
213            SchemaNodeContent::Array(s) => {
214                self.warn_unknown_extensions(parse_ctx);
215                let mut v = ArrayValidator {
216                    ctx: self.ctx,
217                    schema: s,
218                    schema_node_id: self.schema_node_id,
219                };
220                v.parse(parse_ctx)
221            }
222            SchemaNodeContent::Map(s) => {
223                self.warn_unknown_extensions(parse_ctx);
224                let mut v = MapValidator {
225                    ctx: self.ctx,
226                    schema: s,
227                    schema_node_id: self.schema_node_id,
228                };
229                v.parse(parse_ctx)
230            }
231            SchemaNodeContent::Record(s) => {
232                self.warn_unknown_extensions(parse_ctx);
233                let mut v = RecordValidator {
234                    ctx: self.ctx,
235                    schema: s,
236                    schema_node_id: self.schema_node_id,
237                };
238                v.parse(parse_ctx)
239            }
240            SchemaNodeContent::Tuple(s) => {
241                self.warn_unknown_extensions(parse_ctx);
242                let mut v = TupleValidator {
243                    ctx: self.ctx,
244                    schema: s,
245                    schema_node_id: self.schema_node_id,
246                };
247                v.parse(parse_ctx)
248            }
249            SchemaNodeContent::Union(s) => {
250                self.warn_unknown_extensions(parse_ctx);
251                let mut v = UnionValidator {
252                    ctx: self.ctx,
253                    schema: s,
254                    schema_node_id: self.schema_node_id,
255                };
256                v.parse(parse_ctx)
257            }
258            SchemaNodeContent::Reference(r) => {
259                // Reference: recurse with the same parse context so accessed state stays local
260                // to this node while following the resolved schema.
261                let mut child_validator = ReferenceValidator {
262                    ctx: self.ctx,
263                    type_ref: r,
264                    schema_node_id: self.schema_node_id,
265                };
266                child_validator.parse(parse_ctx)
267            }
268        }
269    }
270}
271
272impl<'a, 'doc> SchemaValidator<'a, 'doc> {
273    /// Validate extensions on the current node.
274    ///
275    /// This validates required and present extensions. Accesses are tracked
276    /// in the parse context's AccessedSet.
277    fn validate_extensions(&self, parse_ctx: &ParseContext<'doc>) -> Result<(), ValidatorError> {
278        let schema_node = self.ctx.schema.node(self.schema_node_id);
279        let ext_types = &schema_node.ext_types;
280        let node = parse_ctx.node();
281        let node_id = parse_ctx.node_id();
282
283        // Check for missing required extensions
284        for (ext_ident, ext_schema) in ext_types {
285            if !ext_schema.optional && !node.extensions.contains_key(ext_ident) {
286                self.ctx
287                    .record_error(ValidationError::MissingRequiredExtension {
288                        extension: ext_ident.to_string(),
289                        path: self.ctx.path(),
290                        node_id,
291                        schema_node_id: self.schema_node_id,
292                    });
293            }
294        }
295
296        // Validate present extensions - `ext_optional()` marks them as accessed on this context.
297        for (ext_ident, ext_schema) in ext_types {
298            if let Some(ext_ctx) = parse_ctx.ext_optional(ext_ident.as_ref()) {
299                self.ctx.push_path_extension(ext_ident.clone());
300
301                let child_validator = SchemaValidator {
302                    ctx: self.ctx,
303                    schema_node_id: ext_schema.schema,
304                };
305                let _ = ext_ctx.parse_with(child_validator);
306
307                self.ctx.pop_path();
308            }
309        }
310
311        Ok(())
312    }
313
314    /// Warn about unknown extensions at terminal types.
315    ///
316    /// Extensions that are:
317    /// - Not accessed (not in schema's ext_types)
318    /// - Not built-in ($variant, $schema, $ext-type, etc.)
319    ///
320    /// Uses the parse context's AccessedSet to determine
321    /// which extensions have been accessed.
322    fn warn_unknown_extensions(&self, parse_ctx: &ParseContext<'doc>) {
323        for (ext_ident, _) in parse_ctx.unknown_extensions() {
324            // Skip built-in extensions used by the schema system
325            if Self::is_builtin_extension(ext_ident) {
326                continue;
327            }
328            self.ctx
329                .record_warning(ValidationWarning::UnknownExtension {
330                    name: ext_ident.to_string(),
331                    path: self.ctx.path(),
332                });
333        }
334    }
335
336    /// Check if an extension is a built-in schema system extension.
337    ///
338    /// Built-in extensions are always allowed and not warned about:
339    /// - $variant: used by union types
340    /// - $schema: used to specify the schema for a document
341    /// - $ext-type: used to define extension types in schemas
342    /// - $codegen: used for code generation hints
343    /// - $codegen-defaults: used for default codegen settings
344    /// - $flatten: used for record field flattening
345    fn is_builtin_extension(ident: &eure_document::identifier::Identifier) -> bool {
346        // Core schema extensions
347        ident == &identifiers::VARIANT
348            || ident == &identifiers::SCHEMA
349            || ident == &identifiers::EXT_TYPE
350            || ident == &identifiers::TYPE
351            // Codegen extensions
352            || ident.as_ref() == "codegen"
353            || ident.as_ref() == "codegen-defaults"
354            // FIXME: This seems not builtin so must be properly handled.
355            || ident.as_ref() == "flatten"
356    }
357}
358
359// =============================================================================
360// Tests
361// =============================================================================
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::convert::document_to_schema_with_layout;
367    use crate::type_path_trace::{ResolvedTypeTrace, TypeTraceUnresolvedReason};
368    use crate::{
369        ArraySchema, Bound, CodegenDefaults, FieldCodegen, IntegerSchema, MapSchema,
370        RecordFieldSchema, RecordSchema, RootCodegen, TextSchema, TypeReference, UnionSchema,
371        UnknownFieldsPolicy,
372    };
373    use eure_document::identifier::Identifier;
374    use eure_document::text::Text;
375    use eure_document::value::{ObjectKey, PrimitiveValue};
376    use indexmap::{IndexMap, IndexSet};
377    use num_bigint::BigInt;
378
379    fn create_simple_schema(content: SchemaNodeContent) -> (SchemaDocument, SchemaNodeId) {
380        let mut schema = SchemaDocument {
381            nodes: Vec::new(),
382            root: SchemaNodeId(0),
383            types: IndexMap::new(),
384            root_codegen: RootCodegen::default(),
385            codegen_defaults: CodegenDefaults::default(),
386        };
387        let id = schema.create_node(content);
388        schema.root = id;
389        (schema, id)
390    }
391
392    fn create_doc_with_primitive(value: PrimitiveValue) -> EureDocument {
393        let mut doc = EureDocument::new();
394        let root_id = doc.get_root_id();
395        doc.node_mut(root_id).content = NodeValue::Primitive(value);
396        doc
397    }
398
399    #[test]
400    fn test_validate_text_basic() {
401        let (schema, _) = create_simple_schema(SchemaNodeContent::Text(TextSchema::default()));
402        let doc =
403            create_doc_with_primitive(PrimitiveValue::Text(Text::plaintext("hello".to_string())));
404        let result = validate(&doc, &schema);
405        assert!(result.is_valid);
406    }
407
408    #[test]
409    fn test_validate_text_pattern() {
410        let (schema, _) = create_simple_schema(SchemaNodeContent::Text(TextSchema {
411            pattern: Some(regex::Regex::new("^[a-z]+$").unwrap()),
412            ..Default::default()
413        }));
414
415        let doc =
416            create_doc_with_primitive(PrimitiveValue::Text(Text::plaintext("hello".to_string())));
417        let result = validate(&doc, &schema);
418        assert!(result.is_valid);
419
420        let doc = create_doc_with_primitive(PrimitiveValue::Text(Text::plaintext(
421            "Hello123".to_string(),
422        )));
423        let result = validate(&doc, &schema);
424        assert!(!result.is_valid);
425    }
426
427    #[test]
428    fn test_validate_integer() {
429        let (schema, _) = create_simple_schema(SchemaNodeContent::Integer(IntegerSchema {
430            min: Bound::Inclusive(BigInt::from(0)),
431            max: Bound::Inclusive(BigInt::from(100)),
432            multiple_of: None,
433        }));
434
435        let doc = create_doc_with_primitive(PrimitiveValue::Integer(BigInt::from(50)));
436        let result = validate(&doc, &schema);
437        assert!(result.is_valid);
438
439        let doc = create_doc_with_primitive(PrimitiveValue::Integer(BigInt::from(150)));
440        let result = validate(&doc, &schema);
441        assert!(!result.is_valid);
442    }
443
444    #[test]
445    fn test_validate_boolean() {
446        let (schema, _) = create_simple_schema(SchemaNodeContent::Boolean);
447
448        let doc = create_doc_with_primitive(PrimitiveValue::Bool(true));
449        let result = validate(&doc, &schema);
450        assert!(result.is_valid);
451
452        let doc = create_doc_with_primitive(PrimitiveValue::Integer(BigInt::from(1)));
453        let result = validate(&doc, &schema);
454        assert!(!result.is_valid);
455    }
456
457    #[test]
458    fn test_validate_array() {
459        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
460        let item_schema_id =
461            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
462        schema.node_mut(schema.root).content = SchemaNodeContent::Array(ArraySchema {
463            item: item_schema_id,
464            min_length: Some(1),
465            max_length: Some(3),
466            unique: false,
467            contains: None,
468            binding_style: None,
469        });
470
471        let mut doc = EureDocument::new();
472        let root_id = doc.get_root_id();
473        doc.node_mut(root_id).content = NodeValue::Array(Default::default());
474        let child1 = doc.add_array_element(None, root_id).unwrap().node_id;
475        doc.node_mut(child1).content =
476            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(1)));
477        let child2 = doc.add_array_element(None, root_id).unwrap().node_id;
478        doc.node_mut(child2).content =
479            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(2)));
480
481        let result = validate(&doc, &schema);
482        assert!(result.is_valid);
483    }
484
485    #[test]
486    fn test_validate_map_with_union_key_schema() {
487        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
488        let text_key_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
489        let int_key_schema_id =
490            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
491        let any_value_schema_id = schema.create_node(SchemaNodeContent::Any);
492
493        let mut variants = IndexMap::new();
494        variants.insert("text".to_string(), text_key_schema_id);
495        variants.insert("integer".to_string(), int_key_schema_id);
496        let union_key_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
497            variants,
498            unambiguous: IndexSet::new(),
499            interop: crate::interop::UnionInterop::default(),
500            deny_untagged: IndexSet::new(),
501        }));
502
503        schema.node_mut(schema.root).content = SchemaNodeContent::Map(MapSchema {
504            key: union_key_schema_id,
505            value: any_value_schema_id,
506            min_size: None,
507            max_size: None,
508        });
509
510        let mut doc = EureDocument::new();
511        let root_id = doc.get_root_id();
512
513        let text_value_id = doc
514            .add_map_child(ObjectKey::String("name".to_string()), root_id)
515            .unwrap()
516            .node_id;
517        doc.node_mut(text_value_id).content =
518            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
519
520        let int_value_id = doc
521            .add_map_child(ObjectKey::Number(BigInt::from(1)), root_id)
522            .unwrap()
523            .node_id;
524        doc.node_mut(int_value_id).content =
525            NodeValue::Primitive(PrimitiveValue::Integer(42.into()));
526
527        let result = validate(&doc, &schema);
528        assert!(
529            result.is_valid,
530            "Expected union key schema to validate: {:?}",
531            result.errors
532        );
533    }
534
535    #[test]
536    fn test_validate_map_with_reference_to_union_key_schema() {
537        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
538        let text_key_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
539        let int_key_schema_id =
540            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
541        let any_value_schema_id = schema.create_node(SchemaNodeContent::Any);
542
543        let mut variants = IndexMap::new();
544        variants.insert("text".to_string(), text_key_schema_id);
545        variants.insert("integer".to_string(), int_key_schema_id);
546        let union_key_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
547            variants,
548            unambiguous: IndexSet::new(),
549            interop: crate::interop::UnionInterop::default(),
550            deny_untagged: IndexSet::new(),
551        }));
552        schema.register_type(Identifier::new_unchecked("key"), union_key_schema_id);
553
554        let key_ref_schema_id = schema.create_node(SchemaNodeContent::Reference(TypeReference {
555            namespace: None,
556            name: Identifier::new_unchecked("key"),
557        }));
558
559        schema.node_mut(schema.root).content = SchemaNodeContent::Map(MapSchema {
560            key: key_ref_schema_id,
561            value: any_value_schema_id,
562            min_size: None,
563            max_size: None,
564        });
565
566        let mut doc = EureDocument::new();
567        let root_id = doc.get_root_id();
568        let value_id = doc
569            .add_map_child(ObjectKey::Number(BigInt::from(7)), root_id)
570            .unwrap()
571            .node_id;
572        doc.node_mut(value_id).content = NodeValue::Primitive(PrimitiveValue::Bool(true));
573
574        let result = validate(&doc, &schema);
575        assert!(
576            result.is_valid,
577            "Expected reference to union key schema to validate: {:?}",
578            result.errors
579        );
580    }
581
582    #[test]
583    fn test_validate_record_flattened_map_boolean_key() {
584        // Repro: validate_flattened_map_key has no Boolean arm, so a boolean
585        // key schema ("true"/"false" are ObjectKey::String per ADR-0006) falls
586        // through to InvalidKeyType. This test should fail until the bug is fixed.
587        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
588        let bool_key_schema_id = schema.create_node(SchemaNodeContent::Boolean);
589        let any_value_schema_id = schema.create_node(SchemaNodeContent::Any);
590        let map_schema_id = schema.create_node(SchemaNodeContent::Map(MapSchema {
591            key: bool_key_schema_id,
592            value: any_value_schema_id,
593            min_size: None,
594            max_size: None,
595        }));
596        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
597            properties: IndexMap::new(),
598            flatten: vec![map_schema_id],
599            unknown_fields: UnknownFieldsPolicy::Deny,
600        });
601
602        let mut doc = EureDocument::new();
603        let root_id = doc.get_root_id();
604        let value_id = doc
605            .add_map_child(ObjectKey::String("true".to_string()), root_id)
606            .unwrap()
607            .node_id;
608        doc.node_mut(value_id).content = NodeValue::Primitive(PrimitiveValue::Bool(true));
609
610        let result = validate(&doc, &schema);
611        assert!(
612            result.is_valid,
613            "Expected boolean key 'true' to be valid against Boolean key schema in flattened map: {:?}",
614            result.errors
615        );
616    }
617
618    #[test]
619    fn test_validate_record() {
620        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
621        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
622        let age_schema_id =
623            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
624
625        let mut properties = IndexMap::new();
626        properties.insert(
627            "name".to_string(),
628            RecordFieldSchema {
629                schema: name_schema_id,
630                optional: false,
631                binding_style: None,
632                field_codegen: FieldCodegen::default(),
633            },
634        );
635        properties.insert(
636            "age".to_string(),
637            RecordFieldSchema {
638                schema: age_schema_id,
639                optional: true,
640                binding_style: None,
641                field_codegen: FieldCodegen::default(),
642            },
643        );
644
645        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
646            properties,
647            flatten: vec![],
648            unknown_fields: UnknownFieldsPolicy::Deny,
649        });
650
651        let mut doc = EureDocument::new();
652        let root_id = doc.get_root_id();
653        let name_id = doc
654            .add_map_child(ObjectKey::String("name".to_string()), root_id)
655            .unwrap()
656            .node_id;
657        doc.node_mut(name_id).content =
658            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
659
660        let result = validate(&doc, &schema);
661        assert!(result.is_valid);
662    }
663
664    #[test]
665    fn test_validate_record_with_sibling_flatten_targets() {
666        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
667        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
668        let age_schema_id =
669            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
670
671        let mut left_properties = IndexMap::new();
672        left_properties.insert(
673            "name".to_string(),
674            RecordFieldSchema {
675                schema: name_schema_id,
676                optional: false,
677                binding_style: None,
678                field_codegen: FieldCodegen::default(),
679            },
680        );
681        let left_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
682            properties: left_properties,
683            flatten: vec![],
684            unknown_fields: UnknownFieldsPolicy::Deny,
685        }));
686
687        let mut right_properties = IndexMap::new();
688        right_properties.insert(
689            "age".to_string(),
690            RecordFieldSchema {
691                schema: age_schema_id,
692                optional: false,
693                binding_style: None,
694                field_codegen: FieldCodegen::default(),
695            },
696        );
697        let right_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
698            properties: right_properties,
699            flatten: vec![],
700            unknown_fields: UnknownFieldsPolicy::Deny,
701        }));
702
703        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
704            properties: IndexMap::new(),
705            flatten: vec![left_schema_id, right_schema_id],
706            unknown_fields: UnknownFieldsPolicy::Deny,
707        });
708
709        let mut doc = EureDocument::new();
710        let root_id = doc.get_root_id();
711        let name_id = doc
712            .add_map_child(ObjectKey::String("name".to_string()), root_id)
713            .unwrap()
714            .node_id;
715        doc.node_mut(name_id).content =
716            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
717        let age_id = doc
718            .add_map_child(ObjectKey::String("age".to_string()), root_id)
719            .unwrap()
720            .node_id;
721        doc.node_mut(age_id).content =
722            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(42)));
723
724        let result = validate(&doc, &schema);
725        assert!(
726            result.is_valid,
727            "Expected sibling flatten targets to validate, got errors: {:?}",
728            result.errors
729        );
730    }
731
732    #[test]
733    fn test_validate_record_with_flattened_union_and_sibling_flatten_targets() {
734        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
735        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
736        let nickname_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
737        let age_schema_id =
738            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
739
740        let mut person_properties = IndexMap::new();
741        person_properties.insert(
742            "name".to_string(),
743            RecordFieldSchema {
744                schema: name_schema_id,
745                optional: false,
746                binding_style: None,
747                field_codegen: FieldCodegen::default(),
748            },
749        );
750        let person_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
751            properties: person_properties,
752            flatten: vec![],
753            unknown_fields: UnknownFieldsPolicy::Deny,
754        }));
755
756        let mut alias_properties = IndexMap::new();
757        alias_properties.insert(
758            "nickname".to_string(),
759            RecordFieldSchema {
760                schema: nickname_schema_id,
761                optional: false,
762                binding_style: None,
763                field_codegen: FieldCodegen::default(),
764            },
765        );
766        let alias_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
767            properties: alias_properties,
768            flatten: vec![],
769            unknown_fields: UnknownFieldsPolicy::Deny,
770        }));
771
772        let mut union_variants = IndexMap::new();
773        union_variants.insert("Person".to_string(), person_schema_id);
774        union_variants.insert("Alias".to_string(), alias_schema_id);
775        let union_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
776            variants: union_variants,
777            unambiguous: IndexSet::new(),
778            interop: crate::interop::UnionInterop::default(),
779            deny_untagged: IndexSet::new(),
780        }));
781
782        let mut sibling_properties = IndexMap::new();
783        sibling_properties.insert(
784            "age".to_string(),
785            RecordFieldSchema {
786                schema: age_schema_id,
787                optional: false,
788                binding_style: None,
789                field_codegen: FieldCodegen::default(),
790            },
791        );
792        let sibling_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
793            properties: sibling_properties,
794            flatten: vec![],
795            unknown_fields: UnknownFieldsPolicy::Deny,
796        }));
797
798        let mut doc = EureDocument::new();
799        let root_id = doc.get_root_id();
800        let name_id = doc
801            .add_map_child(ObjectKey::String("name".to_string()), root_id)
802            .unwrap()
803            .node_id;
804        doc.node_mut(name_id).content =
805            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
806        let age_id = doc
807            .add_map_child(ObjectKey::String("age".to_string()), root_id)
808            .unwrap()
809            .node_id;
810        doc.node_mut(age_id).content =
811            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(42)));
812
813        for flatten in [
814            vec![union_schema_id, sibling_schema_id],
815            vec![sibling_schema_id, union_schema_id],
816        ] {
817            schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
818                properties: IndexMap::new(),
819                flatten,
820                unknown_fields: UnknownFieldsPolicy::Deny,
821            });
822
823            let result = validate(&doc, &schema);
824            assert!(
825                result.is_valid,
826                "Expected flattened union + sibling flatten target to validate, got errors: {:?}",
827                result.errors
828            );
829        }
830    }
831
832    #[test]
833    fn test_flattened_union_best_match_ignores_sibling_consumed_fields() {
834        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
835        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
836        let nickname_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
837        let age_schema_id =
838            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
839
840        let mut person_properties = IndexMap::new();
841        person_properties.insert(
842            "name".to_string(),
843            RecordFieldSchema {
844                schema: name_schema_id,
845                optional: false,
846                binding_style: None,
847                field_codegen: FieldCodegen::default(),
848            },
849        );
850        let person_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
851            properties: person_properties,
852            flatten: vec![],
853            unknown_fields: UnknownFieldsPolicy::Deny,
854        }));
855
856        let mut alias_properties = IndexMap::new();
857        alias_properties.insert(
858            "nickname".to_string(),
859            RecordFieldSchema {
860                schema: nickname_schema_id,
861                optional: false,
862                binding_style: None,
863                field_codegen: FieldCodegen::default(),
864            },
865        );
866        let alias_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
867            properties: alias_properties,
868            flatten: vec![],
869            unknown_fields: UnknownFieldsPolicy::Deny,
870        }));
871
872        let mut union_variants = IndexMap::new();
873        union_variants.insert("Person".to_string(), person_schema_id);
874        union_variants.insert("Alias".to_string(), alias_schema_id);
875        let union_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
876            variants: union_variants,
877            unambiguous: IndexSet::new(),
878            interop: crate::interop::UnionInterop::default(),
879            deny_untagged: IndexSet::new(),
880        }));
881
882        let mut sibling_properties = IndexMap::new();
883        sibling_properties.insert(
884            "age".to_string(),
885            RecordFieldSchema {
886                schema: age_schema_id,
887                optional: false,
888                binding_style: None,
889                field_codegen: FieldCodegen::default(),
890            },
891        );
892        let sibling_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
893            properties: sibling_properties,
894            flatten: vec![],
895            unknown_fields: UnknownFieldsPolicy::Deny,
896        }));
897
898        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
899            properties: IndexMap::new(),
900            flatten: vec![union_schema_id, sibling_schema_id],
901            unknown_fields: UnknownFieldsPolicy::Deny,
902        });
903
904        let mut doc = EureDocument::new();
905        let root_id = doc.get_root_id();
906        let age_id = doc
907            .add_map_child(ObjectKey::String("age".to_string()), root_id)
908            .unwrap()
909            .node_id;
910        doc.node_mut(age_id).content =
911            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(42)));
912        let fax_id = doc
913            .add_map_child(ObjectKey::String("fax".to_string()), root_id)
914            .unwrap()
915            .node_id;
916        doc.node_mut(fax_id).content =
917            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("123".to_string())));
918
919        let result = validate(&doc, &schema);
920        assert!(!result.is_valid);
921
922        let no_variant_error = result
923            .errors
924            .iter()
925            .find_map(|error| match error {
926                ValidationError::NoVariantMatched {
927                    best_match: Some(best_match),
928                    ..
929                } => Some(best_match),
930                _ => None,
931            })
932            .expect("expected flattened union best match");
933
934        assert!(
935            no_variant_error.all_errors.iter().any(|error| matches!(
936                error,
937                ValidationError::UnknownField { field, .. } if field == "fax"
938            )),
939            "expected best match to retain globally unknown field"
940        );
941        assert!(
942            !no_variant_error.all_errors.iter().any(|error| matches!(
943                error,
944                ValidationError::UnknownField { field, .. } if field == "age"
945            )),
946            "best match should not treat sibling-consumed field as unknown"
947        );
948    }
949
950    #[test]
951    fn test_validate_hole() {
952        let (schema, _) =
953            create_simple_schema(SchemaNodeContent::Integer(IntegerSchema::default()));
954
955        let mut doc = EureDocument::new();
956        let root_id = doc.get_root_id();
957        doc.node_mut(root_id).content = NodeValue::Hole(None);
958
959        let result = validate(&doc, &schema);
960        assert!(result.is_valid);
961        assert!(!result.is_complete);
962    }
963
964    /// Helper to create a literal schema from an EureDocument
965    fn create_literal_schema(
966        schema: &mut SchemaDocument,
967        literal_doc: EureDocument,
968    ) -> SchemaNodeId {
969        schema.create_node(SchemaNodeContent::Literal(literal_doc))
970    }
971
972    #[test]
973    fn test_validate_union_deny_untagged_without_tag() {
974        use eure_document::eure;
975
976        // Create a union with a literal variant that has deny_untagged = true
977        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
978
979        // Create literal schema for "active"
980        let literal_schema_id = create_literal_schema(&mut schema, eure!({ = "active" }));
981
982        // Create union with literal variant that requires explicit tagging
983        let mut variants = IndexMap::new();
984        variants.insert("literal".to_string(), literal_schema_id);
985
986        let mut deny_untagged = IndexSet::new();
987        deny_untagged.insert("literal".to_string());
988
989        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
990            variants,
991            unambiguous: IndexSet::new(),
992            interop: crate::interop::UnionInterop::default(),
993            deny_untagged,
994        });
995
996        // Create document with literal value but NO $variant tag
997        let doc = eure!({ = "active" });
998
999        // Validation should fail with RequiresExplicitVariant error
1000        let result = validate(&doc, &schema);
1001        assert!(!result.is_valid);
1002        assert!(result.errors.iter().any(|e| matches!(
1003            e,
1004            ValidationError::RequiresExplicitVariant { variant, .. } if variant == "literal"
1005        )));
1006    }
1007
1008    #[test]
1009    fn test_validate_union_deny_untagged_with_tag() {
1010        use eure_document::eure;
1011
1012        // Create a union with a literal variant that has deny_untagged = true
1013        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
1014
1015        // Create literal schema for "active"
1016        let literal_schema_id = create_literal_schema(&mut schema, eure!({ = "active" }));
1017
1018        // Create union with literal variant that requires explicit tagging
1019        let mut variants = IndexMap::new();
1020        variants.insert("literal".to_string(), literal_schema_id);
1021
1022        let mut deny_untagged = IndexSet::new();
1023        deny_untagged.insert("literal".to_string());
1024
1025        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
1026            variants,
1027            unambiguous: IndexSet::new(),
1028            interop: crate::interop::UnionInterop::default(),
1029            deny_untagged,
1030        });
1031
1032        // Create document with literal value WITH $variant tag
1033        let doc = eure!({
1034            = "active"
1035            %variant = "literal"
1036        });
1037
1038        // Validation should succeed
1039        let result = validate(&doc, &schema);
1040        assert!(
1041            result.is_valid,
1042            "Expected valid, got errors: {:?}",
1043            result.errors
1044        );
1045    }
1046
1047    #[test]
1048    fn test_validate_union_mixed_deny_untagged() {
1049        use eure_document::eure;
1050
1051        // Test that non-deny-untagged variants can still match via untagged
1052        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
1053
1054        // Create literal schema for "active" (deny_untagged)
1055        let literal_active_id = create_literal_schema(&mut schema, eure!({ = "active" }));
1056
1057        // Create text schema (not deny_untagged)
1058        let text_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
1059
1060        // Create union where literal requires explicit tag but text doesn't
1061        let mut variants = IndexMap::new();
1062        variants.insert("literal".to_string(), literal_active_id);
1063        variants.insert("text".to_string(), text_schema_id);
1064
1065        let mut deny_untagged = IndexSet::new();
1066        deny_untagged.insert("literal".to_string());
1067
1068        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
1069            variants,
1070            unambiguous: IndexSet::new(),
1071            interop: crate::interop::UnionInterop::default(),
1072            deny_untagged,
1073        });
1074
1075        // Create document with value "active" but no tag
1076        // This should fail because "literal" matches but requires explicit tag
1077        let doc = eure!({ = "active" });
1078
1079        let result = validate(&doc, &schema);
1080        assert!(!result.is_valid);
1081        assert!(result.errors.iter().any(|e| matches!(
1082            e,
1083            ValidationError::RequiresExplicitVariant { variant, .. } if variant == "literal"
1084        )));
1085
1086        // Create document with value "other text" - should match text variant via untagged
1087        let doc2 = eure!({ = "other text" });
1088
1089        let result2 = validate(&doc2, &schema);
1090        assert!(
1091            result2.is_valid,
1092            "Expected valid for text match, got errors: {:?}",
1093            result2.errors
1094        );
1095    }
1096
1097    #[test]
1098    fn test_validate_union_internal_interop_does_not_count_as_explicit_tag() {
1099        use eure_document::eure;
1100
1101        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
1102
1103        let type_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
1104        let mut properties = IndexMap::new();
1105        properties.insert(
1106            "type".to_string(),
1107            RecordFieldSchema {
1108                schema: type_schema_id,
1109                optional: false,
1110                binding_style: None,
1111                field_codegen: FieldCodegen::default(),
1112            },
1113        );
1114        let success_record_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
1115            properties,
1116            flatten: vec![],
1117            unknown_fields: UnknownFieldsPolicy::Deny,
1118        }));
1119
1120        let mut variants = IndexMap::new();
1121        variants.insert("success".to_string(), success_record_id);
1122
1123        let mut deny_untagged = IndexSet::new();
1124        deny_untagged.insert("success".to_string());
1125
1126        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
1127            variants,
1128            unambiguous: IndexSet::new(),
1129            interop: crate::interop::UnionInterop {
1130                variant_repr: Some(crate::interop::VariantRepr::Internal {
1131                    tag: "type".to_string(),
1132                }),
1133            },
1134            deny_untagged,
1135        });
1136
1137        // `type = "success"` is interop metadata only; without `$variant`, this is still untagged.
1138        let doc = eure!({ type = "success" });
1139        let result = validate(&doc, &schema);
1140        assert!(!result.is_valid);
1141        assert!(result.errors.iter().any(|e| matches!(
1142            e,
1143            ValidationError::RequiresExplicitVariant { variant, .. } if variant == "success"
1144        )));
1145
1146        // Adding `$variant` makes it explicit and validation succeeds.
1147        let tagged_doc = eure!({
1148            type = "success"
1149            %variant = "success"
1150        });
1151        let tagged_result = validate(&tagged_doc, &schema);
1152        assert!(
1153            tagged_result.is_valid,
1154            "Expected valid with explicit $variant, got errors: {:?}",
1155            tagged_result.errors
1156        );
1157    }
1158
1159    #[test]
1160    fn test_validate_literal_with_inline_code() {
1161        use eure_document::eure;
1162
1163        // Test that Literal comparison works correctly with inline code (Language::Implicit)
1164        let mut schema = SchemaDocument::new();
1165
1166        // Create literal schema using inline code (like meta-schema does)
1167        let literal_doc = eure!({ = @code("boolean") });
1168
1169        schema.node_mut(schema.root).content = SchemaNodeContent::Literal(literal_doc);
1170
1171        // Create document with inline code "boolean"
1172        let doc = eure!({ = @code("boolean") });
1173
1174        // Validation should succeed
1175        let result = validate(&doc, &schema);
1176        assert!(
1177            result.is_valid,
1178            "Expected valid, got errors: {:?}",
1179            result.errors
1180        );
1181    }
1182
1183    #[test]
1184    fn test_validate_with_trace_covers_all_node_ids_and_is_deterministic() {
1185        use eure_document::eure;
1186
1187        let schema_doc = eure!({
1188            profile {
1189                name = @code("text")
1190                tags = [@code("text")]
1191            }
1192            active = @code("boolean")
1193        });
1194        let (schema, layout, _source_map) =
1195            document_to_schema_with_layout(&schema_doc).expect("schema conversion should succeed");
1196
1197        let input_doc = eure!({
1198            profile {
1199                name = "Alice"
1200                tags = ["core", "ops"]
1201            }
1202            active = true
1203        });
1204
1205        let first = validate_with_trace(&input_doc, &schema, &layout.schema_node_paths);
1206        let second = validate_with_trace(&input_doc, &schema, &layout.schema_node_paths);
1207
1208        assert_eq!(first.node_type_traces, second.node_type_traces);
1209        assert_eq!(first.node_type_traces.len(), input_doc.node_count());
1210
1211        for index in 0..input_doc.node_count() {
1212            assert!(
1213                first.node_type_traces.contains_key(&NodeId(index)),
1214                "missing trace for NodeId({index})"
1215            );
1216        }
1217
1218        assert!(
1219            first.node_type_traces.values().all(|trace| !matches!(
1220                trace,
1221                ResolvedTypeTrace::Unresolved(TypeTraceUnresolvedReason::NotVisited)
1222            )),
1223            "all reachable document nodes must be visited"
1224        );
1225    }
1226}