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}
52
53impl ValidationError {
54    /// return the uid associated with this error, if any.
55    pub fn uid(&self) -> Option<Uid> {
56        match self {
57            ValidationError::DuplicateUid(uid) => Some(*uid),
58            ValidationError::MissingReference { target, .. } => Some(*target),
59            ValidationError::ReferenceTypeMismatch { target, .. } => Some(*target),
60            _ => None,
61        }
62    }
63
64    /// return a key-like string associated with this error, if any.
65    pub fn key_hint(&self) -> Option<String> {
66        match self {
67            ValidationError::DuplicateKey(key) => {
68                if let Some((_, k)) = key.split_once("::") {
69                    Some(k.to_string())
70                } else {
71                    Some(key.clone())
72                }
73            }
74            _ => None,
75        }
76    }
77
78    /// return the type name associated with this error, if any.
79    pub fn type_hint(&self) -> Option<String> {
80        match self {
81            ValidationError::UnknownType(t) => Some(t.clone()),
82            ValidationError::MissingKeyField { type_name, .. }
83            | ValidationError::ExtraKeyField { type_name, .. }
84            | ValidationError::MissingAttrField { type_name, .. }
85            | ValidationError::ExtraAttrField { type_name, .. } => Some(type_name.clone()),
86            ValidationError::InvalidValue { field, .. } => {
87                field.split('.').next().map(|s| s.to_string())
88            }
89            ValidationError::MissingReference { field, .. }
90            | ValidationError::ReferenceTypeMismatch { field, .. } => {
91                field.split('.').next().map(|s| s.to_string())
92            }
93            ValidationError::DuplicateKey(key) => key.split("::").next().map(|s| s.to_string()),
94            _ => None,
95        }
96    }
97}
98
99/// A validation error with optional source location.
100#[derive(Debug, Clone)]
101pub struct LocatedError {
102    pub error: ValidationError,
103    pub source: Option<SourceLocation>,
104}
105
106impl LocatedError {
107    pub fn new(error: ValidationError) -> Self {
108        Self {
109            error,
110            source: None,
111        }
112    }
113
114    pub fn with_source(error: ValidationError, source: Option<SourceLocation>) -> Self {
115        Self { error, source }
116    }
117}
118
119impl fmt::Display for LocatedError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        if let Some(source) = &self.source {
122            write!(f, "{}: {}", source, self.error)
123        } else {
124            write!(f, "{}", self.error)
125        }
126    }
127}
128
129/// aggregated validation report.
130#[derive(Debug, Default, Clone)]
131pub struct ValidationReport {
132    pub errors: Vec<ValidationError>,
133}
134
135impl ValidationReport {
136    /// return true when no errors are present.
137    pub fn is_ok(&self) -> bool {
138        self.errors.is_empty()
139    }
140
141    /// return true when errors are present.
142    pub fn is_err(&self) -> bool {
143        !self.errors.is_empty()
144    }
145
146    /// Enrich errors with source locations from objects.
147    ///
148    /// This matches errors to objects based on UIDs, types, and keys,
149    /// and attaches the object's source location to the error.
150    pub fn with_sources(self, objects: &[Object]) -> Vec<LocatedError> {
151        // Build lookup maps
152        let uid_to_source: BTreeMap<Uid, Option<SourceLocation>> =
153            objects.iter().map(|o| (o.uid, o.source.clone())).collect();
154        let key_to_source: BTreeMap<String, Option<SourceLocation>> = objects
155            .iter()
156            .map(|o| {
157                let key = format!("{}::{}", o.type_name, key_string(&o.key));
158                (key, o.source.clone())
159            })
160            .collect();
161        let type_to_source: BTreeMap<String, Option<SourceLocation>> = objects
162            .iter()
163            .filter_map(|o| o.source.clone().map(|s| (o.type_name.to_string(), Some(s))))
164            .collect();
165
166        self.errors
167            .into_iter()
168            .map(|error| {
169                let source = error
170                    .uid()
171                    .and_then(|uid| uid_to_source.get(&uid).cloned().flatten())
172                    .or_else(|| {
173                        error.key_hint().and_then(|_| {
174                            // For DuplicateKey errors, try to find source
175                            if let ValidationError::DuplicateKey(key) = &error {
176                                key_to_source.get(key).cloned().flatten()
177                            } else {
178                                None
179                            }
180                        })
181                    })
182                    .or_else(|| {
183                        // Try to match by type name in the error
184                        error
185                            .type_hint()
186                            .and_then(|t| type_to_source.get(&t).cloned().flatten())
187                    });
188                LocatedError::with_source(error, source)
189            })
190            .collect()
191    }
192}
193
194/// validate uniqueness and reference integrity for the given inventory.
195pub fn validate_inventory(inventory: &Inventory) -> ValidationReport {
196    let mut report = ValidationReport::default();
197    let mut seen_uids = BTreeSet::new();
198    let mut seen_keys = BTreeSet::new();
199    let mut uid_to_type = BTreeMap::new();
200
201    for object in &inventory.objects {
202        if object.key.is_empty() {
203            report.errors.push(ValidationError::MissingKey);
204        }
205        if object.type_name.is_empty() {
206            report.errors.push(ValidationError::MissingType);
207        }
208        if !seen_uids.insert(object.uid) {
209            report
210                .errors
211                .push(ValidationError::DuplicateUid(object.uid));
212        }
213        let key = format!("{}::{}", object.type_name, key_string(&object.key));
214        if !seen_keys.insert(key.clone()) {
215            report.errors.push(ValidationError::DuplicateKey(key));
216        }
217        uid_to_type.insert(object.uid, object.type_name.clone());
218    }
219
220    validate_schema_types(&inventory.schema, &inventory.objects, &mut report);
221    for object in &inventory.objects {
222        validate_object(object, &inventory.schema, &uid_to_type, &mut report);
223    }
224
225    report
226}
227
228fn validate_schema_types(schema: &Schema, objects: &[Object], report: &mut ValidationReport) {
229    for object in objects {
230        if !schema.types.contains_key(object.type_name.as_str()) {
231            report
232                .errors
233                .push(ValidationError::UnknownType(object.type_name.to_string()));
234        }
235    }
236}
237
238fn validate_object(
239    object: &Object,
240    schema: &Schema,
241    uid_to_type: &BTreeMap<Uid, TypeName>,
242    report: &mut ValidationReport,
243) {
244    let Some(type_schema) = schema.types.get(object.type_name.as_str()) else {
245        return;
246    };
247
248    validate_key_fields(object, type_schema, uid_to_type, report);
249    validate_attr_fields(object, type_schema, uid_to_type, report);
250}
251
252fn validate_key_fields(
253    object: &Object,
254    type_schema: &crate::ir::TypeSchema,
255    uid_to_type: &BTreeMap<Uid, TypeName>,
256    report: &mut ValidationReport,
257) {
258    for (field, field_schema) in &type_schema.key {
259        let Some(value) = object.key.get(field) else {
260            report.errors.push(ValidationError::MissingKeyField {
261                type_name: object.type_name.to_string(),
262                field: field.to_string(),
263            });
264            continue;
265        };
266        validate_field_value(
267            &object.type_name,
268            &format!("key.{field}"),
269            field_schema,
270            value,
271            uid_to_type,
272            report,
273        );
274    }
275
276    for field in object.key.keys() {
277        if !type_schema.key.contains_key(field) {
278            report.errors.push(ValidationError::ExtraKeyField {
279                type_name: object.type_name.to_string(),
280                field: field.to_string(),
281            });
282        }
283    }
284}
285
286fn validate_attr_fields(
287    object: &Object,
288    type_schema: &crate::ir::TypeSchema,
289    uid_to_type: &BTreeMap<Uid, TypeName>,
290    report: &mut ValidationReport,
291) {
292    for (field, field_schema) in &type_schema.fields {
293        let Some(value) = object.attrs.get(field) else {
294            if field_schema.required {
295                report.errors.push(ValidationError::MissingAttrField {
296                    type_name: object.type_name.to_string(),
297                    field: field.to_string(),
298                });
299            }
300            continue;
301        };
302        validate_field_value(
303            &object.type_name,
304            field,
305            field_schema,
306            value,
307            uid_to_type,
308            report,
309        );
310    }
311
312    for field in object.attrs.keys() {
313        if !type_schema.fields.contains_key(field) {
314            report.errors.push(ValidationError::ExtraAttrField {
315                type_name: object.type_name.to_string(),
316                field: field.to_string(),
317            });
318        }
319    }
320}
321
322fn validate_field_value(
323    type_name: &TypeName,
324    field: &str,
325    field_schema: &crate::ir::FieldSchema,
326    value: &Value,
327    uid_to_type: &BTreeMap<Uid, TypeName>,
328    report: &mut ValidationReport,
329) {
330    if value.is_null() {
331        if field_schema.nullable {
332            return;
333        }
334        report.errors.push(ValidationError::InvalidValue {
335            field: format!("{type_name}.{field}"),
336            expected: field_type_label(&field_schema.r#type),
337            actual: "null".to_string(),
338        });
339        return;
340    }
341
342    match &field_schema.r#type {
343        FieldType::Ref { target } => {
344            validate_ref(type_name, field, target, value, uid_to_type, report);
345        }
346        FieldType::ListRef { target } => {
347            if let Some(entries) = value.as_array() {
348                for entry in entries {
349                    validate_ref(type_name, field, target, entry, uid_to_type, report);
350                }
351            } else {
352                report.errors.push(ValidationError::InvalidValue {
353                    field: format!("{type_name}.{field}"),
354                    expected: "list_ref".to_string(),
355                    actual: value_type_label(value),
356                });
357            }
358        }
359        FieldType::List { item } => {
360            if let Some(entries) = value.as_array() {
361                for entry in entries {
362                    let schema = crate::ir::FieldSchema {
363                        r#type: (**item).clone(),
364                        required: true,
365                        nullable: false,
366                        description: None,
367                        format: None,
368                        pattern: None,
369                    };
370                    validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
371                }
372            } else {
373                report.errors.push(ValidationError::InvalidValue {
374                    field: format!("{type_name}.{field}"),
375                    expected: "list".to_string(),
376                    actual: value_type_label(value),
377                });
378            }
379        }
380        FieldType::Map { value: inner } => {
381            if let Some(entries) = value.as_object() {
382                for entry in entries.values() {
383                    let schema = crate::ir::FieldSchema {
384                        r#type: (**inner).clone(),
385                        required: true,
386                        nullable: false,
387                        description: None,
388                        format: None,
389                        pattern: None,
390                    };
391                    validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
392                }
393            } else {
394                report.errors.push(ValidationError::InvalidValue {
395                    field: format!("{type_name}.{field}"),
396                    expected: "map".to_string(),
397                    actual: value_type_label(value),
398                });
399            }
400        }
401        FieldType::Enum { values } => {
402            if let Some(raw) = value.as_str() {
403                if !values.contains(&raw.to_string()) {
404                    report.errors.push(ValidationError::InvalidValue {
405                        field: format!("{type_name}.{field}"),
406                        expected: format!("enum({})", values.join("|")),
407                        actual: raw.to_string(),
408                    });
409                }
410            } else {
411                report.errors.push(ValidationError::InvalidValue {
412                    field: format!("{type_name}.{field}"),
413                    expected: "enum".to_string(),
414                    actual: value_type_label(value),
415                });
416            }
417        }
418        _ => {
419            if !value_matches_type(value, &field_schema.r#type) {
420                report.errors.push(ValidationError::InvalidValue {
421                    field: format!("{type_name}.{field}"),
422                    expected: field_type_label(&field_schema.r#type),
423                    actual: value_type_label(value),
424                });
425            }
426        }
427    }
428
429    validate_string_constraints(type_name, field, field_schema, value, report);
430}
431
432fn parse_uid(value: &Value) -> Option<Uid> {
433    let raw = value.as_str()?;
434    Uid::parse_str(raw).ok()
435}
436
437fn validate_ref(
438    type_name: &TypeName,
439    field: &str,
440    target: &str,
441    value: &Value,
442    uid_to_type: &BTreeMap<Uid, TypeName>,
443    report: &mut ValidationReport,
444) {
445    let Some(uid) = parse_uid(value) else {
446        report.errors.push(ValidationError::InvalidValue {
447            field: format!("{type_name}.{field}"),
448            expected: "uuid".to_string(),
449            actual: value_type_label(value),
450        });
451        return;
452    };
453    let Some(actual) = uid_to_type.get(&uid) else {
454        report.errors.push(ValidationError::MissingReference {
455            field: format!("{type_name}.{field}"),
456            target: uid,
457        });
458        return;
459    };
460    if actual.as_str() != target {
461        report.errors.push(ValidationError::ReferenceTypeMismatch {
462            field: format!("{type_name}.{field}"),
463            target: uid,
464            expected: target.to_string(),
465            actual: actual.to_string(),
466        });
467    }
468}
469
470fn validate_string_constraints(
471    type_name: &TypeName,
472    field: &str,
473    field_schema: &crate::ir::FieldSchema,
474    value: &Value,
475    report: &mut ValidationReport,
476) {
477    if field_schema.format.is_none() && field_schema.pattern.is_none() {
478        return;
479    }
480
481    let Some(raw) = value.as_str() else {
482        report.errors.push(ValidationError::InvalidValue {
483            field: format!("{type_name}.{field}"),
484            expected: "string".to_string(),
485            actual: value_type_label(value),
486        });
487        return;
488    };
489
490    if let Some(format) = &field_schema.format {
491        if !matches_format(format, raw) {
492            report.errors.push(ValidationError::InvalidValue {
493                field: format!("{type_name}.{field}"),
494                expected: format_label(format),
495                actual: raw.to_string(),
496            });
497        }
498    }
499
500    if let Some(pattern) = &field_schema.pattern {
501        match Regex::new(pattern) {
502            Ok(regex) => {
503                if !regex.is_match(raw) {
504                    report.errors.push(ValidationError::InvalidValue {
505                        field: format!("{type_name}.{field}"),
506                        expected: format!("pattern({pattern})"),
507                        actual: raw.to_string(),
508                    });
509                }
510            }
511            Err(err) => {
512                report.errors.push(ValidationError::InvalidValue {
513                    field: format!("{type_name}.{field}"),
514                    expected: format!("pattern({pattern})"),
515                    actual: format!("invalid pattern: {err}"),
516                });
517            }
518        }
519    }
520}
521
522fn slug_regex() -> &'static Regex {
523    static RE: OnceLock<Regex> = OnceLock::new();
524    RE.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$").unwrap())
525}
526
527fn mac_regex() -> &'static Regex {
528    static RE: OnceLock<Regex> = OnceLock::new();
529    RE.get_or_init(|| Regex::new(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$").unwrap())
530}
531
532fn matches_format(format: &FieldFormat, raw: &str) -> bool {
533    match format {
534        FieldFormat::Slug => slug_regex().is_match(raw),
535        FieldFormat::IpAddress => raw.parse::<IpAddr>().is_ok(),
536        FieldFormat::Cidr | FieldFormat::Prefix => raw.parse::<IpNet>().is_ok(),
537        FieldFormat::Mac => mac_regex().is_match(raw),
538        FieldFormat::Uuid => Uid::parse_str(raw).is_ok(),
539    }
540}
541
542fn format_label(format: &FieldFormat) -> String {
543    match format {
544        FieldFormat::Slug => "format(slug)".to_string(),
545        FieldFormat::IpAddress => "format(ip_address)".to_string(),
546        FieldFormat::Cidr => "format(cidr)".to_string(),
547        FieldFormat::Prefix => "format(prefix)".to_string(),
548        FieldFormat::Mac => "format(mac)".to_string(),
549        FieldFormat::Uuid => "format(uuid)".to_string(),
550    }
551}
552
553fn value_matches_type(value: &Value, field_type: &FieldType) -> bool {
554    match field_type {
555        FieldType::String
556        | FieldType::Text
557        | FieldType::Date
558        | FieldType::Datetime
559        | FieldType::Time
560        | FieldType::IpAddress
561        | FieldType::Cidr
562        | FieldType::Prefix
563        | FieldType::Mac
564        | FieldType::Slug => value.is_string(),
565        FieldType::Uuid => value
566            .as_str()
567            .map(|raw| Uid::parse_str(raw).is_ok())
568            .unwrap_or(false),
569        FieldType::Int => value.is_i64() || value.is_u64(),
570        FieldType::Float => value.as_f64().is_some() || value.is_i64() || value.is_u64(),
571        FieldType::Bool => value.is_boolean(),
572        FieldType::Json => true,
573        FieldType::Enum { .. } => value.is_string(),
574        FieldType::List { .. } => value.is_array(),
575        FieldType::Map { .. } => value.is_object(),
576        FieldType::Ref { .. } | FieldType::ListRef { .. } => true,
577    }
578}
579
580fn field_type_label(field_type: &FieldType) -> String {
581    match field_type {
582        FieldType::String => "string".to_string(),
583        FieldType::Text => "text".to_string(),
584        FieldType::Int => "int".to_string(),
585        FieldType::Float => "float".to_string(),
586        FieldType::Bool => "bool".to_string(),
587        FieldType::Uuid => "uuid".to_string(),
588        FieldType::Date => "date".to_string(),
589        FieldType::Datetime => "datetime".to_string(),
590        FieldType::Time => "time".to_string(),
591        FieldType::Json => "json".to_string(),
592        FieldType::IpAddress => "ip_address".to_string(),
593        FieldType::Cidr => "cidr".to_string(),
594        FieldType::Prefix => "prefix".to_string(),
595        FieldType::Mac => "mac".to_string(),
596        FieldType::Slug => "slug".to_string(),
597        FieldType::Enum { .. } => "enum".to_string(),
598        FieldType::List { .. } => "list".to_string(),
599        FieldType::Map { .. } => "map".to_string(),
600        FieldType::Ref { target } => format!("ref({target})"),
601        FieldType::ListRef { target } => format!("list_ref({target})"),
602    }
603}
604
605fn value_type_label(value: &Value) -> String {
606    match value {
607        Value::Null => "null".to_string(),
608        Value::Bool(_) => "bool".to_string(),
609        Value::Number(_) => "number".to_string(),
610        Value::String(_) => "string".to_string(),
611        Value::Array(_) => "array".to_string(),
612        Value::Object(_) => "object".to_string(),
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::ir::{FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema};
620    use serde_json::json;
621    use std::collections::BTreeMap;
622    use uuid::Uuid;
623
624    fn uid(value: u128) -> Uid {
625        Uuid::from_u128(value)
626    }
627
628    #[test]
629    fn detects_duplicate_keys() {
630        let mut key = BTreeMap::new();
631        key.insert("slug".to_string(), serde_json::json!("fra1"));
632        let key = Key::from(key);
633        let type_schema = TypeSchema {
634            key: BTreeMap::from([(
635                "slug".to_string(),
636                FieldSchema {
637                    r#type: FieldType::Slug,
638                    required: true,
639                    nullable: false,
640                    description: None,
641                    format: None,
642                    pattern: None,
643                },
644            )]),
645            fields: BTreeMap::new(),
646        };
647        let objects = vec![
648            Object::new(
649                uid(1),
650                TypeName::new("site"),
651                key.clone(),
652                JsonMap::default(),
653            )
654            .unwrap(),
655            Object::new(uid(2), TypeName::new("site"), key, JsonMap::default()).unwrap(),
656        ];
657        let report = validate_inventory(&Inventory {
658            schema: Schema {
659                types: BTreeMap::from([("site".to_string(), type_schema)]),
660            },
661            objects,
662        });
663        assert!(report
664            .errors
665            .iter()
666            .any(|err| matches!(err, ValidationError::DuplicateKey(_))));
667    }
668
669    #[test]
670    fn detects_missing_key() {
671        let objects = vec![Object {
672            uid: uid(30),
673            type_name: TypeName::new("site"),
674            key: Key::default(),
675            attrs: JsonMap::default(),
676            source: None,
677        }];
678        let report = validate_inventory(&Inventory {
679            schema: Schema {
680                types: BTreeMap::from([(
681                    "site".to_string(),
682                    TypeSchema {
683                        key: BTreeMap::new(),
684                        fields: BTreeMap::new(),
685                    },
686                )]),
687            },
688            objects,
689        });
690        assert!(report
691            .errors
692            .iter()
693            .any(|err| matches!(err, ValidationError::MissingKey)));
694    }
695
696    #[test]
697    fn detects_missing_kind() {
698        let mut key = BTreeMap::new();
699        key.insert("slug".to_string(), serde_json::json!("fra1"));
700        let objects = vec![Object {
701            uid: uid(31),
702            type_name: TypeName::new(""),
703            key: Key::from(key),
704            attrs: JsonMap::default(),
705            source: None,
706        }];
707        let report = validate_inventory(&Inventory {
708            schema: Schema {
709                types: BTreeMap::new(),
710            },
711            objects,
712        });
713        assert!(report
714            .errors
715            .iter()
716            .any(|err| matches!(err, ValidationError::MissingType)));
717    }
718
719    #[test]
720    fn detects_unknown_type() {
721        let mut key = BTreeMap::new();
722        key.insert("slug".to_string(), serde_json::json!("leaf01"));
723        let objects = vec![Object::new(
724            uid(40),
725            TypeName::new("device"),
726            Key::from(key),
727            JsonMap::default(),
728        )
729        .unwrap()];
730        let schema = Schema {
731            types: BTreeMap::new(),
732        };
733        let report = validate_inventory(&Inventory { schema, objects });
734        assert!(report
735            .errors
736            .iter()
737            .any(|err| matches!(err, ValidationError::UnknownType(_))));
738    }
739
740    #[test]
741    fn detects_missing_references_with_schema() {
742        let mut key_fields = BTreeMap::new();
743        key_fields.insert(
744            "slug".to_string(),
745            FieldSchema {
746                r#type: FieldType::Slug,
747                required: true,
748                nullable: false,
749                description: None,
750                format: None,
751                pattern: None,
752            },
753        );
754        let mut fields = BTreeMap::new();
755        fields.insert(
756            "owner".to_string(),
757            FieldSchema {
758                r#type: FieldType::Ref {
759                    target: "person".to_string(),
760                },
761                required: false,
762                nullable: false,
763                description: None,
764                format: None,
765                pattern: None,
766            },
767        );
768        let mut types = BTreeMap::new();
769        types.insert(
770            "device".to_string(),
771            TypeSchema {
772                key: key_fields,
773                fields,
774            },
775        );
776        let schema = Schema { types };
777
778        let mut attrs = BTreeMap::new();
779        attrs.insert(
780            "owner".to_string(),
781            serde_json::json!(Uuid::from_u128(99).to_string()),
782        );
783        let mut key = BTreeMap::new();
784        key.insert("slug".to_string(), serde_json::json!("leaf01"));
785        let objects = vec![Object::new(
786            uid(41),
787            TypeName::new("device"),
788            Key::from(key),
789            attrs.into(),
790        )
791        .unwrap()];
792        let report = validate_inventory(&Inventory { schema, objects });
793        assert!(report
794            .errors
795            .iter()
796            .any(|err| matches!(err, ValidationError::MissingReference { .. })));
797    }
798
799    #[test]
800    fn test_field_value_validation() {
801        let uid_to_type = BTreeMap::from([(uid(1), TypeName::new("target"))]);
802        let mut report = ValidationReport::default();
803
804        // Test Type Mismatch
805        let schema = FieldSchema {
806            r#type: FieldType::Int,
807            required: true,
808            nullable: false,
809            description: None,
810            format: None,
811            pattern: None,
812        };
813        validate_field_value(
814            &TypeName::new("test"),
815            "field",
816            &schema,
817            &json!("not-int"),
818            &uid_to_type,
819            &mut report,
820        );
821        assert!(report
822            .errors
823            .iter()
824            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
825
826        // Test Enum
827        let schema = FieldSchema {
828            r#type: FieldType::Enum {
829                values: vec!["a".to_string(), "b".to_string()],
830            },
831            required: true,
832            nullable: false,
833            description: None,
834            format: None,
835            pattern: None,
836        };
837        report.errors.clear();
838        validate_field_value(
839            &TypeName::new("test"),
840            "field",
841            &schema,
842            &json!("c"),
843            &uid_to_type,
844            &mut report,
845        );
846        assert!(report
847            .errors
848            .iter()
849            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
850
851        // Test Reference Type Mismatch
852        let schema = FieldSchema {
853            r#type: FieldType::Ref {
854                target: "wrong".to_string(),
855            },
856            required: true,
857            nullable: false,
858            description: None,
859            format: None,
860            pattern: None,
861        };
862        report.errors.clear();
863        validate_field_value(
864            &TypeName::new("test"),
865            "field",
866            &schema,
867            &json!(uid(1).to_string()),
868            &uid_to_type,
869            &mut report,
870        );
871        assert!(report
872            .errors
873            .iter()
874            .any(|e| matches!(e, ValidationError::ReferenceTypeMismatch { .. })));
875
876        // Test ListRef
877        let schema = FieldSchema {
878            r#type: FieldType::ListRef {
879                target: "target".to_string(),
880            },
881            required: true,
882            nullable: false,
883            description: None,
884            format: None,
885            pattern: None,
886        };
887        report.errors.clear();
888        validate_field_value(
889            &TypeName::new("test"),
890            "field",
891            &schema,
892            &json!([uid(1).to_string()]),
893            &uid_to_type,
894            &mut report,
895        );
896        assert!(report.errors.is_empty());
897
898        // Test Map
899        let schema = FieldSchema {
900            r#type: FieldType::Map {
901                value: Box::new(FieldType::Int),
902            },
903            required: true,
904            nullable: false,
905            description: None,
906            format: None,
907            pattern: None,
908        };
909        report.errors.clear();
910        validate_field_value(
911            &TypeName::new("test"),
912            "field",
913            &schema,
914            &json!({"a": 1, "b": "not-int"}),
915            &uid_to_type,
916            &mut report,
917        );
918        assert!(report
919            .errors
920            .iter()
921            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
922
923        // Test Uuid
924        let schema = FieldSchema {
925            r#type: FieldType::Uuid,
926            required: true,
927            nullable: false,
928            description: None,
929            format: None,
930            pattern: None,
931        };
932        report.errors.clear();
933        validate_field_value(
934            &TypeName::new("test"),
935            "field",
936            &schema,
937            &json!("not-a-uuid"),
938            &uid_to_type,
939            &mut report,
940        );
941        assert!(report
942            .errors
943            .iter()
944            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
945
946        // Test List of Refs
947        let schema = FieldSchema {
948            r#type: FieldType::List {
949                item: Box::new(FieldType::Ref {
950                    target: "target".to_string(),
951                }),
952            },
953            required: true,
954            nullable: false,
955            description: None,
956            format: None,
957            pattern: None,
958        };
959        report.errors.clear();
960        validate_field_value(
961            &TypeName::new("test"),
962            "field",
963            &schema,
964            &json!([uid(1).to_string()]),
965            &uid_to_type,
966            &mut report,
967        );
968        assert!(report.errors.is_empty());
969    }
970}