Skip to main content

ron_schema/
validate.rs

1/*************************
2 * Author: Bradley Hunter
3 */
4
5use std::collections::{HashMap, HashSet};
6
7use crate::error::{ErrorKind, ValidationError};
8
9/// Validates a parsed RON value against a schema.
10///
11/// Returns all validation errors found — does not stop at the first error.
12/// An empty vec means the data is valid.
13#[must_use] 
14pub fn validate(schema: &Schema, value: &Spanned<RonValue>) -> Vec<ValidationError> {
15    let mut errors = Vec::new();
16    validate_struct(&schema.root, value, "", &mut errors, &schema.enums);
17    errors
18}
19use crate::ron::RonValue;
20use crate::schema::{EnumDef, Schema, SchemaType, StructDef};
21use crate::span::Spanned;
22
23/// Produces a human-readable description of a RON value for error messages.
24fn describe(value: &RonValue) -> String {
25    match value {
26        RonValue::String(s) => {
27            if s.len() > 20 {
28                format!("String(\"{}...\")", &s[..20])
29            } else {
30                format!("String(\"{s}\")")
31            }
32        }
33        RonValue::Integer(n) => format!("Integer({n})"),
34        RonValue::Float(f) => format!("Float({f})"),
35        RonValue::Bool(b) => format!("Bool({b})"),
36        RonValue::Option(_) => "Option".to_string(),
37        RonValue::Identifier(s) => format!("Identifier({s})"),
38        RonValue::List(_) => "List".to_string(),
39        RonValue::Struct(_) => "Struct".to_string(),
40    }
41}
42
43/// Builds a dot-separated field path for error messages.
44///
45/// An empty parent means we're at the root, so just return the field name.
46/// Otherwise, join with a dot: `"cost"` + `"generic"` → `"cost.generic"`.
47fn build_path(parent: &str, field: &str) -> String {
48    if parent.is_empty() {
49        field.to_string()
50    } else {
51        format!("{parent}.{field}")
52    }
53}
54
55/// Validates a single RON value against an expected schema type.
56///
57/// Matches on the expected type and checks that the actual value conforms.
58/// For composite types (Option, List, Struct), recurses into the inner values.
59/// Errors are collected into the `errors` vec — validation does not stop at the first error.
60#[allow(clippy::too_many_lines)]
61fn validate_type(
62    expected: &SchemaType,
63    actual: &Spanned<RonValue>,
64    path: &str,
65    errors: &mut Vec<ValidationError>,
66    enums: &HashMap<String, EnumDef>,
67) {
68    match expected {
69        // Primitives: check that the value variant matches the schema type.
70        SchemaType::String => {
71            if !matches!(actual.value, RonValue::String(_)) {
72                errors.push(ValidationError {
73                    path: path.to_string(),
74                    span: actual.span,
75                    kind: ErrorKind::TypeMismatch {
76                        expected: "String".to_string(),
77                        found: describe(&actual.value),
78                    },
79                });
80            }
81        }
82        SchemaType::Integer => {
83            if !matches!(actual.value, RonValue::Integer(_)) {
84                errors.push(ValidationError {
85                    path: path.to_string(),
86                    span: actual.span,
87                    kind: ErrorKind::TypeMismatch {
88                        expected: "Integer".to_string(),
89                        found: describe(&actual.value),
90                    },
91                });
92            }
93        }
94        SchemaType::Float => {
95            if !matches!(actual.value, RonValue::Float(_)) {
96                errors.push(ValidationError {
97                    path: path.to_string(),
98                    span: actual.span,
99                    kind: ErrorKind::TypeMismatch {
100                        expected: "Float".to_string(),
101                        found: describe(&actual.value),
102                    },
103                });
104            }
105        }
106        SchemaType::Bool => {
107            if !matches!(actual.value, RonValue::Bool(_)) {
108                errors.push(ValidationError {
109                    path: path.to_string(),
110                    span: actual.span,
111                    kind: ErrorKind::TypeMismatch {
112                        expected: "Bool".to_string(),
113                        found: describe(&actual.value),
114                    },
115                });
116            }
117        }
118
119        // Option: None is always valid. Some(inner) recurses into the inner value.
120        // Anything else (bare integer, string, etc.) is an error — must be Some(...) or None.
121        SchemaType::Option(inner_type) => match &actual.value {
122            RonValue::Option(None) => {}
123            RonValue::Option(Some(inner_value)) => {
124                validate_type(inner_type, inner_value, path, errors, enums);
125            }
126            _ => {
127                errors.push(ValidationError {
128                    path: path.to_string(),
129                    span: actual.span,
130                    kind: ErrorKind::ExpectedOption {
131                        found: describe(&actual.value),
132                    },
133                });
134            }
135        },
136
137        // List: check value is a list, then validate each element against the element type.
138        // Path gets bracket notation: "card_types[0]", "card_types[1]", etc.
139        SchemaType::List(element_type) => {
140            if let RonValue::List(elements) = &actual.value {
141                for (index, element) in elements.iter().enumerate() {
142                    let element_path = format!("{path}[{index}]");
143                    validate_type(element_type, element, &element_path, errors, enums);
144                }
145            } else {
146                errors.push(ValidationError {
147                    path: path.to_string(),
148                    span: actual.span,
149                    kind: ErrorKind::ExpectedList {
150                        found: describe(&actual.value),
151                    },
152                });
153            }
154        }
155
156        // EnumRef: value must be an Identifier whose name is in the enum's variant set.
157        // The enum is guaranteed to exist — the schema parser verified all references.
158        SchemaType::EnumRef(enum_name) => {
159            let enum_def = &enums[enum_name];
160            if let RonValue::Identifier(variant) = &actual.value {
161                if !enum_def.variants.contains(variant) {
162                    errors.push(ValidationError {
163                        path: path.to_string(),
164                        span: actual.span,
165                        kind: ErrorKind::InvalidEnumVariant {
166                            enum_name: enum_name.clone(),
167                            variant: variant.clone(),
168                            valid: enum_def.variants.iter().cloned().collect(),
169                        },
170                    });
171                }
172            } else {
173                errors.push(ValidationError {
174                    path: path.to_string(),
175                    span: actual.span,
176                    kind: ErrorKind::TypeMismatch {
177                        expected: enum_name.clone(),
178                        found: describe(&actual.value),
179                    },
180                });
181            }
182        }
183
184        // Nested struct: recurse into validate_struct.
185        SchemaType::Struct(struct_def) => {
186            validate_struct(struct_def, actual, path, errors, enums);
187        }
188    }
189}
190
191/// Validates a RON struct against a schema struct definition.
192///
193/// Three checks:
194/// 1. Missing fields — in schema but not in data (points to closing paren)
195/// 2. Unknown fields — in data but not in schema (points to field name)
196/// 3. Matching fields — present in both, recurse into `validate_type`
197fn validate_struct(
198    struct_def: &StructDef,
199    actual: &Spanned<RonValue>,
200    path: &str,
201    errors: &mut Vec<ValidationError>,
202    enums: &HashMap<String, EnumDef>,
203) {
204    // Value must be a struct — if not, report and bail (can't check fields of a non-struct)
205    let RonValue::Struct(data_struct) = &actual.value else {
206        errors.push(ValidationError {
207            path: path.to_string(),
208            span: actual.span,
209            kind: ErrorKind::ExpectedStruct {
210                found: describe(&actual.value),
211            },
212        });
213        return;
214    };
215
216    // Build a lookup map from data fields for O(1) access by name
217    let data_map: HashMap<&str, &Spanned<RonValue>> = data_struct
218        .fields
219        .iter()
220        .map(|(name, value)| (name.value.as_str(), value))
221        .collect();
222
223    // Build a set of schema field names for unknown-field detection
224    let schema_names: HashSet<&str> = struct_def
225        .fields
226        .iter()
227        .map(|f| f.name.value.as_str())
228        .collect();
229
230    // 1. Missing fields: in schema but not in data
231    for field_def in &struct_def.fields {
232        if !data_map.contains_key(field_def.name.value.as_str()) {
233            errors.push(ValidationError {
234                path: build_path(path, &field_def.name.value),
235                span: data_struct.close_span,
236                kind: ErrorKind::MissingField {
237                    field_name: field_def.name.value.clone(),
238                },
239            });
240        }
241    }
242
243    // 2. Unknown fields: in data but not in schema
244    for (name, _value) in &data_struct.fields {
245        if !schema_names.contains(name.value.as_str()) {
246            errors.push(ValidationError {
247                path: build_path(path, &name.value),
248                span: name.span,
249                kind: ErrorKind::UnknownField {
250                    field_name: name.value.clone(),
251                },
252            });
253        }
254    }
255
256    // 3. Matching fields: validate each against its expected type
257    for field_def in &struct_def.fields {
258        if let Some(data_value) = data_map.get(field_def.name.value.as_str()) {
259            let field_path = build_path(path, &field_def.name.value);
260            validate_type(&field_def.type_.value, data_value, &field_path, errors, enums);
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::schema::parser::parse_schema;
269    use crate::ron::parser::parse_ron;
270
271    /// Parses both a schema and data string, runs validation, returns errors.
272    fn validate_str(schema_src: &str, data_src: &str) -> Vec<ValidationError> {
273        let schema = parse_schema(schema_src).expect("test schema should parse");
274        let data = parse_ron(data_src).expect("test data should parse");
275        validate(&schema, &data)
276    }
277
278    // ========================================================
279    // describe() tests
280    // ========================================================
281
282    // Describes a string value.
283    #[test]
284    fn describe_string() {
285        assert_eq!(describe(&RonValue::String("hi".to_string())), "String(\"hi\")");
286    }
287
288    // Truncates long strings at 20 characters.
289    #[test]
290    fn describe_string_truncated() {
291        let long = "a".repeat(30);
292        let desc = describe(&RonValue::String(long));
293        assert!(desc.contains("..."));
294    }
295
296    // Describes an integer.
297    #[test]
298    fn describe_integer() {
299        assert_eq!(describe(&RonValue::Integer(42)), "Integer(42)");
300    }
301
302    // Describes a float.
303    #[test]
304    fn describe_float() {
305        assert_eq!(describe(&RonValue::Float(3.14)), "Float(3.14)");
306    }
307
308    // Describes a bool.
309    #[test]
310    fn describe_bool() {
311        assert_eq!(describe(&RonValue::Bool(true)), "Bool(true)");
312    }
313
314    // Describes an identifier.
315    #[test]
316    fn describe_identifier() {
317        assert_eq!(describe(&RonValue::Identifier("Creature".to_string())), "Identifier(Creature)");
318    }
319
320    // ========================================================
321    // build_path() tests
322    // ========================================================
323
324    // Root-level field has no dot prefix.
325    #[test]
326    fn build_path_root() {
327        assert_eq!(build_path("", "name"), "name");
328    }
329
330    // Nested field gets dot notation.
331    #[test]
332    fn build_path_nested() {
333        assert_eq!(build_path("cost", "generic"), "cost.generic");
334    }
335
336    // Deeply nested path.
337    #[test]
338    fn build_path_deep() {
339        assert_eq!(build_path("a.b", "c"), "a.b.c");
340    }
341
342    // ========================================================
343    // Valid data — no errors
344    // ========================================================
345
346    // Valid data with a single string field.
347    #[test]
348    fn valid_single_string_field() {
349        let errs = validate_str("(\n  name: String,\n)", "(name: \"hello\")");
350        assert!(errs.is_empty());
351    }
352
353    // Valid data with all primitive types.
354    #[test]
355    fn valid_all_primitives() {
356        let schema = "(\n  s: String,\n  i: Integer,\n  f: Float,\n  b: Bool,\n)";
357        let data = "(s: \"hi\", i: 42, f: 3.14, b: true)";
358        let errs = validate_str(schema, data);
359        assert!(errs.is_empty());
360    }
361
362    // Valid data with None option.
363    #[test]
364    fn valid_option_none() {
365        let errs = validate_str("(\n  power: Option(Integer),\n)", "(power: None)");
366        assert!(errs.is_empty());
367    }
368
369    // Valid data with Some option.
370    #[test]
371    fn valid_option_some() {
372        let errs = validate_str("(\n  power: Option(Integer),\n)", "(power: Some(5))");
373        assert!(errs.is_empty());
374    }
375
376    // Valid data with empty list.
377    #[test]
378    fn valid_list_empty() {
379        let errs = validate_str("(\n  tags: [String],\n)", "(tags: [])");
380        assert!(errs.is_empty());
381    }
382
383    // Valid data with populated list.
384    #[test]
385    fn valid_list_populated() {
386        let errs = validate_str("(\n  tags: [String],\n)", "(tags: [\"a\", \"b\"])");
387        assert!(errs.is_empty());
388    }
389
390    // Valid data with enum variant.
391    #[test]
392    fn valid_enum_variant() {
393        let schema = "(\n  kind: Kind,\n)\nenum Kind { A, B, C }";
394        let data = "(kind: B)";
395        let errs = validate_str(schema, data);
396        assert!(errs.is_empty());
397    }
398
399    // Valid data with list of enum variants.
400    #[test]
401    fn valid_enum_list() {
402        let schema = "(\n  types: [CardType],\n)\nenum CardType { Creature, Trap }";
403        let data = "(types: [Creature, Trap])";
404        let errs = validate_str(schema, data);
405        assert!(errs.is_empty());
406    }
407
408    // Valid data with nested struct.
409    #[test]
410    fn valid_nested_struct() {
411        let schema = "(\n  cost: (\n    generic: Integer,\n    sigil: Integer,\n  ),\n)";
412        let data = "(cost: (generic: 2, sigil: 1))";
413        let errs = validate_str(schema, data);
414        assert!(errs.is_empty());
415    }
416
417    // ========================================================
418    // TypeMismatch errors
419    // ========================================================
420
421    // String field rejects integer value.
422    #[test]
423    fn type_mismatch_string_got_integer() {
424        let errs = validate_str("(\n  name: String,\n)", "(name: 42)");
425        assert_eq!(errs.len(), 1);
426        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { expected, .. } if expected == "String"));
427    }
428
429    // Integer field rejects string value.
430    #[test]
431    fn type_mismatch_integer_got_string() {
432        let errs = validate_str("(\n  age: Integer,\n)", "(age: \"five\")");
433        assert_eq!(errs.len(), 1);
434        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { expected, .. } if expected == "Integer"));
435    }
436
437    // Float field rejects integer value.
438    #[test]
439    fn type_mismatch_float_got_integer() {
440        let errs = validate_str("(\n  rate: Float,\n)", "(rate: 5)");
441        assert_eq!(errs.len(), 1);
442        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { expected, .. } if expected == "Float"));
443    }
444
445    // Bool field rejects string value.
446    #[test]
447    fn type_mismatch_bool_got_string() {
448        let errs = validate_str("(\n  flag: Bool,\n)", "(flag: \"yes\")");
449        assert_eq!(errs.len(), 1);
450        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { expected, .. } if expected == "Bool"));
451    }
452
453    // Error path is correct for type mismatch.
454    #[test]
455    fn type_mismatch_has_correct_path() {
456        let errs = validate_str("(\n  name: String,\n)", "(name: 42)");
457        assert_eq!(errs[0].path, "name");
458    }
459
460    // ========================================================
461    // MissingField errors
462    // ========================================================
463
464    // Missing field is detected.
465    #[test]
466    fn missing_field_detected() {
467        let errs = validate_str("(\n  name: String,\n  age: Integer,\n)", "(name: \"hi\")");
468        assert_eq!(errs.len(), 1);
469        assert!(matches!(&errs[0].kind, ErrorKind::MissingField { field_name } if field_name == "age"));
470    }
471
472    // Missing field path is correct.
473    #[test]
474    fn missing_field_has_correct_path() {
475        let errs = validate_str("(\n  name: String,\n  age: Integer,\n)", "(name: \"hi\")");
476        assert_eq!(errs[0].path, "age");
477    }
478
479    // Missing field span points to close paren.
480    #[test]
481    fn missing_field_span_points_to_close_paren() {
482        let data = "(name: \"hi\")";
483        let errs = validate_str("(\n  name: String,\n  age: Integer,\n)", data);
484        // close paren is the last character
485        assert_eq!(errs[0].span.start.offset, data.len() - 1);
486    }
487
488    // Multiple missing fields are all reported.
489    #[test]
490    fn missing_fields_all_reported() {
491        let errs = validate_str("(\n  a: String,\n  b: Integer,\n  c: Bool,\n)", "()");
492        assert_eq!(errs.len(), 3);
493    }
494
495    // ========================================================
496    // UnknownField errors
497    // ========================================================
498
499    // Unknown field is detected.
500    #[test]
501    fn unknown_field_detected() {
502        let errs = validate_str("(\n  name: String,\n)", "(name: \"hi\", colour: \"red\")");
503        assert_eq!(errs.len(), 1);
504        assert!(matches!(&errs[0].kind, ErrorKind::UnknownField { field_name } if field_name == "colour"));
505    }
506
507    // Unknown field path is correct.
508    #[test]
509    fn unknown_field_has_correct_path() {
510        let errs = validate_str("(\n  name: String,\n)", "(name: \"hi\", extra: 5)");
511        assert_eq!(errs[0].path, "extra");
512    }
513
514    // ========================================================
515    // InvalidEnumVariant errors
516    // ========================================================
517
518    // Invalid enum variant is detected.
519    #[test]
520    fn invalid_enum_variant() {
521        let schema = "(\n  kind: Kind,\n)\nenum Kind { A, B }";
522        let errs = validate_str(schema, "(kind: C)");
523        assert_eq!(errs.len(), 1);
524        assert!(matches!(&errs[0].kind, ErrorKind::InvalidEnumVariant { variant, .. } if variant == "C"));
525    }
526
527    // Enum field rejects string value (should be bare identifier).
528    #[test]
529    fn enum_rejects_string() {
530        let schema = "(\n  kind: Kind,\n)\nenum Kind { A, B }";
531        let errs = validate_str(schema, "(kind: \"A\")");
532        assert_eq!(errs.len(), 1);
533        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { .. }));
534    }
535
536    // ========================================================
537    // ExpectedOption errors
538    // ========================================================
539
540    // Option field rejects bare integer (not wrapped in Some).
541    #[test]
542    fn expected_option_got_bare_value() {
543        let errs = validate_str("(\n  power: Option(Integer),\n)", "(power: 5)");
544        assert_eq!(errs.len(), 1);
545        assert!(matches!(&errs[0].kind, ErrorKind::ExpectedOption { .. }));
546    }
547
548    // Some wrapping wrong type is an error.
549    #[test]
550    fn option_some_wrong_inner_type() {
551        let errs = validate_str("(\n  power: Option(Integer),\n)", "(power: Some(\"five\"))");
552        assert_eq!(errs.len(), 1);
553        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { expected, .. } if expected == "Integer"));
554    }
555
556    // ========================================================
557    // ExpectedList errors
558    // ========================================================
559
560    // List field rejects non-list value.
561    #[test]
562    fn expected_list_got_string() {
563        let errs = validate_str("(\n  tags: [String],\n)", "(tags: \"hi\")");
564        assert_eq!(errs.len(), 1);
565        assert!(matches!(&errs[0].kind, ErrorKind::ExpectedList { .. }));
566    }
567
568    // List element with wrong type is an error.
569    #[test]
570    fn list_element_wrong_type() {
571        let errs = validate_str("(\n  tags: [String],\n)", "(tags: [\"ok\", 42])");
572        assert_eq!(errs.len(), 1);
573        assert!(matches!(&errs[0].kind, ErrorKind::TypeMismatch { expected, .. } if expected == "String"));
574    }
575
576    // List element error has bracket path.
577    #[test]
578    fn list_element_error_has_bracket_path() {
579        let errs = validate_str("(\n  tags: [String],\n)", "(tags: [\"ok\", 42])");
580        assert_eq!(errs[0].path, "tags[1]");
581    }
582
583    // ========================================================
584    // ExpectedStruct errors
585    // ========================================================
586
587    // Struct field rejects non-struct value.
588    #[test]
589    fn expected_struct_got_integer() {
590        let schema = "(\n  cost: (\n    generic: Integer,\n  ),\n)";
591        let errs = validate_str(schema, "(cost: 5)");
592        assert_eq!(errs.len(), 1);
593        assert!(matches!(&errs[0].kind, ErrorKind::ExpectedStruct { .. }));
594    }
595
596    // ========================================================
597    // Nested validation
598    // ========================================================
599
600    // Type mismatch in nested struct has correct path.
601    #[test]
602    fn nested_struct_type_mismatch_path() {
603        let schema = "(\n  cost: (\n    generic: Integer,\n  ),\n)";
604        let errs = validate_str(schema, "(cost: (generic: \"two\"))");
605        assert_eq!(errs.len(), 1);
606        assert_eq!(errs[0].path, "cost.generic");
607    }
608
609    // Missing field in nested struct has correct path.
610    #[test]
611    fn nested_struct_missing_field_path() {
612        let schema = "(\n  cost: (\n    generic: Integer,\n    sigil: Integer,\n  ),\n)";
613        let errs = validate_str(schema, "(cost: (generic: 1))");
614        assert_eq!(errs.len(), 1);
615        assert_eq!(errs[0].path, "cost.sigil");
616    }
617
618    // ========================================================
619    // Multiple errors collected
620    // ========================================================
621
622    // Multiple errors in one struct are all reported.
623    #[test]
624    fn multiple_errors_collected() {
625        let schema = "(\n  name: String,\n  age: Integer,\n  active: Bool,\n)";
626        let data = "(name: 42, age: \"five\", active: \"yes\")";
627        let errs = validate_str(schema, data);
628        assert_eq!(errs.len(), 3);
629    }
630
631    // Mixed error types are all collected.
632    #[test]
633    fn mixed_error_types_collected() {
634        let schema = "(\n  name: String,\n  age: Integer,\n)";
635        let data = "(name: \"hi\", age: \"five\", extra: true)";
636        let errs = validate_str(schema, data);
637        // age is TypeMismatch, extra is UnknownField
638        assert_eq!(errs.len(), 2);
639    }
640
641    // ========================================================
642    // Integration: card-like schema
643    // ========================================================
644
645    // Valid card data produces no errors.
646    #[test]
647    fn valid_card_data() {
648        let schema = r#"(
649            name: String,
650            card_types: [CardType],
651            legendary: Bool,
652            power: Option(Integer),
653            toughness: Option(Integer),
654            keywords: [String],
655        )
656        enum CardType { Creature, Trap, Artifact }"#;
657        let data = r#"(
658            name: "Ashborn Hound",
659            card_types: [Creature],
660            legendary: false,
661            power: Some(1),
662            toughness: Some(1),
663            keywords: [],
664        )"#;
665        let errs = validate_str(schema, data);
666        assert!(errs.is_empty());
667    }
668
669    // Card data with multiple errors reports all of them.
670    #[test]
671    fn card_data_multiple_errors() {
672        let schema = r#"(
673            name: String,
674            card_types: [CardType],
675            legendary: Bool,
676            power: Option(Integer),
677        )
678        enum CardType { Creature, Trap }"#;
679        let data = r#"(
680            name: 42,
681            card_types: [Pirates],
682            legendary: false,
683            power: Some("five"),
684        )"#;
685        let errs = validate_str(schema, data);
686        // name: TypeMismatch, card_types[0]: InvalidEnumVariant, power: TypeMismatch
687        assert_eq!(errs.len(), 3);
688    }
689}