Skip to main content

alembic_core/
validation.rs

1//! validation utilities for the ir.
2
3use crate::ir::{
4    key_string, FieldFormat, FieldType, Inventory, Object, Schema, SourceLocation, TypeName, Uid,
5};
6use ipnet::IpNet;
7use regex::Regex;
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11use std::net::IpAddr;
12use std::sync::OnceLock;
13use thiserror::Error;
14
15/// validation errors emitted during graph validation.
16#[derive(Debug, Error, Clone, PartialEq, Eq)]
17pub enum ValidationError {
18    #[error("duplicate uid: {0}")]
19    DuplicateUid(Uid),
20    #[error("duplicate key: {0}")]
21    DuplicateKey(String),
22    #[error("missing type on object")]
23    MissingType,
24    #[error("missing key on object")]
25    MissingKey,
26    #[error("missing key field {type_name}.{field}")]
27    MissingKeyField { type_name: String, field: String },
28    #[error("extra key field {type_name}.{field}")]
29    ExtraKeyField { type_name: String, field: String },
30    #[error("missing attr field {type_name}.{field}")]
31    MissingAttrField { type_name: String, field: String },
32    #[error("extra attr field {type_name}.{field}")]
33    ExtraAttrField { type_name: String, field: String },
34    #[error("invalid value for {field}: expected {expected}, got {actual}")]
35    InvalidValue {
36        field: String,
37        expected: String,
38        actual: String,
39    },
40    #[error("unknown type: {0}")]
41    UnknownType(String),
42    #[error("missing reference {field} -> {target}")]
43    MissingReference { field: String, target: Uid },
44    #[error("reference type mismatch {field} -> {target} (expected {expected}, got {actual})")]
45    ReferenceTypeMismatch {
46        field: String,
47        target: Uid,
48        expected: String,
49        actual: String,
50    },
51    #[error("unknown ref target {type_name}.{field} -> {target}")]
52    UnknownRefTarget {
53        type_name: String,
54        field: String,
55        target: String,
56    },
57    #[error("invalid pattern for {type_name}.{field}: {error} (pattern: {pattern})")]
58    InvalidSchemaPattern {
59        type_name: String,
60        field: String,
61        pattern: String,
62        error: String,
63    },
64    #[error("{constraint} constraint on non-string field {type_name}.{field} (type {field_type})")]
65    ConstraintOnNonStringField {
66        type_name: String,
67        field: String,
68        constraint: String,
69        field_type: String,
70    },
71    #[error("empty enum for {type_name}.{field}: an enum with no values is unsatisfiable")]
72    EmptyEnum { type_name: String, field: String },
73}
74
75impl ValidationError {
76    /// return the uid associated with this error, if any.
77    pub fn uid(&self) -> Option<Uid> {
78        match self {
79            ValidationError::DuplicateUid(uid) => Some(*uid),
80            ValidationError::MissingReference { target, .. } => Some(*target),
81            ValidationError::ReferenceTypeMismatch { target, .. } => Some(*target),
82            _ => None,
83        }
84    }
85
86    /// return a key-like string associated with this error, if any.
87    pub fn key_hint(&self) -> Option<String> {
88        match self {
89            ValidationError::DuplicateKey(key) => {
90                if let Some((_, k)) = key.split_once("::") {
91                    Some(k.to_string())
92                } else {
93                    Some(key.clone())
94                }
95            }
96            _ => None,
97        }
98    }
99
100    /// return the type name associated with this error, if any.
101    pub fn type_hint(&self) -> Option<String> {
102        match self {
103            ValidationError::UnknownType(t) => Some(t.clone()),
104            ValidationError::MissingKeyField { type_name, .. }
105            | ValidationError::ExtraKeyField { type_name, .. }
106            | ValidationError::MissingAttrField { type_name, .. }
107            | ValidationError::ExtraAttrField { type_name, .. } => Some(type_name.clone()),
108            ValidationError::InvalidValue { field, .. } => {
109                field.split('.').next().map(|s| s.to_string())
110            }
111            ValidationError::MissingReference { field, .. }
112            | ValidationError::ReferenceTypeMismatch { field, .. } => {
113                field.split('.').next().map(|s| s.to_string())
114            }
115            ValidationError::DuplicateKey(key) => key.split("::").next().map(|s| s.to_string()),
116            _ => None,
117        }
118    }
119
120    /// return the dotted field path for this error, if any.
121    pub fn field(&self) -> Option<&str> {
122        match self {
123            ValidationError::InvalidValue { field, .. }
124            | ValidationError::MissingReference { field, .. }
125            | ValidationError::ReferenceTypeMismatch { field, .. } => Some(field),
126            _ => None,
127        }
128    }
129}
130
131/// a validation error with optional source location.
132#[derive(Debug, Clone)]
133pub struct LocatedError {
134    pub error: ValidationError,
135    pub source: Option<SourceLocation>,
136}
137
138impl LocatedError {
139    pub fn new(error: ValidationError) -> Self {
140        Self {
141            error,
142            source: None,
143        }
144    }
145
146    pub fn with_source(error: ValidationError, source: Option<SourceLocation>) -> Self {
147        Self { error, source }
148    }
149}
150
151impl fmt::Display for LocatedError {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        if let Some(source) = &self.source {
154            write!(f, "{}: {}", source, self.error)
155        } else {
156            write!(f, "{}", self.error)
157        }
158    }
159}
160
161/// aggregated validation report.
162#[derive(Debug, Default, Clone)]
163pub struct ValidationReport {
164    pub errors: Vec<ValidationError>,
165}
166
167impl ValidationReport {
168    /// return true when no errors are present.
169    pub fn is_ok(&self) -> bool {
170        self.errors.is_empty()
171    }
172
173    /// return true when errors are present.
174    pub fn is_err(&self) -> bool {
175        !self.errors.is_empty()
176    }
177
178    /// enrich errors with source locations from objects.
179    ///
180    /// this matches errors to objects based on UIDs, types, and keys,
181    /// and attaches the object's source location to the error.
182    pub fn with_sources(self, objects: &[Object]) -> Vec<LocatedError> {
183        // build lookup maps
184        let uid_to_source: BTreeMap<Uid, Option<SourceLocation>> =
185            objects.iter().map(|o| (o.uid, o.source.clone())).collect();
186        let key_to_source: BTreeMap<String, Option<SourceLocation>> = objects
187            .iter()
188            .map(|o| {
189                let key = format!("{}::{}", o.type_name, key_string(&o.key));
190                (key, o.source.clone())
191            })
192            .collect();
193        let type_to_source: BTreeMap<String, Option<SourceLocation>> = objects
194            .iter()
195            .filter_map(|o| o.source.clone().map(|s| (o.type_name.to_string(), Some(s))))
196            .collect();
197        let known_types: BTreeSet<&str> = objects.iter().map(|o| o.type_name.as_str()).collect();
198
199        self.errors
200            .into_iter()
201            .map(|error| {
202                let source = error
203                    .uid()
204                    // only DuplicateUid's uid is the offending object; ref errors' uid is the referent.
205                    .filter(|_| matches!(error, ValidationError::DuplicateUid(_)))
206                    .and_then(|uid| uid_to_source.get(&uid).cloned().flatten())
207                    .or_else(|| {
208                        error.key_hint().and_then(|_| {
209                            // for DuplicateKey errors, try to find source
210                            if let ValidationError::DuplicateKey(key) = &error {
211                                key_to_source.get(key).cloned().flatten()
212                            } else {
213                                None
214                            }
215                        })
216                    })
217                    .or_else(|| {
218                        // type names contain dots, so match the longest known type that prefixes `field`.
219                        if let Some(field) = error.field() {
220                            known_types
221                                .iter()
222                                .filter(|t| {
223                                    field.strip_prefix(**t).is_some_and(|rest| {
224                                        rest.is_empty() || rest.starts_with('.')
225                                    })
226                                })
227                                .max_by_key(|t| t.len())
228                                .and_then(|t| type_to_source.get(*t).cloned().flatten())
229                        } else {
230                            error
231                                .type_hint()
232                                .and_then(|t| type_to_source.get(&t).cloned().flatten())
233                        }
234                    });
235                LocatedError::with_source(error, source)
236            })
237            .collect()
238    }
239}
240
241/// validate uniqueness and reference integrity for the given inventory.
242pub fn validate_inventory(inventory: &Inventory) -> ValidationReport {
243    let mut report = ValidationReport::default();
244    let mut seen_uids = BTreeSet::new();
245    let mut seen_keys = BTreeSet::new();
246    let mut uid_to_type = BTreeMap::new();
247
248    for object in &inventory.objects {
249        if object.key.is_empty() {
250            report.errors.push(ValidationError::MissingKey);
251        }
252        if object.type_name.is_empty() {
253            report.errors.push(ValidationError::MissingType);
254        }
255        if !seen_uids.insert(object.uid) {
256            report
257                .errors
258                .push(ValidationError::DuplicateUid(object.uid));
259        }
260        let key = format!("{}::{}", object.type_name, key_string(&object.key));
261        if !seen_keys.insert(key.clone()) {
262            report.errors.push(ValidationError::DuplicateKey(key));
263        }
264        uid_to_type.insert(object.uid, object.type_name.clone());
265    }
266
267    validate_schema_ref_targets(&inventory.schema, &mut report);
268    validate_schema_patterns(&inventory.schema, &mut report);
269    validate_schema_constraint_types(&inventory.schema, &mut report);
270    validate_schema_enums(&inventory.schema, &mut report);
271    validate_schema_types(&inventory.schema, &inventory.objects, &mut report);
272    for object in &inventory.objects {
273        validate_object(object, &inventory.schema, &uid_to_type, &mut report);
274    }
275
276    report
277}
278
279/// validate that every `ref`/`list_ref` target declared in the schema names a
280/// declared type.
281///
282/// targets are free-form strings, so a typo (`tenant` for `tenancy.tenant`)
283/// would otherwise pass schema validation and only surface later as misleading
284/// per-object reference errors. this catches the mistake at the schema level,
285/// attributed to the declaring type and field.
286fn validate_schema_ref_targets(schema: &Schema, report: &mut ValidationReport) {
287    for (type_name, type_schema) in &schema.types {
288        for (field, field_schema) in &type_schema.key {
289            validate_field_ref_targets(
290                schema,
291                type_name,
292                &format!("key.{field}"),
293                &field_schema.r#type,
294                report,
295            );
296        }
297        for (field, field_schema) in &type_schema.fields {
298            validate_field_ref_targets(schema, type_name, field, &field_schema.r#type, report);
299        }
300    }
301}
302
303/// recursively check ref targets within a field type, descending into `list`
304/// and `map` item types so refs nested inside them are validated too.
305fn validate_field_ref_targets(
306    schema: &Schema,
307    type_name: &str,
308    field: &str,
309    field_type: &FieldType,
310    report: &mut ValidationReport,
311) {
312    match field_type {
313        FieldType::Ref { target } | FieldType::ListRef { target } => {
314            if !schema.types.contains_key(target) {
315                report.errors.push(ValidationError::UnknownRefTarget {
316                    type_name: type_name.to_string(),
317                    field: field.to_string(),
318                    target: target.to_string(),
319                });
320            }
321        }
322        FieldType::List { item } => {
323            validate_field_ref_targets(schema, type_name, field, item, report);
324        }
325        FieldType::Map { value } => {
326            validate_field_ref_targets(schema, type_name, field, value, report);
327        }
328        FieldType::String
329        | FieldType::Text
330        | FieldType::Int
331        | FieldType::Float
332        | FieldType::Bool
333        | FieldType::Uuid
334        | FieldType::Date
335        | FieldType::Datetime
336        | FieldType::Time
337        | FieldType::Json
338        | FieldType::IpAddress
339        | FieldType::Cidr
340        | FieldType::Prefix
341        | FieldType::Mac
342        | FieldType::Slug
343        | FieldType::Enum { .. } => {}
344    }
345}
346
347/// validate that every `pattern:` regex declared in the schema compiles.
348///
349/// a typo'd pattern would otherwise only surface when some object happens to
350/// use the field (and never at all for a type that has no objects yet), as a
351/// confusing per-object error. this catches the mistake at the schema level,
352/// attributed to the declaring type and field.
353///
354/// `pattern` lives only on the top-level `FieldSchema` of each key/attr field;
355/// it is never nested inside `list`/`map` item types, so a flat iteration over
356/// key and attr fields is complete.
357fn validate_schema_patterns(schema: &Schema, report: &mut ValidationReport) {
358    for (type_name, type_schema) in &schema.types {
359        for (field, field_schema) in &type_schema.key {
360            validate_field_pattern(type_name, &format!("key.{field}"), field_schema, report);
361        }
362        for (field, field_schema) in &type_schema.fields {
363            validate_field_pattern(type_name, field, field_schema, report);
364        }
365    }
366}
367
368/// compile a single field's `pattern:`, recording an error if it is malformed.
369fn validate_field_pattern(
370    type_name: &str,
371    field: &str,
372    field_schema: &crate::ir::FieldSchema,
373    report: &mut ValidationReport,
374) {
375    let Some(pattern) = &field_schema.pattern else {
376        return;
377    };
378    if let Err(err) = Regex::new(pattern) {
379        report.errors.push(ValidationError::InvalidSchemaPattern {
380            type_name: type_name.to_string(),
381            field: field.to_string(),
382            pattern: pattern.to_string(),
383            error: err.to_string(),
384        });
385    }
386}
387
388/// reject a top-level `format:`/`pattern:` on a field whose type can never hold
389/// a string; otherwise it is silently accepted at load and only fails per-object
390/// as a misleading `expected string` error (never at all for an empty type).
391///
392/// `format`/`pattern` live only on the top-level `FieldSchema`, so a flat walk
393/// over key and attr fields is complete.
394fn validate_schema_constraint_types(schema: &Schema, report: &mut ValidationReport) {
395    for (type_name, type_schema) in &schema.types {
396        for (field, field_schema) in &type_schema.key {
397            validate_field_constraint_type(
398                type_name,
399                &format!("key.{field}"),
400                field_schema,
401                report,
402            );
403        }
404        for (field, field_schema) in &type_schema.fields {
405            validate_field_constraint_type(type_name, field, field_schema, report);
406        }
407    }
408}
409
410/// flag each `format:`/`pattern:` present on a never-string field, one error per
411/// constraint.
412fn validate_field_constraint_type(
413    type_name: &str,
414    field: &str,
415    field_schema: &crate::ir::FieldSchema,
416    report: &mut ValidationReport,
417) {
418    if !is_never_string_type(&field_schema.r#type) {
419        return;
420    }
421    let field_type = field_type_label(&field_schema.r#type);
422    for (present, constraint) in [
423        (field_schema.format.is_some(), "format"),
424        (field_schema.pattern.is_some(), "pattern"),
425    ] {
426        if present {
427            report
428                .errors
429                .push(ValidationError::ConstraintOnNonStringField {
430                    type_name: type_name.to_string(),
431                    field: field.to_string(),
432                    constraint: constraint.to_string(),
433                    field_type: field_type.clone(),
434                });
435        }
436    }
437}
438
439/// true when a field's type can never hold a json string, so a `format:` or
440/// `pattern:` on it is meaningless. `ref`/`json` can carry a string; only the
441/// scalar non-string and collection types are rejected.
442fn is_never_string_type(field_type: &FieldType) -> bool {
443    match field_type {
444        FieldType::Int
445        | FieldType::Float
446        | FieldType::Bool
447        | FieldType::List { .. }
448        | FieldType::Map { .. }
449        | FieldType::ListRef { .. } => true,
450        FieldType::String
451        | FieldType::Text
452        | FieldType::Date
453        | FieldType::Datetime
454        | FieldType::Time
455        | FieldType::IpAddress
456        | FieldType::Uuid
457        | FieldType::Cidr
458        | FieldType::Prefix
459        | FieldType::Mac
460        | FieldType::Slug
461        | FieldType::Enum { .. }
462        | FieldType::Ref { .. }
463        | FieldType::Json => false,
464    }
465}
466
467/// reject an `enum` field declared with an empty `values` list. an empty enum is
468/// unsatisfiable, so every value fails per-object with a confusing
469/// `expected: enum()` message (and never at all for a type with no objects). this
470/// catches the mistake at the schema level, attributed to the declaring type and
471/// field.
472///
473/// `list`/`map` item types can nest an enum, and per-object validation recurses
474/// into them keeping the same field label, so this walk recurses the same way.
475fn validate_schema_enums(schema: &Schema, report: &mut ValidationReport) {
476    for (type_name, type_schema) in &schema.types {
477        for (field, field_schema) in &type_schema.key {
478            validate_field_enum(
479                type_name,
480                &format!("key.{field}"),
481                &field_schema.r#type,
482                report,
483            );
484        }
485        for (field, field_schema) in &type_schema.fields {
486            validate_field_enum(type_name, field, &field_schema.r#type, report);
487        }
488    }
489}
490
491/// recursively check for an empty-values enum within a field type, descending
492/// into `list` and `map` item types so enums nested inside them are caught too.
493fn validate_field_enum(
494    type_name: &str,
495    field: &str,
496    field_type: &FieldType,
497    report: &mut ValidationReport,
498) {
499    match field_type {
500        FieldType::Enum { values } => {
501            if values.is_empty() {
502                report.errors.push(ValidationError::EmptyEnum {
503                    type_name: type_name.to_string(),
504                    field: field.to_string(),
505                });
506            }
507        }
508        FieldType::List { item } => {
509            validate_field_enum(type_name, field, item, report);
510        }
511        FieldType::Map { value } => {
512            validate_field_enum(type_name, field, value, report);
513        }
514        FieldType::String
515        | FieldType::Text
516        | FieldType::Int
517        | FieldType::Float
518        | FieldType::Bool
519        | FieldType::Uuid
520        | FieldType::Date
521        | FieldType::Datetime
522        | FieldType::Time
523        | FieldType::Json
524        | FieldType::IpAddress
525        | FieldType::Cidr
526        | FieldType::Prefix
527        | FieldType::Mac
528        | FieldType::Slug
529        | FieldType::Ref { .. }
530        | FieldType::ListRef { .. } => {}
531    }
532}
533
534fn validate_schema_types(schema: &Schema, objects: &[Object], report: &mut ValidationReport) {
535    for object in objects {
536        if !schema.types.contains_key(object.type_name.as_str()) {
537            report
538                .errors
539                .push(ValidationError::UnknownType(object.type_name.to_string()));
540        }
541    }
542}
543
544fn validate_object(
545    object: &Object,
546    schema: &Schema,
547    uid_to_type: &BTreeMap<Uid, TypeName>,
548    report: &mut ValidationReport,
549) {
550    let Some(type_schema) = schema.types.get(object.type_name.as_str()) else {
551        return;
552    };
553
554    validate_key_fields(object, type_schema, uid_to_type, report);
555    validate_attr_fields(object, type_schema, uid_to_type, report);
556}
557
558fn validate_key_fields(
559    object: &Object,
560    type_schema: &crate::ir::TypeSchema,
561    uid_to_type: &BTreeMap<Uid, TypeName>,
562    report: &mut ValidationReport,
563) {
564    for (field, field_schema) in &type_schema.key {
565        let Some(value) = object.key.get(field) else {
566            report.errors.push(ValidationError::MissingKeyField {
567                type_name: object.type_name.to_string(),
568                field: field.to_string(),
569            });
570            continue;
571        };
572        validate_field_value(
573            &object.type_name,
574            &format!("key.{field}"),
575            field_schema,
576            value,
577            uid_to_type,
578            report,
579        );
580    }
581
582    for field in object.key.keys() {
583        if !type_schema.key.contains_key(field) {
584            report.errors.push(ValidationError::ExtraKeyField {
585                type_name: object.type_name.to_string(),
586                field: field.to_string(),
587            });
588        }
589    }
590}
591
592fn validate_attr_fields(
593    object: &Object,
594    type_schema: &crate::ir::TypeSchema,
595    uid_to_type: &BTreeMap<Uid, TypeName>,
596    report: &mut ValidationReport,
597) {
598    for (field, field_schema) in &type_schema.fields {
599        let Some(value) = object.attrs.get(field) else {
600            if field_schema.required {
601                report.errors.push(ValidationError::MissingAttrField {
602                    type_name: object.type_name.to_string(),
603                    field: field.to_string(),
604                });
605            }
606            continue;
607        };
608        validate_field_value(
609            &object.type_name,
610            field,
611            field_schema,
612            value,
613            uid_to_type,
614            report,
615        );
616    }
617
618    for field in object.attrs.keys() {
619        if !type_schema.fields.contains_key(field) {
620            report.errors.push(ValidationError::ExtraAttrField {
621                type_name: object.type_name.to_string(),
622                field: field.to_string(),
623            });
624        }
625    }
626}
627
628fn validate_field_value(
629    type_name: &TypeName,
630    field: &str,
631    field_schema: &crate::ir::FieldSchema,
632    value: &Value,
633    uid_to_type: &BTreeMap<Uid, TypeName>,
634    report: &mut ValidationReport,
635) {
636    if value.is_null() {
637        if field_schema.nullable {
638            return;
639        }
640        report.errors.push(ValidationError::InvalidValue {
641            field: format!("{type_name}.{field}"),
642            expected: field_type_label(&field_schema.r#type),
643            actual: "null".to_string(),
644        });
645        return;
646    }
647
648    match &field_schema.r#type {
649        FieldType::Ref { target } => {
650            validate_ref(type_name, field, target, value, uid_to_type, report);
651        }
652        FieldType::ListRef { target } => {
653            if let Some(entries) = value.as_array() {
654                for entry in entries {
655                    validate_ref(type_name, field, target, entry, uid_to_type, report);
656                }
657            } else {
658                report.errors.push(ValidationError::InvalidValue {
659                    field: format!("{type_name}.{field}"),
660                    expected: "list_ref".to_string(),
661                    actual: value_type_label(value),
662                });
663            }
664        }
665        FieldType::List { item } => {
666            if let Some(entries) = value.as_array() {
667                for entry in entries {
668                    let schema = crate::ir::FieldSchema {
669                        r#type: (**item).clone(),
670                        required: true,
671                        nullable: false,
672                        description: None,
673                        format: None,
674                        pattern: None,
675                    };
676                    validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
677                }
678            } else {
679                report.errors.push(ValidationError::InvalidValue {
680                    field: format!("{type_name}.{field}"),
681                    expected: "list".to_string(),
682                    actual: value_type_label(value),
683                });
684            }
685        }
686        FieldType::Map { value: inner } => {
687            if let Some(entries) = value.as_object() {
688                for entry in entries.values() {
689                    let schema = crate::ir::FieldSchema {
690                        r#type: (**inner).clone(),
691                        required: true,
692                        nullable: false,
693                        description: None,
694                        format: None,
695                        pattern: None,
696                    };
697                    validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
698                }
699            } else {
700                report.errors.push(ValidationError::InvalidValue {
701                    field: format!("{type_name}.{field}"),
702                    expected: "map".to_string(),
703                    actual: value_type_label(value),
704                });
705            }
706        }
707        FieldType::Enum { values } => {
708            if let Some(raw) = value.as_str() {
709                if !values.contains(&raw.to_string()) {
710                    report.errors.push(ValidationError::InvalidValue {
711                        field: format!("{type_name}.{field}"),
712                        expected: format!("enum({})", values.join("|")),
713                        actual: raw.to_string(),
714                    });
715                }
716            } else {
717                report.errors.push(ValidationError::InvalidValue {
718                    field: format!("{type_name}.{field}"),
719                    expected: "enum".to_string(),
720                    actual: value_type_label(value),
721                });
722            }
723        }
724        _ => {
725            if !value_matches_type(value, &field_schema.r#type) {
726                report.errors.push(ValidationError::InvalidValue {
727                    field: format!("{type_name}.{field}"),
728                    expected: field_type_label(&field_schema.r#type),
729                    actual: value_type_label(value),
730                });
731            }
732        }
733    }
734
735    validate_string_constraints(type_name, field, field_schema, value, report);
736}
737
738fn parse_uid(value: &Value) -> Option<Uid> {
739    let raw = value.as_str()?;
740    Uid::parse_str(raw).ok()
741}
742
743fn validate_ref(
744    type_name: &TypeName,
745    field: &str,
746    target: &str,
747    value: &Value,
748    uid_to_type: &BTreeMap<Uid, TypeName>,
749    report: &mut ValidationReport,
750) {
751    let Some(uid) = parse_uid(value) else {
752        report.errors.push(ValidationError::InvalidValue {
753            field: format!("{type_name}.{field}"),
754            expected: "uuid".to_string(),
755            actual: value_type_label(value),
756        });
757        return;
758    };
759    let Some(actual) = uid_to_type.get(&uid) else {
760        report.errors.push(ValidationError::MissingReference {
761            field: format!("{type_name}.{field}"),
762            target: uid,
763        });
764        return;
765    };
766    if actual.as_str() != target {
767        report.errors.push(ValidationError::ReferenceTypeMismatch {
768            field: format!("{type_name}.{field}"),
769            target: uid,
770            expected: target.to_string(),
771            actual: actual.to_string(),
772        });
773    }
774}
775
776fn validate_string_constraints(
777    type_name: &TypeName,
778    field: &str,
779    field_schema: &crate::ir::FieldSchema,
780    value: &Value,
781    report: &mut ValidationReport,
782) {
783    if field_schema.format.is_none() && field_schema.pattern.is_none() {
784        return;
785    }
786
787    let Some(raw) = value.as_str() else {
788        report.errors.push(ValidationError::InvalidValue {
789            field: format!("{type_name}.{field}"),
790            expected: "string".to_string(),
791            actual: value_type_label(value),
792        });
793        return;
794    };
795
796    if let Some(format) = &field_schema.format {
797        if !matches_format(format, raw) {
798            report.errors.push(ValidationError::InvalidValue {
799                field: format!("{type_name}.{field}"),
800                expected: format_label(format),
801                actual: raw.to_string(),
802            });
803        }
804    }
805
806    if let Some(pattern) = &field_schema.pattern {
807        match Regex::new(pattern) {
808            Ok(regex) => {
809                if !regex.is_match(raw) {
810                    report.errors.push(ValidationError::InvalidValue {
811                        field: format!("{type_name}.{field}"),
812                        expected: format!("pattern({pattern})"),
813                        actual: raw.to_string(),
814                    });
815                }
816            }
817            Err(err) => {
818                report.errors.push(ValidationError::InvalidValue {
819                    field: format!("{type_name}.{field}"),
820                    expected: format!("pattern({pattern})"),
821                    actual: format!("invalid pattern: {err}"),
822                });
823            }
824        }
825    }
826}
827
828fn slug_regex() -> &'static Regex {
829    static RE: OnceLock<Regex> = OnceLock::new();
830    RE.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$").unwrap())
831}
832
833fn mac_regex() -> &'static Regex {
834    static RE: OnceLock<Regex> = OnceLock::new();
835    RE.get_or_init(|| Regex::new(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$").unwrap())
836}
837
838fn matches_format(format: &FieldFormat, raw: &str) -> bool {
839    match format {
840        FieldFormat::Slug => slug_regex().is_match(raw),
841        FieldFormat::IpAddress => raw.parse::<IpAddr>().is_ok(),
842        FieldFormat::Cidr | FieldFormat::Prefix => raw.parse::<IpNet>().is_ok(),
843        FieldFormat::Mac => mac_regex().is_match(raw),
844        FieldFormat::Uuid => Uid::parse_str(raw).is_ok(),
845    }
846}
847
848fn format_label(format: &FieldFormat) -> String {
849    match format {
850        FieldFormat::Slug => "format(slug)".to_string(),
851        FieldFormat::IpAddress => "format(ip_address)".to_string(),
852        FieldFormat::Cidr => "format(cidr)".to_string(),
853        FieldFormat::Prefix => "format(prefix)".to_string(),
854        FieldFormat::Mac => "format(mac)".to_string(),
855        FieldFormat::Uuid => "format(uuid)".to_string(),
856    }
857}
858
859fn value_matches_type(value: &Value, field_type: &FieldType) -> bool {
860    match field_type {
861        FieldType::String
862        | FieldType::Text
863        | FieldType::Date
864        | FieldType::Datetime
865        | FieldType::Time
866        // `ip_address` stays a plain string check: the canonical IPAM examples
867        // carry NetBox-style masked addresses (`10.0.0.10/24`) that the strict
868        // `IpAddr` format rejects, so whether it should accept a mask is a
869        // convention decision left to the maintainer rather than guessed here.
870        | FieldType::IpAddress => value.is_string(),
871        // format-typed fields with an unambiguous textual format must hold a
872        // string that matches it, mirroring how the `format:` constraint validates.
873        FieldType::Uuid => value_matches_format(value, &FieldFormat::Uuid),
874        FieldType::Cidr => value_matches_format(value, &FieldFormat::Cidr),
875        FieldType::Prefix => value_matches_format(value, &FieldFormat::Prefix),
876        FieldType::Mac => value_matches_format(value, &FieldFormat::Mac),
877        FieldType::Slug => value_matches_format(value, &FieldFormat::Slug),
878        FieldType::Int => value.is_i64() || value.is_u64(),
879        FieldType::Float => value.as_f64().is_some() || value.is_i64() || value.is_u64(),
880        FieldType::Bool => value.is_boolean(),
881        FieldType::Json => true,
882        FieldType::Enum { .. } => value.is_string(),
883        FieldType::List { .. } => value.is_array(),
884        FieldType::Map { .. } => value.is_object(),
885        FieldType::Ref { .. } | FieldType::ListRef { .. } => true,
886    }
887}
888
889/// a value satisfies a format-typed field when it is a string matching that format.
890fn value_matches_format(value: &Value, format: &FieldFormat) -> bool {
891    value
892        .as_str()
893        .map(|raw| matches_format(format, raw))
894        .unwrap_or(false)
895}
896
897fn field_type_label(field_type: &FieldType) -> String {
898    match field_type {
899        FieldType::String => "string".to_string(),
900        FieldType::Text => "text".to_string(),
901        FieldType::Int => "int".to_string(),
902        FieldType::Float => "float".to_string(),
903        FieldType::Bool => "bool".to_string(),
904        FieldType::Uuid => "uuid".to_string(),
905        FieldType::Date => "date".to_string(),
906        FieldType::Datetime => "datetime".to_string(),
907        FieldType::Time => "time".to_string(),
908        FieldType::Json => "json".to_string(),
909        FieldType::IpAddress => "ip_address".to_string(),
910        FieldType::Cidr => "cidr".to_string(),
911        FieldType::Prefix => "prefix".to_string(),
912        FieldType::Mac => "mac".to_string(),
913        FieldType::Slug => "slug".to_string(),
914        FieldType::Enum { .. } => "enum".to_string(),
915        FieldType::List { .. } => "list".to_string(),
916        FieldType::Map { .. } => "map".to_string(),
917        FieldType::Ref { target } => format!("ref({target})"),
918        FieldType::ListRef { target } => format!("list_ref({target})"),
919    }
920}
921
922fn value_type_label(value: &Value) -> String {
923    match value {
924        Value::Null => "null".to_string(),
925        Value::Bool(_) => "bool".to_string(),
926        Value::Number(_) => "number".to_string(),
927        Value::String(_) => "string".to_string(),
928        Value::Array(_) => "array".to_string(),
929        Value::Object(_) => "object".to_string(),
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use crate::ir::{
937        FieldFormat, FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema,
938    };
939    use serde_json::json;
940    use std::collections::BTreeMap;
941    use uuid::Uuid;
942
943    fn uid(value: u128) -> Uid {
944        Uuid::from_u128(value)
945    }
946
947    #[test]
948    fn detects_duplicate_keys() {
949        let mut key = BTreeMap::new();
950        key.insert("slug".to_string(), serde_json::json!("fra1"));
951        let key = Key::from(key);
952        let type_schema = TypeSchema {
953            key: BTreeMap::from([(
954                "slug".to_string(),
955                FieldSchema {
956                    r#type: FieldType::Slug,
957                    required: true,
958                    nullable: false,
959                    description: None,
960                    format: None,
961                    pattern: None,
962                },
963            )]),
964            fields: BTreeMap::new(),
965        };
966        let objects = vec![
967            Object::new(
968                uid(1),
969                TypeName::new("site"),
970                key.clone(),
971                JsonMap::default(),
972            )
973            .unwrap(),
974            Object::new(uid(2), TypeName::new("site"), key, JsonMap::default()).unwrap(),
975        ];
976        let report = validate_inventory(&Inventory {
977            schema: Schema {
978                types: BTreeMap::from([("site".to_string(), type_schema)]),
979            },
980            objects,
981        });
982        assert!(report
983            .errors
984            .iter()
985            .any(|err| matches!(err, ValidationError::DuplicateKey(_))));
986    }
987
988    #[test]
989    fn detects_missing_key() {
990        let objects = vec![Object {
991            uid: uid(30),
992            type_name: TypeName::new("site"),
993            key: Key::default(),
994            attrs: JsonMap::default(),
995            source: None,
996        }];
997        let report = validate_inventory(&Inventory {
998            schema: Schema {
999                types: BTreeMap::from([(
1000                    "site".to_string(),
1001                    TypeSchema {
1002                        key: BTreeMap::new(),
1003                        fields: BTreeMap::new(),
1004                    },
1005                )]),
1006            },
1007            objects,
1008        });
1009        assert!(report
1010            .errors
1011            .iter()
1012            .any(|err| matches!(err, ValidationError::MissingKey)));
1013    }
1014
1015    #[test]
1016    fn detects_missing_kind() {
1017        let mut key = BTreeMap::new();
1018        key.insert("slug".to_string(), serde_json::json!("fra1"));
1019        let objects = vec![Object {
1020            uid: uid(31),
1021            type_name: TypeName::new(""),
1022            key: Key::from(key),
1023            attrs: JsonMap::default(),
1024            source: None,
1025        }];
1026        let report = validate_inventory(&Inventory {
1027            schema: Schema {
1028                types: BTreeMap::new(),
1029            },
1030            objects,
1031        });
1032        assert!(report
1033            .errors
1034            .iter()
1035            .any(|err| matches!(err, ValidationError::MissingType)));
1036    }
1037
1038    #[test]
1039    fn detects_unknown_type() {
1040        let mut key = BTreeMap::new();
1041        key.insert("slug".to_string(), serde_json::json!("leaf01"));
1042        let objects = vec![Object::new(
1043            uid(40),
1044            TypeName::new("device"),
1045            Key::from(key),
1046            JsonMap::default(),
1047        )
1048        .unwrap()];
1049        let schema = Schema {
1050            types: BTreeMap::new(),
1051        };
1052        let report = validate_inventory(&Inventory { schema, objects });
1053        assert!(report
1054            .errors
1055            .iter()
1056            .any(|err| matches!(err, ValidationError::UnknownType(_))));
1057    }
1058
1059    #[test]
1060    fn detects_missing_references_with_schema() {
1061        let mut key_fields = BTreeMap::new();
1062        key_fields.insert(
1063            "slug".to_string(),
1064            FieldSchema {
1065                r#type: FieldType::Slug,
1066                required: true,
1067                nullable: false,
1068                description: None,
1069                format: None,
1070                pattern: None,
1071            },
1072        );
1073        let mut fields = BTreeMap::new();
1074        fields.insert(
1075            "owner".to_string(),
1076            FieldSchema {
1077                r#type: FieldType::Ref {
1078                    target: "person".to_string(),
1079                },
1080                required: false,
1081                nullable: false,
1082                description: None,
1083                format: None,
1084                pattern: None,
1085            },
1086        );
1087        let mut types = BTreeMap::new();
1088        types.insert(
1089            "device".to_string(),
1090            TypeSchema {
1091                key: key_fields,
1092                fields,
1093            },
1094        );
1095        let schema = Schema { types };
1096
1097        let mut attrs = BTreeMap::new();
1098        attrs.insert(
1099            "owner".to_string(),
1100            serde_json::json!(Uuid::from_u128(99).to_string()),
1101        );
1102        let mut key = BTreeMap::new();
1103        key.insert("slug".to_string(), serde_json::json!("leaf01"));
1104        let objects = vec![Object::new(
1105            uid(41),
1106            TypeName::new("device"),
1107            Key::from(key),
1108            attrs.into(),
1109        )
1110        .unwrap()];
1111        let report = validate_inventory(&Inventory { schema, objects });
1112        assert!(report
1113            .errors
1114            .iter()
1115            .any(|err| matches!(err, ValidationError::MissingReference { .. })));
1116    }
1117
1118    /// build a non-required field of the given type, for schema-shape tests.
1119    fn schema_field(r#type: FieldType) -> FieldSchema {
1120        FieldSchema {
1121            r#type,
1122            required: false,
1123            nullable: false,
1124            description: None,
1125            format: None,
1126            pattern: None,
1127        }
1128    }
1129
1130    /// run schema-only validation (no objects) and return the report.
1131    fn validate_schema(types: BTreeMap<String, TypeSchema>) -> ValidationReport {
1132        validate_inventory(&Inventory {
1133            schema: Schema { types },
1134            objects: vec![],
1135        })
1136    }
1137
1138    #[test]
1139    fn detects_unknown_ref_target_in_attr_field() {
1140        let device = TypeSchema {
1141            key: BTreeMap::new(),
1142            fields: BTreeMap::from([(
1143                "owner".to_string(),
1144                schema_field(FieldType::Ref {
1145                    target: "person".to_string(),
1146                }),
1147            )]),
1148        };
1149        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1150        assert!(report.errors.iter().any(|e| matches!(
1151            e,
1152            ValidationError::UnknownRefTarget { type_name, field, target }
1153                if type_name == "device" && field == "owner" && target == "person"
1154        )));
1155    }
1156
1157    #[test]
1158    fn detects_unknown_ref_target_in_key_field() {
1159        let device = TypeSchema {
1160            key: BTreeMap::from([(
1161                "site".to_string(),
1162                schema_field(FieldType::Ref {
1163                    target: "place".to_string(),
1164                }),
1165            )]),
1166            fields: BTreeMap::new(),
1167        };
1168        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1169        assert!(report.errors.iter().any(|e| matches!(
1170            e,
1171            ValidationError::UnknownRefTarget { field, target, .. }
1172                if field == "key.site" && target == "place"
1173        )));
1174    }
1175
1176    #[test]
1177    fn detects_unknown_ref_target_nested_in_list_and_map() {
1178        let group = TypeSchema {
1179            key: BTreeMap::new(),
1180            fields: BTreeMap::from([
1181                (
1182                    "members".to_string(),
1183                    schema_field(FieldType::List {
1184                        item: Box::new(FieldType::Ref {
1185                            target: "ghost".to_string(),
1186                        }),
1187                    }),
1188                ),
1189                (
1190                    "roles".to_string(),
1191                    schema_field(FieldType::Map {
1192                        value: Box::new(FieldType::ListRef {
1193                            target: "phantom".to_string(),
1194                        }),
1195                    }),
1196                ),
1197            ]),
1198        };
1199        let report = validate_schema(BTreeMap::from([("group".to_string(), group)]));
1200        assert!(report.errors.iter().any(|e| matches!(
1201            e,
1202            ValidationError::UnknownRefTarget { target, .. } if target == "ghost"
1203        )));
1204        assert!(report.errors.iter().any(|e| matches!(
1205            e,
1206            ValidationError::UnknownRefTarget { target, .. } if target == "phantom"
1207        )));
1208    }
1209
1210    #[test]
1211    fn valid_ref_targets_pass_schema_validation() {
1212        let device = TypeSchema {
1213            key: BTreeMap::new(),
1214            fields: BTreeMap::from([
1215                (
1216                    "owner".to_string(),
1217                    schema_field(FieldType::Ref {
1218                        target: "person".to_string(),
1219                    }),
1220                ),
1221                (
1222                    "watchers".to_string(),
1223                    schema_field(FieldType::List {
1224                        item: Box::new(FieldType::ListRef {
1225                            target: "person".to_string(),
1226                        }),
1227                    }),
1228                ),
1229            ]),
1230        };
1231        let person = TypeSchema {
1232            key: BTreeMap::new(),
1233            fields: BTreeMap::new(),
1234        };
1235        let report = validate_schema(BTreeMap::from([
1236            ("device".to_string(), device),
1237            ("person".to_string(), person),
1238        ]));
1239        assert!(!report
1240            .errors
1241            .iter()
1242            .any(|e| matches!(e, ValidationError::UnknownRefTarget { .. })));
1243    }
1244
1245    #[test]
1246    fn detects_invalid_pattern_in_attr_field() {
1247        let device = TypeSchema {
1248            key: BTreeMap::new(),
1249            fields: BTreeMap::from([("name".to_string(), pattern_field("[unclosed"))]),
1250        };
1251        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1252        let count = report
1253            .errors
1254            .iter()
1255            .filter(|e| {
1256                matches!(
1257                    e,
1258                    ValidationError::InvalidSchemaPattern { type_name, field, .. }
1259                        if type_name == "device" && field == "name"
1260                )
1261            })
1262            .count();
1263        assert_eq!(count, 1);
1264    }
1265
1266    #[test]
1267    fn detects_invalid_pattern_in_key_field() {
1268        let device = TypeSchema {
1269            key: BTreeMap::from([("slug".to_string(), pattern_field("(unbalanced"))]),
1270            fields: BTreeMap::new(),
1271        };
1272        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1273        assert!(report.errors.iter().any(|e| matches!(
1274            e,
1275            ValidationError::InvalidSchemaPattern { type_name, field, .. }
1276                if type_name == "device" && field == "key.slug"
1277        )));
1278    }
1279
1280    #[test]
1281    fn detects_invalid_pattern_for_type_with_no_objects() {
1282        // the headline win: a bad pattern on a type with NO objects is never
1283        // reached by per-object validation, but schema-load validation catches it.
1284        let ghost = TypeSchema {
1285            key: BTreeMap::new(),
1286            fields: BTreeMap::from([("name".to_string(), pattern_field("[bad"))]),
1287        };
1288        let report = validate_inventory(&Inventory {
1289            schema: Schema {
1290                types: BTreeMap::from([("ghost".to_string(), ghost)]),
1291            },
1292            objects: vec![],
1293        });
1294        assert!(report.errors.iter().any(|e| matches!(
1295            e,
1296            ValidationError::InvalidSchemaPattern { type_name, field, .. }
1297                if type_name == "ghost" && field == "name"
1298        )));
1299    }
1300
1301    #[test]
1302    fn accumulates_invalid_patterns_across_fields_and_types() {
1303        let device = TypeSchema {
1304            key: BTreeMap::from([("slug".to_string(), pattern_field("(bad"))]),
1305            fields: BTreeMap::from([("name".to_string(), pattern_field("[bad"))]),
1306        };
1307        let site = TypeSchema {
1308            key: BTreeMap::new(),
1309            fields: BTreeMap::from([("code".to_string(), pattern_field("*bad"))]),
1310        };
1311        let report = validate_schema(BTreeMap::from([
1312            ("device".to_string(), device),
1313            ("site".to_string(), site),
1314        ]));
1315        let count = report
1316            .errors
1317            .iter()
1318            .filter(|e| matches!(e, ValidationError::InvalidSchemaPattern { .. }))
1319            .count();
1320        assert_eq!(count, 3);
1321    }
1322
1323    #[test]
1324    fn valid_and_absent_patterns_pass_schema_validation() {
1325        let device = TypeSchema {
1326            key: BTreeMap::from([("slug".to_string(), pattern_field("^[a-z0-9-]+$"))]),
1327            fields: BTreeMap::from([
1328                ("name".to_string(), pattern_field("^[A-Za-z ]+$")),
1329                ("count".to_string(), schema_field(FieldType::Int)),
1330            ]),
1331        };
1332        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1333        assert!(!report
1334            .errors
1335            .iter()
1336            .any(|e| matches!(e, ValidationError::InvalidSchemaPattern { .. })));
1337    }
1338
1339    #[test]
1340    fn detects_format_on_int_field() {
1341        let mut count = schema_field(FieldType::Int);
1342        count.format = Some(FieldFormat::Slug);
1343        let device = TypeSchema {
1344            key: BTreeMap::new(),
1345            fields: BTreeMap::from([("count".to_string(), count)]),
1346        };
1347        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1348        assert!(report.errors.iter().any(|e| matches!(
1349            e,
1350            ValidationError::ConstraintOnNonStringField { type_name, field, constraint, field_type }
1351                if type_name == "device"
1352                    && field == "count"
1353                    && constraint == "format"
1354                    && field_type == "int"
1355        )));
1356    }
1357
1358    #[test]
1359    fn detects_pattern_on_list_field() {
1360        let mut tags = schema_field(FieldType::List {
1361            item: Box::new(FieldType::String),
1362        });
1363        tags.pattern = Some("^x$".to_string());
1364        let device = TypeSchema {
1365            key: BTreeMap::new(),
1366            fields: BTreeMap::from([("tags".to_string(), tags)]),
1367        };
1368        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1369        assert!(report.errors.iter().any(|e| matches!(
1370            e,
1371            ValidationError::ConstraintOnNonStringField { type_name, field, constraint, field_type }
1372                if type_name == "device"
1373                    && field == "tags"
1374                    && constraint == "pattern"
1375                    && field_type == "list"
1376        )));
1377    }
1378
1379    #[test]
1380    fn format_and_pattern_on_non_string_field_yield_two_errors() {
1381        let mut flag = schema_field(FieldType::Bool);
1382        flag.format = Some(FieldFormat::Slug);
1383        flag.pattern = Some("^x$".to_string());
1384        let device = TypeSchema {
1385            key: BTreeMap::new(),
1386            fields: BTreeMap::from([("flag".to_string(), flag)]),
1387        };
1388        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1389        let count = report
1390            .errors
1391            .iter()
1392            .filter(|e| matches!(e, ValidationError::ConstraintOnNonStringField { .. }))
1393            .count();
1394        assert_eq!(count, 2);
1395    }
1396
1397    #[test]
1398    fn detects_constraint_on_non_string_field_for_type_with_no_objects() {
1399        // the headline win: a constraint on a never-string field of a type with
1400        // NO objects is never reached by per-object validation, but schema-load
1401        // validation catches it.
1402        let mut count = schema_field(FieldType::Int);
1403        count.pattern = Some("^[0-9]+$".to_string());
1404        let ghost = TypeSchema {
1405            key: BTreeMap::new(),
1406            fields: BTreeMap::from([("count".to_string(), count)]),
1407        };
1408        let report = validate_inventory(&Inventory {
1409            schema: Schema {
1410                types: BTreeMap::from([("ghost".to_string(), ghost)]),
1411            },
1412            objects: vec![],
1413        });
1414        assert!(report.errors.iter().any(|e| matches!(
1415            e,
1416            ValidationError::ConstraintOnNonStringField { type_name, field, .. }
1417                if type_name == "ghost" && field == "count"
1418        )));
1419    }
1420
1421    #[test]
1422    fn string_valued_fields_accept_format_and_pattern() {
1423        // string, slug, enum, and ref can all hold a string, so a format/pattern
1424        // on them is not a malformed schema.
1425        let mut name = schema_field(FieldType::String);
1426        name.pattern = Some("^[a-z]+$".to_string());
1427        let mut handle = schema_field(FieldType::Slug);
1428        handle.format = Some(FieldFormat::Slug);
1429        let mut role = schema_field(FieldType::Enum {
1430            values: vec!["leaf".to_string()],
1431        });
1432        role.pattern = Some("^[a-z]+$".to_string());
1433        let mut owner = schema_field(FieldType::Ref {
1434            target: "person".to_string(),
1435        });
1436        owner.format = Some(FieldFormat::Uuid);
1437        let device = TypeSchema {
1438            key: BTreeMap::new(),
1439            fields: BTreeMap::from([
1440                ("name".to_string(), name),
1441                ("handle".to_string(), handle),
1442                ("role".to_string(), role),
1443                ("owner".to_string(), owner),
1444            ]),
1445        };
1446        let person = TypeSchema {
1447            key: BTreeMap::new(),
1448            fields: BTreeMap::new(),
1449        };
1450        let report = validate_schema(BTreeMap::from([
1451            ("device".to_string(), device),
1452            ("person".to_string(), person),
1453        ]));
1454        assert!(!report
1455            .errors
1456            .iter()
1457            .any(|e| matches!(e, ValidationError::ConstraintOnNonStringField { .. })));
1458    }
1459
1460    #[test]
1461    fn detects_empty_enum_in_attr_field() {
1462        let device = TypeSchema {
1463            key: BTreeMap::new(),
1464            fields: BTreeMap::from([(
1465                "role".to_string(),
1466                schema_field(FieldType::Enum { values: vec![] }),
1467            )]),
1468        };
1469        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1470        assert!(report.errors.iter().any(|e| matches!(
1471            e,
1472            ValidationError::EmptyEnum { type_name, field }
1473                if type_name == "device" && field == "role"
1474        )));
1475    }
1476
1477    #[test]
1478    fn detects_empty_enum_nested_in_list_field() {
1479        let device = TypeSchema {
1480            key: BTreeMap::new(),
1481            fields: BTreeMap::from([(
1482                "roles".to_string(),
1483                schema_field(FieldType::List {
1484                    item: Box::new(FieldType::Enum { values: vec![] }),
1485                }),
1486            )]),
1487        };
1488        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1489        assert!(report.errors.iter().any(|e| matches!(
1490            e,
1491            ValidationError::EmptyEnum { type_name, field }
1492                if type_name == "device" && field == "roles"
1493        )));
1494    }
1495
1496    #[test]
1497    fn non_empty_enum_passes_schema_validation() {
1498        let device = TypeSchema {
1499            key: BTreeMap::new(),
1500            fields: BTreeMap::from([
1501                (
1502                    "role".to_string(),
1503                    schema_field(FieldType::Enum {
1504                        values: vec!["leaf".to_string(), "spine".to_string()],
1505                    }),
1506                ),
1507                (
1508                    "roles".to_string(),
1509                    schema_field(FieldType::List {
1510                        item: Box::new(FieldType::Enum {
1511                            values: vec!["a".to_string()],
1512                        }),
1513                    }),
1514                ),
1515            ]),
1516        };
1517        let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
1518        assert!(!report
1519            .errors
1520            .iter()
1521            .any(|e| matches!(e, ValidationError::EmptyEnum { .. })));
1522    }
1523
1524    #[test]
1525    fn detects_empty_enum_for_type_with_no_objects() {
1526        // the headline win: an empty enum on a type with NO objects is never
1527        // reached by per-object validation, but schema-load validation catches
1528        // it. the key field also exercises the `key.{field}` label path.
1529        let ghost = TypeSchema {
1530            key: BTreeMap::from([(
1531                "role".to_string(),
1532                schema_field(FieldType::Enum { values: vec![] }),
1533            )]),
1534            fields: BTreeMap::new(),
1535        };
1536        let report = validate_inventory(&Inventory {
1537            schema: Schema {
1538                types: BTreeMap::from([("ghost".to_string(), ghost)]),
1539            },
1540            objects: vec![],
1541        });
1542        assert!(report.errors.iter().any(|e| matches!(
1543            e,
1544            ValidationError::EmptyEnum { type_name, field }
1545                if type_name == "ghost" && field == "key.role"
1546        )));
1547    }
1548
1549    #[test]
1550    fn accumulates_errors_for_multiple_invalid_fields() {
1551        let field = |r#type| FieldSchema {
1552            r#type,
1553            required: true,
1554            nullable: false,
1555            description: None,
1556            format: None,
1557            pattern: None,
1558        };
1559        let type_schema = TypeSchema {
1560            key: BTreeMap::from([("slug".to_string(), field(FieldType::Slug))]),
1561            fields: BTreeMap::from([
1562                ("count".to_string(), field(FieldType::Int)),
1563                ("enabled".to_string(), field(FieldType::Bool)),
1564            ]),
1565        };
1566        let mut key = BTreeMap::new();
1567        key.insert("slug".to_string(), json!("leaf01"));
1568        let mut attrs = BTreeMap::new();
1569        attrs.insert("count".to_string(), json!("not-an-int"));
1570        attrs.insert("enabled".to_string(), json!("not-a-bool"));
1571        let objects = vec![Object::new(
1572            uid(1),
1573            TypeName::new("device"),
1574            Key::from(key),
1575            attrs.into(),
1576        )
1577        .unwrap()];
1578        let report = validate_inventory(&Inventory {
1579            schema: Schema {
1580                types: BTreeMap::from([("device".to_string(), type_schema)]),
1581            },
1582            objects,
1583        });
1584        let invalid = report
1585            .errors
1586            .iter()
1587            .filter(|e| matches!(e, ValidationError::InvalidValue { .. }))
1588            .count();
1589        assert_eq!(invalid, 2);
1590    }
1591
1592    #[test]
1593    fn with_sources_attaches_location_for_dotted_type() {
1594        let mut key = BTreeMap::new();
1595        key.insert("slug".to_string(), serde_json::json!("fra1"));
1596        let object = Object::new(
1597            uid(50),
1598            TypeName::new("dcim.site"),
1599            Key::from(key),
1600            JsonMap::default(),
1601        )
1602        .unwrap()
1603        .with_source(SourceLocation::file_line("inventory.yaml", 42));
1604
1605        let report = ValidationReport {
1606            errors: vec![ValidationError::InvalidValue {
1607                field: "dcim.site.key.slug".to_string(),
1608                expected: "slug".to_string(),
1609                actual: "FRA1".to_string(),
1610            }],
1611        };
1612
1613        let located = report.with_sources(&[object]);
1614        assert_eq!(located.len(), 1);
1615        assert_eq!(
1616            located[0].source,
1617            Some(SourceLocation::file_line("inventory.yaml", 42))
1618        );
1619    }
1620
1621    #[test]
1622    fn with_sources_attributes_ref_mismatch_to_referencing_object() {
1623        let mut dkey = BTreeMap::new();
1624        dkey.insert("name".to_string(), serde_json::json!("leaf1"));
1625        let device = Object::new(
1626            uid(1),
1627            TypeName::new("dcim.device"),
1628            Key::from(dkey),
1629            JsonMap::default(),
1630        )
1631        .unwrap()
1632        .with_source(SourceLocation::file_line("inventory.yaml", 20));
1633
1634        let mut skey = BTreeMap::new();
1635        skey.insert("slug".to_string(), serde_json::json!("fra1"));
1636        let site = Object::new(
1637            uid(2),
1638            TypeName::new("dcim.site"),
1639            Key::from(skey),
1640            JsonMap::default(),
1641        )
1642        .unwrap()
1643        .with_source(SourceLocation::file_line("inventory.yaml", 5));
1644
1645        let report = ValidationReport {
1646            errors: vec![ValidationError::ReferenceTypeMismatch {
1647                field: "dcim.device.owner".to_string(),
1648                target: uid(2),
1649                expected: "tenancy.tenant".to_string(),
1650                actual: "dcim.site".to_string(),
1651            }],
1652        };
1653
1654        let located = report.with_sources(&[device, site]);
1655        assert_eq!(located.len(), 1);
1656        assert_eq!(
1657            located[0].source,
1658            Some(SourceLocation::file_line("inventory.yaml", 20))
1659        );
1660    }
1661
1662    #[test]
1663    fn test_field_value_validation() {
1664        let uid_to_type = BTreeMap::from([(uid(1), TypeName::new("target"))]);
1665        let mut report = ValidationReport::default();
1666
1667        // test Type Mismatch
1668        let schema = FieldSchema {
1669            r#type: FieldType::Int,
1670            required: true,
1671            nullable: false,
1672            description: None,
1673            format: None,
1674            pattern: None,
1675        };
1676        validate_field_value(
1677            &TypeName::new("test"),
1678            "field",
1679            &schema,
1680            &json!("not-int"),
1681            &uid_to_type,
1682            &mut report,
1683        );
1684        assert!(report
1685            .errors
1686            .iter()
1687            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
1688
1689        // test Enum
1690        let schema = FieldSchema {
1691            r#type: FieldType::Enum {
1692                values: vec!["a".to_string(), "b".to_string()],
1693            },
1694            required: true,
1695            nullable: false,
1696            description: None,
1697            format: None,
1698            pattern: None,
1699        };
1700        report.errors.clear();
1701        validate_field_value(
1702            &TypeName::new("test"),
1703            "field",
1704            &schema,
1705            &json!("c"),
1706            &uid_to_type,
1707            &mut report,
1708        );
1709        assert!(report
1710            .errors
1711            .iter()
1712            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
1713
1714        // test Reference Type Mismatch
1715        let schema = FieldSchema {
1716            r#type: FieldType::Ref {
1717                target: "wrong".to_string(),
1718            },
1719            required: true,
1720            nullable: false,
1721            description: None,
1722            format: None,
1723            pattern: None,
1724        };
1725        report.errors.clear();
1726        validate_field_value(
1727            &TypeName::new("test"),
1728            "field",
1729            &schema,
1730            &json!(uid(1).to_string()),
1731            &uid_to_type,
1732            &mut report,
1733        );
1734        assert!(report
1735            .errors
1736            .iter()
1737            .any(|e| matches!(e, ValidationError::ReferenceTypeMismatch { .. })));
1738
1739        // test ListRef
1740        let schema = FieldSchema {
1741            r#type: FieldType::ListRef {
1742                target: "target".to_string(),
1743            },
1744            required: true,
1745            nullable: false,
1746            description: None,
1747            format: None,
1748            pattern: None,
1749        };
1750        report.errors.clear();
1751        validate_field_value(
1752            &TypeName::new("test"),
1753            "field",
1754            &schema,
1755            &json!([uid(1).to_string()]),
1756            &uid_to_type,
1757            &mut report,
1758        );
1759        assert!(report.errors.is_empty());
1760
1761        // test Map
1762        let schema = FieldSchema {
1763            r#type: FieldType::Map {
1764                value: Box::new(FieldType::Int),
1765            },
1766            required: true,
1767            nullable: false,
1768            description: None,
1769            format: None,
1770            pattern: None,
1771        };
1772        report.errors.clear();
1773        validate_field_value(
1774            &TypeName::new("test"),
1775            "field",
1776            &schema,
1777            &json!({"a": 1, "b": "not-int"}),
1778            &uid_to_type,
1779            &mut report,
1780        );
1781        assert!(report
1782            .errors
1783            .iter()
1784            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
1785
1786        // test Uuid
1787        let schema = FieldSchema {
1788            r#type: FieldType::Uuid,
1789            required: true,
1790            nullable: false,
1791            description: None,
1792            format: None,
1793            pattern: None,
1794        };
1795        report.errors.clear();
1796        validate_field_value(
1797            &TypeName::new("test"),
1798            "field",
1799            &schema,
1800            &json!("not-a-uuid"),
1801            &uid_to_type,
1802            &mut report,
1803        );
1804        assert!(report
1805            .errors
1806            .iter()
1807            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
1808
1809        // test List of Refs
1810        let schema = FieldSchema {
1811            r#type: FieldType::List {
1812                item: Box::new(FieldType::Ref {
1813                    target: "target".to_string(),
1814                }),
1815            },
1816            required: true,
1817            nullable: false,
1818            description: None,
1819            format: None,
1820            pattern: None,
1821        };
1822        report.errors.clear();
1823        validate_field_value(
1824            &TypeName::new("test"),
1825            "field",
1826            &schema,
1827            &json!([uid(1).to_string()]),
1828            &uid_to_type,
1829            &mut report,
1830        );
1831        assert!(report.errors.is_empty());
1832    }
1833
1834    #[test]
1835    fn validate_field_value_null_tristate() {
1836        let nullable = FieldSchema {
1837            r#type: FieldType::String,
1838            required: false,
1839            nullable: true,
1840            description: None,
1841            format: None,
1842            pattern: None,
1843        };
1844        assert!(check(&nullable, &json!(null)).errors.is_empty());
1845
1846        let non_nullable = FieldSchema {
1847            r#type: FieldType::String,
1848            required: true,
1849            nullable: false,
1850            description: None,
1851            format: None,
1852            pattern: None,
1853        };
1854        assert!(check(&non_nullable, &json!(null))
1855            .errors
1856            .iter()
1857            .any(|e| matches!(
1858                e,
1859                ValidationError::InvalidValue { actual, .. } if actual == "null"
1860            )));
1861    }
1862
1863    // ----- string constraint (format / pattern) tests -----
1864
1865    /// build a string-typed field carrying a `format` constraint.
1866    fn fmt_field(format: FieldFormat) -> FieldSchema {
1867        FieldSchema {
1868            r#type: FieldType::String,
1869            required: true,
1870            nullable: false,
1871            description: None,
1872            format: Some(format),
1873            pattern: None,
1874        }
1875    }
1876
1877    /// build a string-typed field carrying a `pattern` constraint.
1878    fn pattern_field(pattern: &str) -> FieldSchema {
1879        FieldSchema {
1880            r#type: FieldType::String,
1881            required: true,
1882            nullable: false,
1883            description: None,
1884            format: None,
1885            pattern: Some(pattern.to_string()),
1886        }
1887    }
1888
1889    /// build a field whose `FieldType` carries an implicit format and no
1890    /// separate `format:` constraint, so validation comes solely from the type.
1891    fn typed_field(field_type: FieldType) -> FieldSchema {
1892        FieldSchema {
1893            r#type: field_type,
1894            required: true,
1895            nullable: false,
1896            description: None,
1897            format: None,
1898            pattern: None,
1899        }
1900    }
1901
1902    /// run `validate_field_value` against a value and return the report.
1903    fn check(schema: &FieldSchema, value: &serde_json::Value) -> ValidationReport {
1904        let uid_to_type: BTreeMap<Uid, TypeName> = BTreeMap::new();
1905        let mut report = ValidationReport::default();
1906        validate_field_value(
1907            &TypeName::new("test"),
1908            "field",
1909            schema,
1910            value,
1911            &uid_to_type,
1912            &mut report,
1913        );
1914        report
1915    }
1916
1917    fn has_invalid_value(report: &ValidationReport) -> bool {
1918        report
1919            .errors
1920            .iter()
1921            .any(|e| matches!(e, ValidationError::InvalidValue { .. }))
1922    }
1923
1924    #[test]
1925    fn format_slug_accepts_valid_and_rejects_invalid() {
1926        assert!(check(&fmt_field(FieldFormat::Slug), &json!("leaf-01"))
1927            .errors
1928            .is_empty());
1929        // trailing hyphen is not allowed by the slug regex.
1930        assert!(has_invalid_value(&check(
1931            &fmt_field(FieldFormat::Slug),
1932            &json!("leaf01-")
1933        )));
1934        // uppercase is not allowed either.
1935        assert!(has_invalid_value(&check(
1936            &fmt_field(FieldFormat::Slug),
1937            &json!("Leaf01")
1938        )));
1939    }
1940
1941    #[test]
1942    fn format_ip_address_accepts_valid_and_rejects_invalid() {
1943        assert!(
1944            check(&fmt_field(FieldFormat::IpAddress), &json!("10.0.0.1"))
1945                .errors
1946                .is_empty()
1947        );
1948        assert!(has_invalid_value(&check(
1949            &fmt_field(FieldFormat::IpAddress),
1950            &json!("not-an-ip")
1951        )));
1952    }
1953
1954    #[test]
1955    fn format_cidr_and_prefix_accept_valid_and_reject_invalid() {
1956        assert!(check(&fmt_field(FieldFormat::Cidr), &json!("10.0.0.0/24"))
1957            .errors
1958            .is_empty());
1959        assert!(
1960            check(&fmt_field(FieldFormat::Prefix), &json!("10.0.0.0/24"))
1961                .errors
1962                .is_empty()
1963        );
1964        assert!(has_invalid_value(&check(
1965            &fmt_field(FieldFormat::Cidr),
1966            &json!("not-a-cidr")
1967        )));
1968    }
1969
1970    #[test]
1971    fn format_mac_accepts_valid_and_rejects_invalid() {
1972        assert!(
1973            check(&fmt_field(FieldFormat::Mac), &json!("aa:bb:cc:dd:ee:ff"))
1974                .errors
1975                .is_empty()
1976        );
1977        // too short to be a full mac address.
1978        assert!(has_invalid_value(&check(
1979            &fmt_field(FieldFormat::Mac),
1980            &json!("aa:bb")
1981        )));
1982    }
1983
1984    #[test]
1985    fn format_uuid_accepts_valid_and_rejects_invalid() {
1986        assert!(
1987            check(&fmt_field(FieldFormat::Uuid), &json!(uid(1).to_string()))
1988                .errors
1989                .is_empty()
1990        );
1991        assert!(has_invalid_value(&check(
1992            &fmt_field(FieldFormat::Uuid),
1993            &json!("not-a-uuid")
1994        )));
1995    }
1996
1997    #[test]
1998    fn type_uuid_enforces_format() {
1999        assert!(
2000            check(&typed_field(FieldType::Uuid), &json!(uid(1).to_string()))
2001                .errors
2002                .is_empty()
2003        );
2004        assert!(has_invalid_value(&check(
2005            &typed_field(FieldType::Uuid),
2006            &json!("not-a-uuid")
2007        )));
2008    }
2009
2010    #[test]
2011    fn type_ip_address_accepts_any_string_including_masked() {
2012        // `ip_address` is intentionally not format-validated yet: NetBox-style
2013        // masked addresses (as in examples/e2e.yaml) must keep passing until the
2014        // mask convention is decided. both bare and masked strings are accepted.
2015        assert!(
2016            check(&typed_field(FieldType::IpAddress), &json!("10.0.0.10/24"))
2017                .errors
2018                .is_empty()
2019        );
2020        assert!(
2021            check(&typed_field(FieldType::IpAddress), &json!("10.0.0.1"))
2022                .errors
2023                .is_empty()
2024        );
2025    }
2026
2027    #[test]
2028    fn type_cidr_and_prefix_enforce_format() {
2029        assert!(check(&typed_field(FieldType::Cidr), &json!("10.0.0.0/24"))
2030            .errors
2031            .is_empty());
2032        assert!(
2033            check(&typed_field(FieldType::Prefix), &json!("10.0.0.0/24"))
2034                .errors
2035                .is_empty()
2036        );
2037        assert!(has_invalid_value(&check(
2038            &typed_field(FieldType::Cidr),
2039            &json!("not-a-cidr")
2040        )));
2041        assert!(has_invalid_value(&check(
2042            &typed_field(FieldType::Prefix),
2043            &json!("10.0.0.1")
2044        )));
2045    }
2046
2047    #[test]
2048    fn type_mac_enforces_format() {
2049        assert!(
2050            check(&typed_field(FieldType::Mac), &json!("aa:bb:cc:dd:ee:ff"))
2051                .errors
2052                .is_empty()
2053        );
2054        assert!(has_invalid_value(&check(
2055            &typed_field(FieldType::Mac),
2056            &json!("aa:bb")
2057        )));
2058    }
2059
2060    #[test]
2061    fn type_slug_enforces_format() {
2062        assert!(check(&typed_field(FieldType::Slug), &json!("leaf-01"))
2063            .errors
2064            .is_empty());
2065        // uppercase is rejected by the slug regex.
2066        assert!(has_invalid_value(&check(
2067            &typed_field(FieldType::Slug),
2068            &json!("Leaf01")
2069        )));
2070    }
2071
2072    #[test]
2073    fn pattern_matches_and_mismatches() {
2074        assert!(check(&pattern_field(r"^[a-z]+$"), &json!("abc"))
2075            .errors
2076            .is_empty());
2077        assert!(has_invalid_value(&check(
2078            &pattern_field(r"^[a-z]+$"),
2079            &json!("ABC")
2080        )));
2081    }
2082
2083    #[test]
2084    fn invalid_pattern_reports_error_without_panicking() {
2085        // an unparsable regex must surface a clean InvalidValue, not panic.
2086        let report = check(&pattern_field("["), &json!("anything"));
2087        assert!(report.errors.iter().any(|e| matches!(
2088            e,
2089            ValidationError::InvalidValue { actual, .. } if actual.contains("invalid pattern")
2090        )));
2091    }
2092
2093    #[test]
2094    fn format_or_pattern_requires_string_value() {
2095        // a json base type accepts the number through the type check, so the
2096        // only error comes from the string-constraint `as_str` else-branch.
2097        let mut schema = fmt_field(FieldFormat::Slug);
2098        schema.r#type = FieldType::Json;
2099        let report = check(&schema, &json!(42));
2100        assert_eq!(report.errors.len(), 1);
2101        assert!(report.errors.iter().any(|e| matches!(
2102            e,
2103            ValidationError::InvalidValue { expected, .. } if expected == "string"
2104        )));
2105
2106        let mut schema = pattern_field(r"^\d+$");
2107        schema.r#type = FieldType::Json;
2108        let report = check(&schema, &json!(42));
2109        assert_eq!(report.errors.len(), 1);
2110        assert!(report.errors.iter().any(|e| matches!(
2111            e,
2112            ValidationError::InvalidValue { expected, .. } if expected == "string"
2113        )));
2114    }
2115}