facet_styx/
schema_validate.rs

1//! Schema validation for Styx documents.
2//!
3//! Validates `styx_tree::Value` instances against `Schema` definitions.
4
5use std::collections::HashSet;
6
7use styx_tree::{Payload, Value};
8
9/// Compute Levenshtein distance between two strings.
10fn levenshtein(a: &str, b: &str) -> usize {
11    let a_len = a.chars().count();
12    let b_len = b.chars().count();
13
14    if a_len == 0 {
15        return b_len;
16    }
17    if b_len == 0 {
18        return a_len;
19    }
20
21    let mut prev_row: Vec<usize> = (0..=b_len).collect();
22    let mut curr_row = vec![0; b_len + 1];
23
24    for (i, a_char) in a.chars().enumerate() {
25        curr_row[0] = i + 1;
26        for (j, b_char) in b.chars().enumerate() {
27            let cost = if a_char == b_char { 0 } else { 1 };
28            curr_row[j + 1] = (prev_row[j + 1] + 1)
29                .min(curr_row[j] + 1)
30                .min(prev_row[j] + cost);
31        }
32        std::mem::swap(&mut prev_row, &mut curr_row);
33    }
34
35    prev_row[b_len]
36}
37
38/// Find the most similar string from a list, if one is close enough.
39fn suggest_similar<'a>(unknown: &str, valid: &'a [String]) -> Option<&'a str> {
40    let unknown_lower = unknown.to_lowercase();
41    valid
42        .iter()
43        .filter_map(|v| {
44            let v_lower = v.to_lowercase();
45            let dist = levenshtein(&unknown_lower, &v_lower);
46            // Only suggest if edit distance is at most 2 and less than half the length
47            if dist <= 2 && dist < unknown.len().max(1) {
48                Some((v.as_str(), dist))
49            } else {
50                None
51            }
52        })
53        .min_by_key(|(_, d)| *d)
54        .map(|(v, _)| v)
55}
56
57use crate::schema_error::{
58    ValidationError, ValidationErrorKind, ValidationResult, ValidationWarning,
59    ValidationWarningKind,
60};
61use crate::schema_types::{
62    DefaultSchema, DeprecatedSchema, Documented, EnumSchema, FlattenSchema, FloatConstraints,
63    IntConstraints, MapSchema, ObjectKey, ObjectSchema, OneOfSchema, OptionalSchema, Schema,
64    SchemaFile, SeqSchema, StringConstraints, TupleSchema, UnionSchema,
65};
66
67/// Validator for Styx documents.
68pub struct Validator<'a> {
69    /// The schema file containing type definitions.
70    schema_file: &'a SchemaFile,
71}
72
73impl<'a> Validator<'a> {
74    /// Create a new validator with the given schema.
75    pub fn new(schema_file: &'a SchemaFile) -> Self {
76        Self { schema_file }
77    }
78
79    /// Validate a document against the schema's root type.
80    pub fn validate_document(&self, doc: &Value) -> ValidationResult {
81        // Look up the root schema (key None = unit/@)
82        match self.schema_file.schema.get(&None) {
83            Some(root_schema) => self.validate_value(doc, root_schema, ""),
84            None => {
85                let mut result = ValidationResult::ok();
86                result.error(
87                    ValidationError::new(
88                        "",
89                        ValidationErrorKind::SchemaError {
90                            reason: "no root type (@) defined in schema".into(),
91                        },
92                        "schema has no root type definition",
93                    )
94                    .with_span(doc.span),
95                );
96                result
97            }
98        }
99    }
100
101    /// Validate a value against a specific named type.
102    pub fn validate_as_type(&self, value: &Value, type_name: &str) -> ValidationResult {
103        match self.schema_file.schema.get(&Some(type_name.to_string())) {
104            Some(schema) => self.validate_value(value, schema, ""),
105            None => {
106                let mut result = ValidationResult::ok();
107                result.error(
108                    ValidationError::new(
109                        "",
110                        ValidationErrorKind::UnknownType {
111                            name: type_name.into(),
112                        },
113                        format!("unknown type '{type_name}'"),
114                    )
115                    .with_span(value.span),
116                );
117                result
118            }
119        }
120    }
121
122    /// Validate a value against a schema.
123    pub fn validate_value(&self, value: &Value, schema: &Schema, path: &str) -> ValidationResult {
124        match schema {
125            // Built-in scalar types
126            Schema::String(constraints) => self.validate_string(value, constraints.as_ref(), path),
127            Schema::Int(constraints) => self.validate_int(value, constraints.as_ref(), path),
128            Schema::Float(constraints) => self.validate_float(value, constraints.as_ref(), path),
129            Schema::Bool => self.validate_bool(value, path),
130            Schema::Unit => self.validate_unit(value, path),
131            Schema::Any => ValidationResult::ok(),
132
133            // Structural types
134            Schema::Object(obj_schema) => self.validate_object(value, obj_schema, path),
135            Schema::Seq(seq_schema) => self.validate_seq(value, seq_schema, path),
136            Schema::Tuple(tuple_schema) => self.validate_tuple(value, tuple_schema, path),
137            Schema::Map(map_schema) => self.validate_map(value, map_schema, path),
138
139            // Combinators
140            Schema::Union(union_schema) => self.validate_union(value, union_schema, path),
141            Schema::Optional(opt_schema) => self.validate_optional(value, opt_schema, path),
142            Schema::Enum(enum_schema) => self.validate_enum(value, enum_schema, path),
143            Schema::OneOf(oneof_schema) => self.validate_one_of(value, oneof_schema, path),
144            Schema::Flatten(flatten_schema) => self.validate_flatten(value, flatten_schema, path),
145
146            // Wrappers
147            Schema::Default(default_schema) => self.validate_default(value, default_schema, path),
148            Schema::Deprecated(deprecated_schema) => {
149                self.validate_deprecated(value, deprecated_schema, path)
150            }
151
152            // Other
153            Schema::Literal(expected) => self.validate_literal(value, expected, path),
154            Schema::Type { name } => self.validate_type_ref(value, name.as_deref(), path),
155        }
156    }
157
158    // =========================================================================
159    // Built-in scalar types
160    // =========================================================================
161
162    fn validate_string(
163        &self,
164        value: &Value,
165        constraints: Option<&StringConstraints>,
166        path: &str,
167    ) -> ValidationResult {
168        let mut result = ValidationResult::ok();
169
170        let text = match value.scalar_text() {
171            Some(t) => t,
172            None => {
173                result.error(
174                    ValidationError::new(
175                        path,
176                        ValidationErrorKind::ExpectedScalar,
177                        format!("expected string, got {}", value_type_name(value)),
178                    )
179                    .with_span(value.span),
180                );
181                return result;
182            }
183        };
184
185        // Apply constraints if present
186        if let Some(c) = constraints {
187            if let Some(min) = c.min_len
188                && text.len() < min
189            {
190                result.error(
191                    ValidationError::new(
192                        path,
193                        ValidationErrorKind::InvalidValue {
194                            reason: format!("string length {} < minimum {}", text.len(), min),
195                        },
196                        format!("string too short (min length: {})", min),
197                    )
198                    .with_span(value.span),
199                );
200            }
201            if let Some(max) = c.max_len
202                && text.len() > max
203            {
204                result.error(
205                    ValidationError::new(
206                        path,
207                        ValidationErrorKind::InvalidValue {
208                            reason: format!("string length {} > maximum {}", text.len(), max),
209                        },
210                        format!("string too long (max length: {})", max),
211                    )
212                    .with_span(value.span),
213                );
214            }
215            if let Some(pattern) = &c.pattern {
216                // TODO: compile and match regex
217                let _ = pattern; // Suppress unused warning for now
218            }
219        }
220
221        result
222    }
223
224    fn validate_int(
225        &self,
226        value: &Value,
227        constraints: Option<&IntConstraints>,
228        path: &str,
229    ) -> ValidationResult {
230        let mut result = ValidationResult::ok();
231
232        let text = match value.scalar_text() {
233            Some(t) => t,
234            None => {
235                result.error(
236                    ValidationError::new(
237                        path,
238                        ValidationErrorKind::ExpectedScalar,
239                        format!("expected integer, got {}", value_type_name(value)),
240                    )
241                    .with_span(value.span),
242                );
243                return result;
244            }
245        };
246
247        let parsed = match text.parse::<i128>() {
248            Ok(n) => n,
249            Err(_) => {
250                result.error(
251                    ValidationError::new(
252                        path,
253                        ValidationErrorKind::InvalidValue {
254                            reason: "not a valid integer".into(),
255                        },
256                        format!("'{}' is not a valid integer", text),
257                    )
258                    .with_span(value.span),
259                );
260                return result;
261            }
262        };
263
264        // Apply constraints
265        if let Some(c) = constraints {
266            if let Some(min) = c.min
267                && parsed < min
268            {
269                result.error(
270                    ValidationError::new(
271                        path,
272                        ValidationErrorKind::InvalidValue {
273                            reason: format!("value {} < minimum {}", parsed, min),
274                        },
275                        format!("value too small (min: {})", min),
276                    )
277                    .with_span(value.span),
278                );
279            }
280            if let Some(max) = c.max
281                && parsed > max
282            {
283                result.error(
284                    ValidationError::new(
285                        path,
286                        ValidationErrorKind::InvalidValue {
287                            reason: format!("value {} > maximum {}", parsed, max),
288                        },
289                        format!("value too large (max: {})", max),
290                    )
291                    .with_span(value.span),
292                );
293            }
294        }
295
296        result
297    }
298
299    fn validate_float(
300        &self,
301        value: &Value,
302        constraints: Option<&FloatConstraints>,
303        path: &str,
304    ) -> ValidationResult {
305        let mut result = ValidationResult::ok();
306
307        let text = match value.scalar_text() {
308            Some(t) => t,
309            None => {
310                result.error(
311                    ValidationError::new(
312                        path,
313                        ValidationErrorKind::ExpectedScalar,
314                        format!("expected number, got {}", value_type_name(value)),
315                    )
316                    .with_span(value.span),
317                );
318                return result;
319            }
320        };
321
322        let parsed = match text.parse::<f64>() {
323            Ok(n) => n,
324            Err(_) => {
325                result.error(
326                    ValidationError::new(
327                        path,
328                        ValidationErrorKind::InvalidValue {
329                            reason: "not a valid number".into(),
330                        },
331                        format!("'{}' is not a valid number", text),
332                    )
333                    .with_span(value.span),
334                );
335                return result;
336            }
337        };
338
339        // Apply constraints
340        if let Some(c) = constraints {
341            if let Some(min) = c.min
342                && parsed < min
343            {
344                result.error(
345                    ValidationError::new(
346                        path,
347                        ValidationErrorKind::InvalidValue {
348                            reason: format!("value {} < minimum {}", parsed, min),
349                        },
350                        format!("value too small (min: {})", min),
351                    )
352                    .with_span(value.span),
353                );
354            }
355            if let Some(max) = c.max
356                && parsed > max
357            {
358                result.error(
359                    ValidationError::new(
360                        path,
361                        ValidationErrorKind::InvalidValue {
362                            reason: format!("value {} > maximum {}", parsed, max),
363                        },
364                        format!("value too large (max: {})", max),
365                    )
366                    .with_span(value.span),
367                );
368            }
369        }
370
371        result
372    }
373
374    fn validate_bool(&self, value: &Value, path: &str) -> ValidationResult {
375        let mut result = ValidationResult::ok();
376
377        match value.scalar_text() {
378            Some(text) if text == "true" || text == "false" => {}
379            Some(text) => {
380                result.error(
381                    ValidationError::new(
382                        path,
383                        ValidationErrorKind::InvalidValue {
384                            reason: "not a valid boolean".into(),
385                        },
386                        format!("'{}' is not a valid boolean (expected true/false)", text),
387                    )
388                    .with_span(value.span),
389                );
390            }
391            None => {
392                result.error(
393                    ValidationError::new(
394                        path,
395                        ValidationErrorKind::ExpectedScalar,
396                        format!("expected boolean, got {}", value_type_name(value)),
397                    )
398                    .with_span(value.span),
399                );
400            }
401        }
402
403        result
404    }
405
406    fn validate_unit(&self, value: &Value, path: &str) -> ValidationResult {
407        let mut result = ValidationResult::ok();
408
409        if !value.is_unit() {
410            result.error(
411                ValidationError::new(
412                    path,
413                    ValidationErrorKind::TypeMismatch {
414                        expected: "unit".into(),
415                        got: value_type_name(value).into(),
416                    },
417                    "expected unit value",
418                )
419                .with_span(value.span),
420            );
421        }
422
423        result
424    }
425
426    // =========================================================================
427    // Structural types
428    // =========================================================================
429
430    fn validate_object(
431        &self,
432        value: &Value,
433        schema: &ObjectSchema,
434        path: &str,
435    ) -> ValidationResult {
436        let mut result = ValidationResult::ok();
437
438        let obj = match value.as_object() {
439            Some(o) => o,
440            None => {
441                result.error(
442                    ValidationError::new(
443                        path,
444                        ValidationErrorKind::ExpectedObject,
445                        format!("expected object, got {}", value_type_name(value)),
446                    )
447                    .with_span(value.span),
448                );
449                return result;
450            }
451        };
452
453        let mut seen_fields: HashSet<Option<&str>> = HashSet::new();
454        // Look up catch-all schema - find any key that is a typed pattern or unit
455        let additional_schema = schema
456            .0
457            .iter()
458            .find_map(|(k, v)| if k.value.tag.is_some() { Some(v) } else { None });
459
460        for entry in &obj.entries {
461            let key_opt: Option<&str> = if entry.key.is_unit() {
462                None
463            } else if let Some(s) = entry.key.as_str() {
464                Some(s)
465            } else {
466                result.error(
467                    ValidationError::new(
468                        path,
469                        ValidationErrorKind::InvalidValue {
470                            reason: "object keys must be scalars or unit".into(),
471                        },
472                        "invalid object key",
473                    )
474                    .with_span(entry.key.span),
475                );
476                continue;
477            };
478
479            let key_display = key_opt.unwrap_or("@");
480            let field_path = if path.is_empty() {
481                key_display.to_string()
482            } else {
483                format!("{path}.{key_display}")
484            };
485
486            seen_fields.insert(key_opt);
487
488            // Look up by Documented<ObjectKey> - for named fields
489            let lookup_key = Documented::new(ObjectKey::named(key_opt.unwrap_or("")));
490            if let Some(field_schema) = schema.0.get(&lookup_key) {
491                result.merge(self.validate_value(&entry.value, field_schema, &field_path));
492            } else if let Some(add_schema) = additional_schema {
493                result.merge(self.validate_value(&entry.value, add_schema, &field_path));
494            } else {
495                // Collect valid field names for error message
496                let valid_fields: Vec<String> = schema
497                    .0
498                    .keys()
499                    .filter_map(|k| k.value.name().map(|s| s.to_string()))
500                    .collect();
501
502                // Try to find a similar field name (typo detection)
503                let suggestion = suggest_similar(key_display, &valid_fields).map(String::from);
504
505                result.error(
506                    ValidationError::new(
507                        &field_path,
508                        ValidationErrorKind::UnknownField {
509                            field: key_display.into(),
510                            valid_fields,
511                            suggestion,
512                        },
513                        format!("unknown field '{key_display}'"),
514                    )
515                    .with_span(entry.key.span),
516                );
517            }
518        }
519
520        // Check for missing required fields
521        for (field_name_doc, field_schema) in &schema.0 {
522            // Skip catch-all fields (typed patterns like @string)
523            let Some(name) = field_name_doc.value.name() else {
524                continue;
525            };
526
527            if !seen_fields.contains(&Some(name)) {
528                // Optional and Default fields are not required
529                if !matches!(field_schema, Schema::Optional(_) | Schema::Default(_)) {
530                    let field_path = if path.is_empty() {
531                        name.to_string()
532                    } else {
533                        format!("{path}.{name}")
534                    };
535                    result.error(
536                        ValidationError::new(
537                            &field_path,
538                            ValidationErrorKind::MissingField {
539                                field: name.to_string(),
540                            },
541                            format!("missing required field '{name}'"),
542                        )
543                        .with_span(value.span),
544                    );
545                }
546            }
547        }
548
549        result
550    }
551
552    fn validate_seq(&self, value: &Value, schema: &SeqSchema, path: &str) -> ValidationResult {
553        let mut result = ValidationResult::ok();
554
555        let seq = match value.as_sequence() {
556            Some(s) => s,
557            None => {
558                result.error(
559                    ValidationError::new(
560                        path,
561                        ValidationErrorKind::ExpectedSequence,
562                        format!("expected sequence, got {}", value_type_name(value)),
563                    )
564                    .with_span(value.span),
565                );
566                return result;
567            }
568        };
569
570        // Validate each element against the inner schema
571        let inner_schema = &*schema.0.0.value;
572        for (i, item) in seq.items.iter().enumerate() {
573            let item_path = format!("{path}[{i}]");
574            result.merge(self.validate_value(item, inner_schema, &item_path));
575        }
576
577        result
578    }
579
580    fn validate_tuple(&self, value: &Value, schema: &TupleSchema, path: &str) -> ValidationResult {
581        let mut result = ValidationResult::ok();
582
583        let seq = match value.as_sequence() {
584            Some(s) => s,
585            None => {
586                result.error(
587                    ValidationError::new(
588                        path,
589                        ValidationErrorKind::ExpectedSequence,
590                        format!("expected tuple (sequence), got {}", value_type_name(value)),
591                    )
592                    .with_span(value.span),
593                );
594                return result;
595            }
596        };
597
598        let expected_len = schema.0.len();
599        let actual_len = seq.items.len();
600
601        if actual_len != expected_len {
602            result.error(
603                ValidationError::new(
604                    path,
605                    ValidationErrorKind::InvalidValue {
606                        reason: format!(
607                            "tuple has wrong number of elements: expected {}, got {}",
608                            expected_len, actual_len
609                        ),
610                    },
611                    format!(
612                        "tuple has wrong number of elements: expected {}, got {}",
613                        expected_len, actual_len
614                    ),
615                )
616                .with_span(value.span),
617            );
618            // Still validate the elements we have
619        }
620
621        // Validate each element against its corresponding schema
622        for (i, (item, element_schema)) in seq.items.iter().zip(schema.0.iter()).enumerate() {
623            let item_path = format!("{path}[{i}]");
624            result.merge(self.validate_value(item, &element_schema.value, &item_path));
625        }
626
627        result
628    }
629
630    fn validate_map(&self, value: &Value, schema: &MapSchema, path: &str) -> ValidationResult {
631        let mut result = ValidationResult::ok();
632
633        let obj = match value.as_object() {
634            Some(o) => o,
635            None => {
636                result.error(
637                    ValidationError::new(
638                        path,
639                        ValidationErrorKind::ExpectedObject,
640                        format!("expected map (object), got {}", value_type_name(value)),
641                    )
642                    .with_span(value.span),
643                );
644                return result;
645            }
646        };
647
648        // @map(@V) has 1 element, @map(@K @V) has 2
649        let (key_schema, value_schema) = match schema.0.len() {
650            1 => (None, &schema.0[0].value),
651            2 => (Some(&schema.0[0].value), &schema.0[1].value),
652            n => {
653                result.error(
654                    ValidationError::new(
655                        path,
656                        ValidationErrorKind::SchemaError {
657                            reason: format!("map schema must have 1 or 2 types, got {}", n),
658                        },
659                        "invalid map schema",
660                    )
661                    .with_span(value.span),
662                );
663                return result;
664            }
665        };
666
667        for entry in &obj.entries {
668            let key_str = match entry.key.as_str() {
669                Some(s) => s,
670                None => {
671                    result.error(
672                        ValidationError::new(
673                            path,
674                            ValidationErrorKind::InvalidValue {
675                                reason: "map keys must be scalars".into(),
676                            },
677                            "invalid map key",
678                        )
679                        .with_span(entry.key.span),
680                    );
681                    continue;
682                }
683            };
684
685            // Validate key if schema provided
686            if let Some(ks) = key_schema {
687                result.merge(self.validate_value(&entry.key, ks, path));
688            }
689
690            // Validate value
691            let entry_path = if path.is_empty() {
692                key_str.to_string()
693            } else {
694                format!("{path}.{key_str}")
695            };
696            result.merge(self.validate_value(&entry.value, value_schema, &entry_path));
697        }
698
699        result
700    }
701
702    // =========================================================================
703    // Combinators
704    // =========================================================================
705
706    fn validate_union(&self, value: &Value, schema: &UnionSchema, path: &str) -> ValidationResult {
707        let mut result = ValidationResult::ok();
708
709        if schema.0.is_empty() {
710            result.error(
711                ValidationError::new(
712                    path,
713                    ValidationErrorKind::SchemaError {
714                        reason: "union must have at least one variant".into(),
715                    },
716                    "invalid union schema: no variants",
717                )
718                .with_span(value.span),
719            );
720            return result;
721        }
722
723        let mut tried = Vec::new();
724        for variant in &schema.0 {
725            let variant_result = self.validate_value(value, &variant.value, path);
726            if variant_result.is_valid() {
727                return ValidationResult::ok();
728            }
729            tried.push(schema_type_name(&variant.value));
730        }
731
732        result.error(
733            ValidationError::new(
734                path,
735                ValidationErrorKind::UnionMismatch { tried },
736                format!(
737                    "value doesn't match any union variant (tried: {})",
738                    schema
739                        .0
740                        .iter()
741                        .map(|d| schema_type_name(&d.value))
742                        .collect::<Vec<_>>()
743                        .join(", ")
744                ),
745            )
746            .with_span(value.span),
747        );
748
749        result
750    }
751
752    fn validate_optional(
753        &self,
754        value: &Value,
755        schema: &OptionalSchema,
756        path: &str,
757    ) -> ValidationResult {
758        // Unit value represents None - always valid for Optional
759        if value.is_unit() {
760            return ValidationResult::ok();
761        }
762        // Otherwise validate the inner type
763        self.validate_value(value, &schema.0.0.value, path)
764    }
765
766    fn validate_enum(&self, value: &Value, schema: &EnumSchema, path: &str) -> ValidationResult {
767        let mut result = ValidationResult::ok();
768
769        // An enum value must have a tag, OR match a fallback variant
770        let tag = match &value.tag {
771            Some(t) => t.name.as_str(),
772            None => {
773                // No tag - try to find a fallback variant that accepts this value type
774                if let Some(fallback_schema) = self.find_enum_fallback(value, schema) {
775                    // Validate against the fallback variant's schema
776                    return self.validate_value(value, fallback_schema, path);
777                }
778                result.error(
779                    ValidationError::new(
780                        path,
781                        ValidationErrorKind::ExpectedTagged,
782                        format!(
783                            "expected tagged value for enum, got {}",
784                            value_type_name(value)
785                        ),
786                    )
787                    .with_span(value.span),
788                );
789                return result;
790            }
791        };
792
793        // Extract payload as a Value for recursive validation
794        let payload_value = value.payload.as_ref().map(|p| Value {
795            tag: None,
796            payload: Some(p.clone()),
797            span: None,
798        });
799
800        let expected_variants: Vec<String> = schema.0.keys().map(|k| k.value.clone()).collect();
801
802        match schema.0.get(&Documented::new(tag.to_string())) {
803            Some(variant_schema) => {
804                match (&payload_value, variant_schema) {
805                    (None, Schema::Unit) => {
806                        // @variant with unit schema - OK
807                    }
808                    (None, Schema::Type { name: Some(n) }) if n == "unit" => {
809                        // @variant with @unit type ref - OK
810                    }
811                    (None, Schema::Type { name: None }) => {
812                        // @variant with @ schema - OK (unit)
813                    }
814                    (Some(p), _) => {
815                        let variant_path = if path.is_empty() {
816                            tag.to_string()
817                        } else {
818                            format!("{path}.{tag}")
819                        };
820                        result.merge(self.validate_value(p, variant_schema, &variant_path));
821                    }
822                    (None, _) => {
823                        result.error(
824                            ValidationError::new(
825                                path,
826                                ValidationErrorKind::TypeMismatch {
827                                    expected: schema_type_name(variant_schema),
828                                    got: "unit".into(),
829                                },
830                                format!("variant '{tag}' requires a payload"),
831                            )
832                            .with_span(value.span),
833                        );
834                    }
835                }
836            }
837            None => {
838                result.error(
839                    ValidationError::new(
840                        path,
841                        ValidationErrorKind::InvalidVariant {
842                            expected: expected_variants.clone(),
843                            got: tag.into(),
844                        },
845                        format!(
846                            "unknown enum variant '{tag}' (expected one of: {})",
847                            expected_variants.join(", ")
848                        ),
849                    )
850                    .with_span(value.span),
851                );
852            }
853        }
854
855        result
856    }
857
858    /// Find a fallback variant in an enum that can accept an untagged value.
859    ///
860    /// For example, if the enum has `eq @string` variant and the value is a bare string,
861    /// this returns the `@string` schema so the value can be validated against it.
862    fn find_enum_fallback<'s>(&self, value: &Value, schema: &'s EnumSchema) -> Option<&'s Schema> {
863        // Only scalars can fall back
864        let text = value.scalar_text()?;
865
866        // Look for a variant whose schema matches this value
867        for variant_schema in schema.0.values() {
868            match variant_schema {
869                // @string accepts any scalar
870                Schema::String(_) => return Some(variant_schema),
871                // @int accepts scalars that parse as integers
872                Schema::Int(_) if text.parse::<i64>().is_ok() => return Some(variant_schema),
873                // @float accepts scalars that parse as floats
874                Schema::Float(_) if text.parse::<f64>().is_ok() => return Some(variant_schema),
875                // @bool accepts "true" or "false"
876                Schema::Bool if text == "true" || text == "false" => return Some(variant_schema),
877                _ => continue,
878            }
879        }
880
881        None
882    }
883
884    fn validate_one_of(&self, value: &Value, schema: &OneOfSchema, path: &str) -> ValidationResult {
885        let mut result = ValidationResult::ok();
886
887        // First validate against the base type
888        let base_type = &schema.0.0.value;
889        let base_result = self.validate_value(value, base_type, path);
890        if !base_result.is_valid() {
891            return base_result;
892        }
893
894        // Then check if the value matches one of the allowed values
895        let allowed_values = &schema.0.1;
896        if allowed_values.is_empty() {
897            // No values specified means any value of the base type is allowed
898            return result;
899        }
900
901        // Get the string representation of the value for comparison
902        let value_text = match value.scalar_text() {
903            Some(t) => t,
904            None => {
905                // Non-scalar values can't be compared to allowed values
906                result.error(
907                    ValidationError::new(
908                        path,
909                        ValidationErrorKind::ExpectedScalar,
910                        format!(
911                            "expected scalar value for one-of constraint, got {}",
912                            value_type_name(value)
913                        ),
914                    )
915                    .with_span(value.span),
916                );
917                return result;
918            }
919        };
920
921        // Check if the value is in the allowed list
922        let allowed_strings: Vec<&str> = allowed_values.iter().map(|v| v.as_str()).collect();
923        if !allowed_strings.contains(&value_text) {
924            // Try to find a similar value for suggestions
925            let allowed_owned: Vec<String> = allowed_values.iter().map(|v| v.0.clone()).collect();
926            let suggestion = suggest_similar(value_text, &allowed_owned).map(String::from);
927
928            result.error(
929                ValidationError::new(
930                    path,
931                    ValidationErrorKind::InvalidValue {
932                        reason: format!(
933                            "value '{}' not in allowed set: {}",
934                            value_text,
935                            allowed_strings.join(", ")
936                        ),
937                    },
938                    format!(
939                        "'{}' is not one of: {}{}",
940                        value_text,
941                        allowed_strings.join(", "),
942                        suggestion
943                            .map(|s| format!(" (did you mean '{}'?)", s))
944                            .unwrap_or_default()
945                    ),
946                )
947                .with_span(value.span),
948            );
949        }
950
951        result
952    }
953
954    fn validate_flatten(
955        &self,
956        value: &Value,
957        schema: &FlattenSchema,
958        path: &str,
959    ) -> ValidationResult {
960        // Flatten just validates against the inner type
961        self.validate_value(value, &schema.0.0.value, path)
962    }
963
964    // =========================================================================
965    // Wrappers
966    // =========================================================================
967
968    fn validate_default(
969        &self,
970        value: &Value,
971        schema: &DefaultSchema,
972        path: &str,
973    ) -> ValidationResult {
974        // Default just validates against the inner type
975        // (the default value is used at deserialization time, not validation time)
976        self.validate_value(value, &schema.0.1.value, path)
977    }
978
979    fn validate_deprecated(
980        &self,
981        value: &Value,
982        schema: &DeprecatedSchema,
983        path: &str,
984    ) -> ValidationResult {
985        let (reason, inner) = &schema.0;
986        let mut result = self.validate_value(value, &inner.value, path);
987
988        // Add deprecation warning
989        result.warning(
990            ValidationWarning::new(
991                path,
992                ValidationWarningKind::Deprecated {
993                    reason: reason.clone(),
994                },
995                format!("deprecated: {}", reason),
996            )
997            .with_span(value.span),
998        );
999
1000        result
1001    }
1002
1003    // =========================================================================
1004    // Other
1005    // =========================================================================
1006
1007    fn validate_literal(&self, value: &Value, expected: &str, path: &str) -> ValidationResult {
1008        let mut result = ValidationResult::ok();
1009
1010        match value.scalar_text() {
1011            Some(text) if text == expected => {}
1012            Some(text) => {
1013                result.error(
1014                    ValidationError::new(
1015                        path,
1016                        ValidationErrorKind::InvalidValue {
1017                            reason: format!("expected literal '{expected}', got '{}'", text),
1018                        },
1019                        format!("expected '{expected}', got '{}'", text),
1020                    )
1021                    .with_span(value.span),
1022                );
1023            }
1024            None => {
1025                result.error(
1026                    ValidationError::new(
1027                        path,
1028                        ValidationErrorKind::ExpectedScalar,
1029                        format!("expected literal '{expected}', got non-scalar"),
1030                    )
1031                    .with_span(value.span),
1032                );
1033            }
1034        }
1035
1036        result
1037    }
1038
1039    fn validate_type_ref(
1040        &self,
1041        value: &Value,
1042        type_name: Option<&str>,
1043        path: &str,
1044    ) -> ValidationResult {
1045        let mut result = ValidationResult::ok();
1046
1047        match type_name {
1048            None => {
1049                // Unit type reference (@)
1050                if !value.is_unit() {
1051                    result.error(
1052                        ValidationError::new(
1053                            path,
1054                            ValidationErrorKind::TypeMismatch {
1055                                expected: "unit".into(),
1056                                got: value_type_name(value).into(),
1057                            },
1058                            "expected unit value",
1059                        )
1060                        .with_span(value.span),
1061                    );
1062                }
1063            }
1064            Some(name) => {
1065                // Named type reference - look up in schema
1066                if let Some(type_schema) = self.schema_file.schema.get(&Some(name.to_string())) {
1067                    result.merge(self.validate_value(value, type_schema, path));
1068                } else {
1069                    result.error(
1070                        ValidationError::new(
1071                            path,
1072                            ValidationErrorKind::UnknownType { name: name.into() },
1073                            format!("unknown type '{name}'"),
1074                        )
1075                        .with_span(value.span),
1076                    );
1077                }
1078            }
1079        }
1080
1081        result
1082    }
1083}
1084
1085/// Get a human-readable name for a value type.
1086fn value_type_name(value: &Value) -> &'static str {
1087    if value.is_unit() {
1088        return "unit";
1089    }
1090    if value.tag.is_some() {
1091        return "tagged";
1092    }
1093    match &value.payload {
1094        None => "unit",
1095        Some(Payload::Scalar(_)) => "scalar",
1096        Some(Payload::Sequence(_)) => "sequence",
1097        Some(Payload::Object(_)) => "object",
1098    }
1099}
1100
1101/// Get a human-readable name for a schema type.
1102fn schema_type_name(schema: &Schema) -> String {
1103    match schema {
1104        Schema::String(_) => "string".into(),
1105        Schema::Int(_) => "int".into(),
1106        Schema::Float(_) => "float".into(),
1107        Schema::Bool => "bool".into(),
1108        Schema::Unit => "unit".into(),
1109        Schema::Any => "any".into(),
1110        Schema::Object(_) => "object".into(),
1111        Schema::Seq(_) => "seq".into(),
1112        Schema::Tuple(_) => "tuple".into(),
1113        Schema::Map(_) => "map".into(),
1114        Schema::Union(_) => "union".into(),
1115        Schema::Optional(_) => "optional".into(),
1116        Schema::Enum(_) => "enum".into(),
1117        Schema::OneOf(_) => "one-of".into(),
1118        Schema::Flatten(_) => "flatten".into(),
1119        Schema::Default(_) => "default".into(),
1120        Schema::Deprecated(_) => "deprecated".into(),
1121        Schema::Literal(s) => format!("literal({s})"),
1122        Schema::Type { name: None } => "unit".into(),
1123        Schema::Type { name: Some(n) } => n.clone(),
1124    }
1125}
1126
1127/// Convenience function to validate a document against a schema.
1128pub fn validate(doc: &Value, schema: &SchemaFile) -> ValidationResult {
1129    let validator = Validator::new(schema);
1130    validator.validate_document(doc)
1131}
1132
1133/// Convenience function to validate a value against a named type.
1134pub fn validate_as(value: &Value, schema: &SchemaFile, type_name: &str) -> ValidationResult {
1135    let validator = Validator::new(schema);
1136    validator.validate_as_type(value, type_name)
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    #[test]
1144    fn test_typed_catchall_validation() {
1145        // Schema with @string catch-all (like HashMap<String, i32>)
1146        let schema_source = r#"meta {id test}
1147schema {
1148    @ @object{
1149        @string @int
1150    }
1151}"#;
1152        let schema: SchemaFile = crate::from_str(schema_source).expect("should parse schema");
1153
1154        // Check the parsed schema structure
1155        let root = schema.schema.get(&None).expect("should have root");
1156        if let Schema::Object(obj) = root {
1157            tracing::debug!("Object schema has {} entries", obj.0.len());
1158            for (key, value) in &obj.0 {
1159                tracing::debug!(
1160                    "Key: value={:?}, tag={:?} -> {:?}",
1161                    key.value.value,
1162                    key.value.tag,
1163                    value
1164                );
1165            }
1166            // Check if catch-all is found
1167            let catchall = obj.0.iter().find(|(k, _)| k.value.tag.is_some());
1168            assert!(
1169                catchall.is_some(),
1170                "Schema should have a typed catch-all entry. Keys: {:?}",
1171                obj.0
1172                    .keys()
1173                    .map(|k| (&k.value.value, &k.value.tag))
1174                    .collect::<Vec<_>>()
1175            );
1176        } else {
1177            panic!("Root should be an object schema, got {:?}", root);
1178        }
1179
1180        // Document with arbitrary keys
1181        let doc_source = r#"foo 42
1182bar 123"#;
1183        let doc = styx_tree::parse(doc_source).expect("should parse doc");
1184
1185        let result = validate(&doc, &schema);
1186        assert!(
1187            result.is_valid(),
1188            "Document should validate. Errors: {:?}",
1189            result.errors
1190        );
1191    }
1192
1193    #[test]
1194    fn test_enum_with_fallback_variant() {
1195        // Schema: enum with explicit variants + fallback `eq @string`
1196        let schema_source = r#"meta {id test}
1197schema {
1198    @ @object{
1199        filter @enum{
1200            gt @string
1201            lt @string
1202            eq @string
1203        }
1204    }
1205}"#;
1206        let schema: SchemaFile = crate::from_str(schema_source).expect("should parse schema");
1207
1208        // Document: bare string should match `eq @string` fallback
1209        let doc = styx_tree::parse(r#"filter "published""#).expect("should parse doc");
1210
1211        let result = validate(&doc, &schema);
1212        assert!(
1213            result.is_valid(),
1214            "Bare string should fall back to eq variant. Errors: {:?}",
1215            result.errors
1216        );
1217    }
1218
1219    #[test]
1220    fn test_optional_accepts_unit() {
1221        // Schema: field is @optional(@string)
1222        let schema_source = r#"meta {id test}
1223schema {
1224    @ @object{
1225        name @optional(@string)
1226    }
1227}"#;
1228        let schema: SchemaFile = crate::from_str(schema_source).expect("should parse schema");
1229
1230        // Document: field has unit value (represents None)
1231        let doc = styx_tree::parse("name").expect("should parse doc");
1232
1233        let result = validate(&doc, &schema);
1234        assert!(
1235            result.is_valid(),
1236            "Unit value should be valid for @optional. Errors: {:?}",
1237            result.errors
1238        );
1239    }
1240}