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            exports: IndexSet::new(),
385            imports: IndexMap::new(),
386            root_codegen: RootCodegen::default(),
387            codegen_defaults: CodegenDefaults::default(),
388        };
389        let id = schema.create_node(content);
390        schema.root = id;
391        (schema, id)
392    }
393
394    fn create_doc_with_primitive(value: PrimitiveValue) -> EureDocument {
395        let mut doc = EureDocument::new();
396        let root_id = doc.get_root_id();
397        doc.node_mut(root_id).content = NodeValue::Primitive(value);
398        doc
399    }
400
401    #[test]
402    fn test_validate_text_basic() {
403        let (schema, _) = create_simple_schema(SchemaNodeContent::Text(TextSchema::default()));
404        let doc =
405            create_doc_with_primitive(PrimitiveValue::Text(Text::plaintext("hello".to_string())));
406        let result = validate(&doc, &schema);
407        assert!(result.is_valid);
408    }
409
410    #[test]
411    fn test_validate_text_pattern() {
412        let (schema, _) = create_simple_schema(SchemaNodeContent::Text(TextSchema {
413            pattern: Some(regex::Regex::new("^[a-z]+$").unwrap()),
414            ..Default::default()
415        }));
416
417        let doc =
418            create_doc_with_primitive(PrimitiveValue::Text(Text::plaintext("hello".to_string())));
419        let result = validate(&doc, &schema);
420        assert!(result.is_valid);
421
422        let doc = create_doc_with_primitive(PrimitiveValue::Text(Text::plaintext(
423            "Hello123".to_string(),
424        )));
425        let result = validate(&doc, &schema);
426        assert!(!result.is_valid);
427    }
428
429    #[test]
430    fn test_validate_integer() {
431        let (schema, _) = create_simple_schema(SchemaNodeContent::Integer(IntegerSchema {
432            min: Bound::Inclusive(BigInt::from(0)),
433            max: Bound::Inclusive(BigInt::from(100)),
434            multiple_of: None,
435        }));
436
437        let doc = create_doc_with_primitive(PrimitiveValue::Integer(BigInt::from(50)));
438        let result = validate(&doc, &schema);
439        assert!(result.is_valid);
440
441        let doc = create_doc_with_primitive(PrimitiveValue::Integer(BigInt::from(150)));
442        let result = validate(&doc, &schema);
443        assert!(!result.is_valid);
444    }
445
446    #[test]
447    fn test_validate_boolean() {
448        let (schema, _) = create_simple_schema(SchemaNodeContent::Boolean);
449
450        let doc = create_doc_with_primitive(PrimitiveValue::Bool(true));
451        let result = validate(&doc, &schema);
452        assert!(result.is_valid);
453
454        let doc = create_doc_with_primitive(PrimitiveValue::Integer(BigInt::from(1)));
455        let result = validate(&doc, &schema);
456        assert!(!result.is_valid);
457    }
458
459    #[test]
460    fn test_validate_array() {
461        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
462        let item_schema_id =
463            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
464        schema.node_mut(schema.root).content = SchemaNodeContent::Array(ArraySchema {
465            item: item_schema_id,
466            min_length: Some(1),
467            max_length: Some(3),
468            unique: false,
469            contains: None,
470            binding_style: None,
471        });
472
473        let mut doc = EureDocument::new();
474        let root_id = doc.get_root_id();
475        doc.node_mut(root_id).content = NodeValue::Array(Default::default());
476        let child1 = doc.add_array_element(None, root_id).unwrap().node_id;
477        doc.node_mut(child1).content =
478            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(1)));
479        let child2 = doc.add_array_element(None, root_id).unwrap().node_id;
480        doc.node_mut(child2).content =
481            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(2)));
482
483        let result = validate(&doc, &schema);
484        assert!(result.is_valid);
485    }
486
487    #[test]
488    fn test_validate_map_with_union_key_schema() {
489        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
490        let text_key_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
491        let int_key_schema_id =
492            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
493        let any_value_schema_id = schema.create_node(SchemaNodeContent::Any);
494
495        let mut variants = IndexMap::new();
496        variants.insert("text".to_string(), text_key_schema_id);
497        variants.insert("integer".to_string(), int_key_schema_id);
498        let union_key_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
499            variants,
500            unambiguous: IndexSet::new(),
501            interop: crate::interop::UnionInterop::default(),
502            deny_untagged: IndexSet::new(),
503        }));
504
505        schema.node_mut(schema.root).content = SchemaNodeContent::Map(MapSchema {
506            key: union_key_schema_id,
507            value: any_value_schema_id,
508            min_size: None,
509            max_size: None,
510        });
511
512        let mut doc = EureDocument::new();
513        let root_id = doc.get_root_id();
514
515        let text_value_id = doc
516            .add_map_child(ObjectKey::String("name".to_string()), root_id)
517            .unwrap()
518            .node_id;
519        doc.node_mut(text_value_id).content =
520            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
521
522        let int_value_id = doc
523            .add_map_child(ObjectKey::Number(BigInt::from(1)), root_id)
524            .unwrap()
525            .node_id;
526        doc.node_mut(int_value_id).content =
527            NodeValue::Primitive(PrimitiveValue::Integer(42.into()));
528
529        let result = validate(&doc, &schema);
530        assert!(
531            result.is_valid,
532            "Expected union key schema to validate: {:?}",
533            result.errors
534        );
535    }
536
537    #[test]
538    fn test_validate_map_with_reference_to_union_key_schema() {
539        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
540        let text_key_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
541        let int_key_schema_id =
542            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
543        let any_value_schema_id = schema.create_node(SchemaNodeContent::Any);
544
545        let mut variants = IndexMap::new();
546        variants.insert("text".to_string(), text_key_schema_id);
547        variants.insert("integer".to_string(), int_key_schema_id);
548        let union_key_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
549            variants,
550            unambiguous: IndexSet::new(),
551            interop: crate::interop::UnionInterop::default(),
552            deny_untagged: IndexSet::new(),
553        }));
554        schema.register_type(Identifier::new_unchecked("key"), union_key_schema_id);
555
556        let key_ref_schema_id =
557            schema.create_node(SchemaNodeContent::Reference(TypeReference::Named {
558                namespace: None,
559                name: Identifier::new_unchecked("key"),
560            }));
561
562        schema.node_mut(schema.root).content = SchemaNodeContent::Map(MapSchema {
563            key: key_ref_schema_id,
564            value: any_value_schema_id,
565            min_size: None,
566            max_size: None,
567        });
568
569        let mut doc = EureDocument::new();
570        let root_id = doc.get_root_id();
571        let value_id = doc
572            .add_map_child(ObjectKey::Number(BigInt::from(7)), root_id)
573            .unwrap()
574            .node_id;
575        doc.node_mut(value_id).content = NodeValue::Primitive(PrimitiveValue::Bool(true));
576
577        let result = validate(&doc, &schema);
578        assert!(
579            result.is_valid,
580            "Expected reference to union key schema to validate: {:?}",
581            result.errors
582        );
583    }
584
585    #[test]
586    fn test_validate_record_flattened_map_boolean_key() {
587        // Repro: validate_flattened_map_key has no Boolean arm, so a boolean
588        // key schema ("true"/"false" are ObjectKey::String per ADR-0006) falls
589        // through to InvalidKeyType. This test should fail until the bug is fixed.
590        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
591        let bool_key_schema_id = schema.create_node(SchemaNodeContent::Boolean);
592        let any_value_schema_id = schema.create_node(SchemaNodeContent::Any);
593        let map_schema_id = schema.create_node(SchemaNodeContent::Map(MapSchema {
594            key: bool_key_schema_id,
595            value: any_value_schema_id,
596            min_size: None,
597            max_size: None,
598        }));
599        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
600            properties: IndexMap::new(),
601            flatten: vec![map_schema_id],
602            unknown_fields: UnknownFieldsPolicy::Deny,
603        });
604
605        let mut doc = EureDocument::new();
606        let root_id = doc.get_root_id();
607        let value_id = doc
608            .add_map_child(ObjectKey::String("true".to_string()), root_id)
609            .unwrap()
610            .node_id;
611        doc.node_mut(value_id).content = NodeValue::Primitive(PrimitiveValue::Bool(true));
612
613        let result = validate(&doc, &schema);
614        assert!(
615            result.is_valid,
616            "Expected boolean key 'true' to be valid against Boolean key schema in flattened map: {:?}",
617            result.errors
618        );
619    }
620
621    #[test]
622    fn test_validate_record() {
623        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
624        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
625        let age_schema_id =
626            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
627
628        let mut properties = IndexMap::new();
629        properties.insert(
630            "name".to_string(),
631            RecordFieldSchema {
632                schema: name_schema_id,
633                optional: false,
634                binding_style: None,
635                field_codegen: FieldCodegen::default(),
636            },
637        );
638        properties.insert(
639            "age".to_string(),
640            RecordFieldSchema {
641                schema: age_schema_id,
642                optional: true,
643                binding_style: None,
644                field_codegen: FieldCodegen::default(),
645            },
646        );
647
648        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
649            properties,
650            flatten: vec![],
651            unknown_fields: UnknownFieldsPolicy::Deny,
652        });
653
654        let mut doc = EureDocument::new();
655        let root_id = doc.get_root_id();
656        let name_id = doc
657            .add_map_child(ObjectKey::String("name".to_string()), root_id)
658            .unwrap()
659            .node_id;
660        doc.node_mut(name_id).content =
661            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
662
663        let result = validate(&doc, &schema);
664        assert!(result.is_valid);
665    }
666
667    #[test]
668    fn test_validate_record_with_sibling_flatten_targets() {
669        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
670        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
671        let age_schema_id =
672            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
673
674        let mut left_properties = IndexMap::new();
675        left_properties.insert(
676            "name".to_string(),
677            RecordFieldSchema {
678                schema: name_schema_id,
679                optional: false,
680                binding_style: None,
681                field_codegen: FieldCodegen::default(),
682            },
683        );
684        let left_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
685            properties: left_properties,
686            flatten: vec![],
687            unknown_fields: UnknownFieldsPolicy::Deny,
688        }));
689
690        let mut right_properties = IndexMap::new();
691        right_properties.insert(
692            "age".to_string(),
693            RecordFieldSchema {
694                schema: age_schema_id,
695                optional: false,
696                binding_style: None,
697                field_codegen: FieldCodegen::default(),
698            },
699        );
700        let right_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
701            properties: right_properties,
702            flatten: vec![],
703            unknown_fields: UnknownFieldsPolicy::Deny,
704        }));
705
706        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
707            properties: IndexMap::new(),
708            flatten: vec![left_schema_id, right_schema_id],
709            unknown_fields: UnknownFieldsPolicy::Deny,
710        });
711
712        let mut doc = EureDocument::new();
713        let root_id = doc.get_root_id();
714        let name_id = doc
715            .add_map_child(ObjectKey::String("name".to_string()), root_id)
716            .unwrap()
717            .node_id;
718        doc.node_mut(name_id).content =
719            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
720        let age_id = doc
721            .add_map_child(ObjectKey::String("age".to_string()), root_id)
722            .unwrap()
723            .node_id;
724        doc.node_mut(age_id).content =
725            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(42)));
726
727        let result = validate(&doc, &schema);
728        assert!(
729            result.is_valid,
730            "Expected sibling flatten targets to validate, got errors: {:?}",
731            result.errors
732        );
733    }
734
735    #[test]
736    fn test_validate_record_with_flattened_union_and_sibling_flatten_targets() {
737        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
738        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
739        let nickname_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
740        let age_schema_id =
741            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
742
743        let mut person_properties = IndexMap::new();
744        person_properties.insert(
745            "name".to_string(),
746            RecordFieldSchema {
747                schema: name_schema_id,
748                optional: false,
749                binding_style: None,
750                field_codegen: FieldCodegen::default(),
751            },
752        );
753        let person_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
754            properties: person_properties,
755            flatten: vec![],
756            unknown_fields: UnknownFieldsPolicy::Deny,
757        }));
758
759        let mut alias_properties = IndexMap::new();
760        alias_properties.insert(
761            "nickname".to_string(),
762            RecordFieldSchema {
763                schema: nickname_schema_id,
764                optional: false,
765                binding_style: None,
766                field_codegen: FieldCodegen::default(),
767            },
768        );
769        let alias_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
770            properties: alias_properties,
771            flatten: vec![],
772            unknown_fields: UnknownFieldsPolicy::Deny,
773        }));
774
775        let mut union_variants = IndexMap::new();
776        union_variants.insert("Person".to_string(), person_schema_id);
777        union_variants.insert("Alias".to_string(), alias_schema_id);
778        let union_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
779            variants: union_variants,
780            unambiguous: IndexSet::new(),
781            interop: crate::interop::UnionInterop::default(),
782            deny_untagged: IndexSet::new(),
783        }));
784
785        let mut sibling_properties = IndexMap::new();
786        sibling_properties.insert(
787            "age".to_string(),
788            RecordFieldSchema {
789                schema: age_schema_id,
790                optional: false,
791                binding_style: None,
792                field_codegen: FieldCodegen::default(),
793            },
794        );
795        let sibling_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
796            properties: sibling_properties,
797            flatten: vec![],
798            unknown_fields: UnknownFieldsPolicy::Deny,
799        }));
800
801        let mut doc = EureDocument::new();
802        let root_id = doc.get_root_id();
803        let name_id = doc
804            .add_map_child(ObjectKey::String("name".to_string()), root_id)
805            .unwrap()
806            .node_id;
807        doc.node_mut(name_id).content =
808            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("Alice".to_string())));
809        let age_id = doc
810            .add_map_child(ObjectKey::String("age".to_string()), root_id)
811            .unwrap()
812            .node_id;
813        doc.node_mut(age_id).content =
814            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(42)));
815
816        for flatten in [
817            vec![union_schema_id, sibling_schema_id],
818            vec![sibling_schema_id, union_schema_id],
819        ] {
820            schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
821                properties: IndexMap::new(),
822                flatten,
823                unknown_fields: UnknownFieldsPolicy::Deny,
824            });
825
826            let result = validate(&doc, &schema);
827            assert!(
828                result.is_valid,
829                "Expected flattened union + sibling flatten target to validate, got errors: {:?}",
830                result.errors
831            );
832        }
833    }
834
835    #[test]
836    fn test_flattened_union_best_match_ignores_sibling_consumed_fields() {
837        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
838        let name_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
839        let nickname_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
840        let age_schema_id =
841            schema.create_node(SchemaNodeContent::Integer(IntegerSchema::default()));
842
843        let mut person_properties = IndexMap::new();
844        person_properties.insert(
845            "name".to_string(),
846            RecordFieldSchema {
847                schema: name_schema_id,
848                optional: false,
849                binding_style: None,
850                field_codegen: FieldCodegen::default(),
851            },
852        );
853        let person_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
854            properties: person_properties,
855            flatten: vec![],
856            unknown_fields: UnknownFieldsPolicy::Deny,
857        }));
858
859        let mut alias_properties = IndexMap::new();
860        alias_properties.insert(
861            "nickname".to_string(),
862            RecordFieldSchema {
863                schema: nickname_schema_id,
864                optional: false,
865                binding_style: None,
866                field_codegen: FieldCodegen::default(),
867            },
868        );
869        let alias_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
870            properties: alias_properties,
871            flatten: vec![],
872            unknown_fields: UnknownFieldsPolicy::Deny,
873        }));
874
875        let mut union_variants = IndexMap::new();
876        union_variants.insert("Person".to_string(), person_schema_id);
877        union_variants.insert("Alias".to_string(), alias_schema_id);
878        let union_schema_id = schema.create_node(SchemaNodeContent::Union(UnionSchema {
879            variants: union_variants,
880            unambiguous: IndexSet::new(),
881            interop: crate::interop::UnionInterop::default(),
882            deny_untagged: IndexSet::new(),
883        }));
884
885        let mut sibling_properties = IndexMap::new();
886        sibling_properties.insert(
887            "age".to_string(),
888            RecordFieldSchema {
889                schema: age_schema_id,
890                optional: false,
891                binding_style: None,
892                field_codegen: FieldCodegen::default(),
893            },
894        );
895        let sibling_schema_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
896            properties: sibling_properties,
897            flatten: vec![],
898            unknown_fields: UnknownFieldsPolicy::Deny,
899        }));
900
901        schema.node_mut(schema.root).content = SchemaNodeContent::Record(RecordSchema {
902            properties: IndexMap::new(),
903            flatten: vec![union_schema_id, sibling_schema_id],
904            unknown_fields: UnknownFieldsPolicy::Deny,
905        });
906
907        let mut doc = EureDocument::new();
908        let root_id = doc.get_root_id();
909        let age_id = doc
910            .add_map_child(ObjectKey::String("age".to_string()), root_id)
911            .unwrap()
912            .node_id;
913        doc.node_mut(age_id).content =
914            NodeValue::Primitive(PrimitiveValue::Integer(BigInt::from(42)));
915        let fax_id = doc
916            .add_map_child(ObjectKey::String("fax".to_string()), root_id)
917            .unwrap()
918            .node_id;
919        doc.node_mut(fax_id).content =
920            NodeValue::Primitive(PrimitiveValue::Text(Text::plaintext("123".to_string())));
921
922        let result = validate(&doc, &schema);
923        assert!(!result.is_valid);
924
925        let no_variant_error = result
926            .errors
927            .iter()
928            .find_map(|error| match error {
929                ValidationError::NoVariantMatched {
930                    best_match: Some(best_match),
931                    ..
932                } => Some(best_match),
933                _ => None,
934            })
935            .expect("expected flattened union best match");
936
937        assert!(
938            no_variant_error.all_errors.iter().any(|error| matches!(
939                error,
940                ValidationError::UnknownField { field, .. } if field == "fax"
941            )),
942            "expected best match to retain globally unknown field"
943        );
944        assert!(
945            !no_variant_error.all_errors.iter().any(|error| matches!(
946                error,
947                ValidationError::UnknownField { field, .. } if field == "age"
948            )),
949            "best match should not treat sibling-consumed field as unknown"
950        );
951    }
952
953    #[test]
954    fn test_validate_hole() {
955        let (schema, _) =
956            create_simple_schema(SchemaNodeContent::Integer(IntegerSchema::default()));
957
958        let mut doc = EureDocument::new();
959        let root_id = doc.get_root_id();
960        doc.node_mut(root_id).content = NodeValue::Hole(None);
961
962        let result = validate(&doc, &schema);
963        assert!(result.is_valid);
964        assert!(!result.is_complete);
965    }
966
967    /// Helper to create a literal schema from an EureDocument
968    fn create_literal_schema(
969        schema: &mut SchemaDocument,
970        literal_doc: EureDocument,
971    ) -> SchemaNodeId {
972        schema.create_node(SchemaNodeContent::Literal(literal_doc))
973    }
974
975    #[test]
976    fn test_validate_union_deny_untagged_without_tag() {
977        use eure_document::eure;
978
979        // Create a union with a literal variant that has deny_untagged = true
980        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
981
982        // Create literal schema for "active"
983        let literal_schema_id = create_literal_schema(&mut schema, eure!({ = "active" }));
984
985        // Create union with literal variant that requires explicit tagging
986        let mut variants = IndexMap::new();
987        variants.insert("literal".to_string(), literal_schema_id);
988
989        let mut deny_untagged = IndexSet::new();
990        deny_untagged.insert("literal".to_string());
991
992        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
993            variants,
994            unambiguous: IndexSet::new(),
995            interop: crate::interop::UnionInterop::default(),
996            deny_untagged,
997        });
998
999        // Create document with literal value but NO $variant tag
1000        let doc = eure!({ = "active" });
1001
1002        // Validation should fail with RequiresExplicitVariant error
1003        let result = validate(&doc, &schema);
1004        assert!(!result.is_valid);
1005        assert!(result.errors.iter().any(|e| matches!(
1006            e,
1007            ValidationError::RequiresExplicitVariant { variant, .. } if variant == "literal"
1008        )));
1009    }
1010
1011    #[test]
1012    fn test_validate_union_deny_untagged_with_tag() {
1013        use eure_document::eure;
1014
1015        // Create a union with a literal variant that has deny_untagged = true
1016        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
1017
1018        // Create literal schema for "active"
1019        let literal_schema_id = create_literal_schema(&mut schema, eure!({ = "active" }));
1020
1021        // Create union with literal variant that requires explicit tagging
1022        let mut variants = IndexMap::new();
1023        variants.insert("literal".to_string(), literal_schema_id);
1024
1025        let mut deny_untagged = IndexSet::new();
1026        deny_untagged.insert("literal".to_string());
1027
1028        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
1029            variants,
1030            unambiguous: IndexSet::new(),
1031            interop: crate::interop::UnionInterop::default(),
1032            deny_untagged,
1033        });
1034
1035        // Create document with literal value WITH $variant tag
1036        let doc = eure!({
1037            = "active"
1038            %variant = "literal"
1039        });
1040
1041        // Validation should succeed
1042        let result = validate(&doc, &schema);
1043        assert!(
1044            result.is_valid,
1045            "Expected valid, got errors: {:?}",
1046            result.errors
1047        );
1048    }
1049
1050    #[test]
1051    fn test_validate_union_mixed_deny_untagged() {
1052        use eure_document::eure;
1053
1054        // Test that non-deny-untagged variants can still match via untagged
1055        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
1056
1057        // Create literal schema for "active" (deny_untagged)
1058        let literal_active_id = create_literal_schema(&mut schema, eure!({ = "active" }));
1059
1060        // Create text schema (not deny_untagged)
1061        let text_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
1062
1063        // Create union where literal requires explicit tag but text doesn't
1064        let mut variants = IndexMap::new();
1065        variants.insert("literal".to_string(), literal_active_id);
1066        variants.insert("text".to_string(), text_schema_id);
1067
1068        let mut deny_untagged = IndexSet::new();
1069        deny_untagged.insert("literal".to_string());
1070
1071        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
1072            variants,
1073            unambiguous: IndexSet::new(),
1074            interop: crate::interop::UnionInterop::default(),
1075            deny_untagged,
1076        });
1077
1078        // Create document with value "active" but no tag
1079        // This should fail because "literal" matches but requires explicit tag
1080        let doc = eure!({ = "active" });
1081
1082        let result = validate(&doc, &schema);
1083        assert!(!result.is_valid);
1084        assert!(result.errors.iter().any(|e| matches!(
1085            e,
1086            ValidationError::RequiresExplicitVariant { variant, .. } if variant == "literal"
1087        )));
1088
1089        // Create document with value "other text" - should match text variant via untagged
1090        let doc2 = eure!({ = "other text" });
1091
1092        let result2 = validate(&doc2, &schema);
1093        assert!(
1094            result2.is_valid,
1095            "Expected valid for text match, got errors: {:?}",
1096            result2.errors
1097        );
1098    }
1099
1100    #[test]
1101    fn test_validate_union_internal_interop_does_not_count_as_explicit_tag() {
1102        use eure_document::eure;
1103
1104        let (mut schema, _) = create_simple_schema(SchemaNodeContent::Any);
1105
1106        let type_schema_id = schema.create_node(SchemaNodeContent::Text(TextSchema::default()));
1107        let mut properties = IndexMap::new();
1108        properties.insert(
1109            "type".to_string(),
1110            RecordFieldSchema {
1111                schema: type_schema_id,
1112                optional: false,
1113                binding_style: None,
1114                field_codegen: FieldCodegen::default(),
1115            },
1116        );
1117        let success_record_id = schema.create_node(SchemaNodeContent::Record(RecordSchema {
1118            properties,
1119            flatten: vec![],
1120            unknown_fields: UnknownFieldsPolicy::Deny,
1121        }));
1122
1123        let mut variants = IndexMap::new();
1124        variants.insert("success".to_string(), success_record_id);
1125
1126        let mut deny_untagged = IndexSet::new();
1127        deny_untagged.insert("success".to_string());
1128
1129        schema.node_mut(schema.root).content = SchemaNodeContent::Union(UnionSchema {
1130            variants,
1131            unambiguous: IndexSet::new(),
1132            interop: crate::interop::UnionInterop {
1133                variant_repr: Some(crate::interop::VariantRepr::Internal {
1134                    tag: "type".to_string(),
1135                }),
1136            },
1137            deny_untagged,
1138        });
1139
1140        // `type = "success"` is interop metadata only; without `$variant`, this is still untagged.
1141        let doc = eure!({ type = "success" });
1142        let result = validate(&doc, &schema);
1143        assert!(!result.is_valid);
1144        assert!(result.errors.iter().any(|e| matches!(
1145            e,
1146            ValidationError::RequiresExplicitVariant { variant, .. } if variant == "success"
1147        )));
1148
1149        // Adding `$variant` makes it explicit and validation succeeds.
1150        let tagged_doc = eure!({
1151            type = "success"
1152            %variant = "success"
1153        });
1154        let tagged_result = validate(&tagged_doc, &schema);
1155        assert!(
1156            tagged_result.is_valid,
1157            "Expected valid with explicit $variant, got errors: {:?}",
1158            tagged_result.errors
1159        );
1160    }
1161
1162    #[test]
1163    fn test_validate_literal_with_inline_code() {
1164        use eure_document::eure;
1165
1166        // Test that Literal comparison works correctly with inline code (Language::Implicit)
1167        let mut schema = SchemaDocument::new();
1168
1169        // Create literal schema using inline code (like meta-schema does)
1170        let literal_doc = eure!({ = @code("boolean") });
1171
1172        schema.node_mut(schema.root).content = SchemaNodeContent::Literal(literal_doc);
1173
1174        // Create document with inline code "boolean"
1175        let doc = eure!({ = @code("boolean") });
1176
1177        // Validation should succeed
1178        let result = validate(&doc, &schema);
1179        assert!(
1180            result.is_valid,
1181            "Expected valid, got errors: {:?}",
1182            result.errors
1183        );
1184    }
1185
1186    #[test]
1187    fn test_validate_with_trace_covers_all_node_ids_and_is_deterministic() {
1188        use eure_document::eure;
1189
1190        let schema_doc = eure!({
1191            profile {
1192                name = @code("text")
1193                tags = [@code("text")]
1194            }
1195            active = @code("boolean")
1196        });
1197        let (schema, layout, _source_map) =
1198            document_to_schema_with_layout(&schema_doc).expect("schema conversion should succeed");
1199
1200        let input_doc = eure!({
1201            profile {
1202                name = "Alice"
1203                tags = ["core", "ops"]
1204            }
1205            active = true
1206        });
1207
1208        let first = validate_with_trace(&input_doc, &schema, &layout.schema_node_paths);
1209        let second = validate_with_trace(&input_doc, &schema, &layout.schema_node_paths);
1210
1211        assert_eq!(first.node_type_traces, second.node_type_traces);
1212        assert_eq!(first.node_type_traces.len(), input_doc.node_count());
1213
1214        for index in 0..input_doc.node_count() {
1215            assert!(
1216                first.node_type_traces.contains_key(&NodeId(index)),
1217                "missing trace for NodeId({index})"
1218            );
1219        }
1220
1221        assert!(
1222            first.node_type_traces.values().all(|trace| !matches!(
1223                trace,
1224                ResolvedTypeTrace::Unresolved(TypeTraceUnresolvedReason::NotVisited)
1225            )),
1226            "all reachable document nodes must be visited"
1227        );
1228    }
1229}