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    /// return the dotted field path for this error, if any.
99    pub fn field(&self) -> Option<&str> {
100        match self {
101            ValidationError::InvalidValue { field, .. }
102            | ValidationError::MissingReference { field, .. }
103            | ValidationError::ReferenceTypeMismatch { field, .. } => Some(field),
104            _ => None,
105        }
106    }
107}
108
109/// a validation error with optional source location.
110#[derive(Debug, Clone)]
111pub struct LocatedError {
112    pub error: ValidationError,
113    pub source: Option<SourceLocation>,
114}
115
116impl LocatedError {
117    pub fn new(error: ValidationError) -> Self {
118        Self {
119            error,
120            source: None,
121        }
122    }
123
124    pub fn with_source(error: ValidationError, source: Option<SourceLocation>) -> Self {
125        Self { error, source }
126    }
127}
128
129impl fmt::Display for LocatedError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        if let Some(source) = &self.source {
132            write!(f, "{}: {}", source, self.error)
133        } else {
134            write!(f, "{}", self.error)
135        }
136    }
137}
138
139/// aggregated validation report.
140#[derive(Debug, Default, Clone)]
141pub struct ValidationReport {
142    pub errors: Vec<ValidationError>,
143}
144
145impl ValidationReport {
146    /// return true when no errors are present.
147    pub fn is_ok(&self) -> bool {
148        self.errors.is_empty()
149    }
150
151    /// return true when errors are present.
152    pub fn is_err(&self) -> bool {
153        !self.errors.is_empty()
154    }
155
156    /// enrich errors with source locations from objects.
157    ///
158    /// this matches errors to objects based on UIDs, types, and keys,
159    /// and attaches the object's source location to the error.
160    pub fn with_sources(self, objects: &[Object]) -> Vec<LocatedError> {
161        // build lookup maps
162        let uid_to_source: BTreeMap<Uid, Option<SourceLocation>> =
163            objects.iter().map(|o| (o.uid, o.source.clone())).collect();
164        let key_to_source: BTreeMap<String, Option<SourceLocation>> = objects
165            .iter()
166            .map(|o| {
167                let key = format!("{}::{}", o.type_name, key_string(&o.key));
168                (key, o.source.clone())
169            })
170            .collect();
171        let type_to_source: BTreeMap<String, Option<SourceLocation>> = objects
172            .iter()
173            .filter_map(|o| o.source.clone().map(|s| (o.type_name.to_string(), Some(s))))
174            .collect();
175        let known_types: BTreeSet<&str> = objects.iter().map(|o| o.type_name.as_str()).collect();
176
177        self.errors
178            .into_iter()
179            .map(|error| {
180                let source = error
181                    .uid()
182                    .and_then(|uid| uid_to_source.get(&uid).cloned().flatten())
183                    .or_else(|| {
184                        error.key_hint().and_then(|_| {
185                            // for DuplicateKey errors, try to find source
186                            if let ValidationError::DuplicateKey(key) = &error {
187                                key_to_source.get(key).cloned().flatten()
188                            } else {
189                                None
190                            }
191                        })
192                    })
193                    .or_else(|| {
194                        // type names contain dots, so match the longest known type that prefixes `field`.
195                        if let Some(field) = error.field() {
196                            known_types
197                                .iter()
198                                .filter(|t| {
199                                    field.strip_prefix(**t).is_some_and(|rest| {
200                                        rest.is_empty() || rest.starts_with('.')
201                                    })
202                                })
203                                .max_by_key(|t| t.len())
204                                .and_then(|t| type_to_source.get(*t).cloned().flatten())
205                        } else {
206                            error
207                                .type_hint()
208                                .and_then(|t| type_to_source.get(&t).cloned().flatten())
209                        }
210                    });
211                LocatedError::with_source(error, source)
212            })
213            .collect()
214    }
215}
216
217/// validate uniqueness and reference integrity for the given inventory.
218pub fn validate_inventory(inventory: &Inventory) -> ValidationReport {
219    let mut report = ValidationReport::default();
220    let mut seen_uids = BTreeSet::new();
221    let mut seen_keys = BTreeSet::new();
222    let mut uid_to_type = BTreeMap::new();
223
224    for object in &inventory.objects {
225        if object.key.is_empty() {
226            report.errors.push(ValidationError::MissingKey);
227        }
228        if object.type_name.is_empty() {
229            report.errors.push(ValidationError::MissingType);
230        }
231        if !seen_uids.insert(object.uid) {
232            report
233                .errors
234                .push(ValidationError::DuplicateUid(object.uid));
235        }
236        let key = format!("{}::{}", object.type_name, key_string(&object.key));
237        if !seen_keys.insert(key.clone()) {
238            report.errors.push(ValidationError::DuplicateKey(key));
239        }
240        uid_to_type.insert(object.uid, object.type_name.clone());
241    }
242
243    validate_schema_types(&inventory.schema, &inventory.objects, &mut report);
244    for object in &inventory.objects {
245        validate_object(object, &inventory.schema, &uid_to_type, &mut report);
246    }
247
248    report
249}
250
251fn validate_schema_types(schema: &Schema, objects: &[Object], report: &mut ValidationReport) {
252    for object in objects {
253        if !schema.types.contains_key(object.type_name.as_str()) {
254            report
255                .errors
256                .push(ValidationError::UnknownType(object.type_name.to_string()));
257        }
258    }
259}
260
261fn validate_object(
262    object: &Object,
263    schema: &Schema,
264    uid_to_type: &BTreeMap<Uid, TypeName>,
265    report: &mut ValidationReport,
266) {
267    let Some(type_schema) = schema.types.get(object.type_name.as_str()) else {
268        return;
269    };
270
271    validate_key_fields(object, type_schema, uid_to_type, report);
272    validate_attr_fields(object, type_schema, uid_to_type, report);
273}
274
275fn validate_key_fields(
276    object: &Object,
277    type_schema: &crate::ir::TypeSchema,
278    uid_to_type: &BTreeMap<Uid, TypeName>,
279    report: &mut ValidationReport,
280) {
281    for (field, field_schema) in &type_schema.key {
282        let Some(value) = object.key.get(field) else {
283            report.errors.push(ValidationError::MissingKeyField {
284                type_name: object.type_name.to_string(),
285                field: field.to_string(),
286            });
287            continue;
288        };
289        validate_field_value(
290            &object.type_name,
291            &format!("key.{field}"),
292            field_schema,
293            value,
294            uid_to_type,
295            report,
296        );
297    }
298
299    for field in object.key.keys() {
300        if !type_schema.key.contains_key(field) {
301            report.errors.push(ValidationError::ExtraKeyField {
302                type_name: object.type_name.to_string(),
303                field: field.to_string(),
304            });
305        }
306    }
307}
308
309fn validate_attr_fields(
310    object: &Object,
311    type_schema: &crate::ir::TypeSchema,
312    uid_to_type: &BTreeMap<Uid, TypeName>,
313    report: &mut ValidationReport,
314) {
315    for (field, field_schema) in &type_schema.fields {
316        let Some(value) = object.attrs.get(field) else {
317            if field_schema.required {
318                report.errors.push(ValidationError::MissingAttrField {
319                    type_name: object.type_name.to_string(),
320                    field: field.to_string(),
321                });
322            }
323            continue;
324        };
325        validate_field_value(
326            &object.type_name,
327            field,
328            field_schema,
329            value,
330            uid_to_type,
331            report,
332        );
333    }
334
335    for field in object.attrs.keys() {
336        if !type_schema.fields.contains_key(field) {
337            report.errors.push(ValidationError::ExtraAttrField {
338                type_name: object.type_name.to_string(),
339                field: field.to_string(),
340            });
341        }
342    }
343}
344
345fn validate_field_value(
346    type_name: &TypeName,
347    field: &str,
348    field_schema: &crate::ir::FieldSchema,
349    value: &Value,
350    uid_to_type: &BTreeMap<Uid, TypeName>,
351    report: &mut ValidationReport,
352) {
353    if value.is_null() {
354        if field_schema.nullable {
355            return;
356        }
357        report.errors.push(ValidationError::InvalidValue {
358            field: format!("{type_name}.{field}"),
359            expected: field_type_label(&field_schema.r#type),
360            actual: "null".to_string(),
361        });
362        return;
363    }
364
365    match &field_schema.r#type {
366        FieldType::Ref { target } => {
367            validate_ref(type_name, field, target, value, uid_to_type, report);
368        }
369        FieldType::ListRef { target } => {
370            if let Some(entries) = value.as_array() {
371                for entry in entries {
372                    validate_ref(type_name, field, target, entry, uid_to_type, report);
373                }
374            } else {
375                report.errors.push(ValidationError::InvalidValue {
376                    field: format!("{type_name}.{field}"),
377                    expected: "list_ref".to_string(),
378                    actual: value_type_label(value),
379                });
380            }
381        }
382        FieldType::List { item } => {
383            if let Some(entries) = value.as_array() {
384                for entry in entries {
385                    let schema = crate::ir::FieldSchema {
386                        r#type: (**item).clone(),
387                        required: true,
388                        nullable: false,
389                        description: None,
390                        format: None,
391                        pattern: None,
392                    };
393                    validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
394                }
395            } else {
396                report.errors.push(ValidationError::InvalidValue {
397                    field: format!("{type_name}.{field}"),
398                    expected: "list".to_string(),
399                    actual: value_type_label(value),
400                });
401            }
402        }
403        FieldType::Map { value: inner } => {
404            if let Some(entries) = value.as_object() {
405                for entry in entries.values() {
406                    let schema = crate::ir::FieldSchema {
407                        r#type: (**inner).clone(),
408                        required: true,
409                        nullable: false,
410                        description: None,
411                        format: None,
412                        pattern: None,
413                    };
414                    validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
415                }
416            } else {
417                report.errors.push(ValidationError::InvalidValue {
418                    field: format!("{type_name}.{field}"),
419                    expected: "map".to_string(),
420                    actual: value_type_label(value),
421                });
422            }
423        }
424        FieldType::Enum { values } => {
425            if let Some(raw) = value.as_str() {
426                if !values.contains(&raw.to_string()) {
427                    report.errors.push(ValidationError::InvalidValue {
428                        field: format!("{type_name}.{field}"),
429                        expected: format!("enum({})", values.join("|")),
430                        actual: raw.to_string(),
431                    });
432                }
433            } else {
434                report.errors.push(ValidationError::InvalidValue {
435                    field: format!("{type_name}.{field}"),
436                    expected: "enum".to_string(),
437                    actual: value_type_label(value),
438                });
439            }
440        }
441        _ => {
442            if !value_matches_type(value, &field_schema.r#type) {
443                report.errors.push(ValidationError::InvalidValue {
444                    field: format!("{type_name}.{field}"),
445                    expected: field_type_label(&field_schema.r#type),
446                    actual: value_type_label(value),
447                });
448            }
449        }
450    }
451
452    validate_string_constraints(type_name, field, field_schema, value, report);
453}
454
455fn parse_uid(value: &Value) -> Option<Uid> {
456    let raw = value.as_str()?;
457    Uid::parse_str(raw).ok()
458}
459
460fn validate_ref(
461    type_name: &TypeName,
462    field: &str,
463    target: &str,
464    value: &Value,
465    uid_to_type: &BTreeMap<Uid, TypeName>,
466    report: &mut ValidationReport,
467) {
468    let Some(uid) = parse_uid(value) else {
469        report.errors.push(ValidationError::InvalidValue {
470            field: format!("{type_name}.{field}"),
471            expected: "uuid".to_string(),
472            actual: value_type_label(value),
473        });
474        return;
475    };
476    let Some(actual) = uid_to_type.get(&uid) else {
477        report.errors.push(ValidationError::MissingReference {
478            field: format!("{type_name}.{field}"),
479            target: uid,
480        });
481        return;
482    };
483    if actual.as_str() != target {
484        report.errors.push(ValidationError::ReferenceTypeMismatch {
485            field: format!("{type_name}.{field}"),
486            target: uid,
487            expected: target.to_string(),
488            actual: actual.to_string(),
489        });
490    }
491}
492
493fn validate_string_constraints(
494    type_name: &TypeName,
495    field: &str,
496    field_schema: &crate::ir::FieldSchema,
497    value: &Value,
498    report: &mut ValidationReport,
499) {
500    if field_schema.format.is_none() && field_schema.pattern.is_none() {
501        return;
502    }
503
504    let Some(raw) = value.as_str() else {
505        report.errors.push(ValidationError::InvalidValue {
506            field: format!("{type_name}.{field}"),
507            expected: "string".to_string(),
508            actual: value_type_label(value),
509        });
510        return;
511    };
512
513    if let Some(format) = &field_schema.format {
514        if !matches_format(format, raw) {
515            report.errors.push(ValidationError::InvalidValue {
516                field: format!("{type_name}.{field}"),
517                expected: format_label(format),
518                actual: raw.to_string(),
519            });
520        }
521    }
522
523    if let Some(pattern) = &field_schema.pattern {
524        match Regex::new(pattern) {
525            Ok(regex) => {
526                if !regex.is_match(raw) {
527                    report.errors.push(ValidationError::InvalidValue {
528                        field: format!("{type_name}.{field}"),
529                        expected: format!("pattern({pattern})"),
530                        actual: raw.to_string(),
531                    });
532                }
533            }
534            Err(err) => {
535                report.errors.push(ValidationError::InvalidValue {
536                    field: format!("{type_name}.{field}"),
537                    expected: format!("pattern({pattern})"),
538                    actual: format!("invalid pattern: {err}"),
539                });
540            }
541        }
542    }
543}
544
545fn slug_regex() -> &'static Regex {
546    static RE: OnceLock<Regex> = OnceLock::new();
547    RE.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$").unwrap())
548}
549
550fn mac_regex() -> &'static Regex {
551    static RE: OnceLock<Regex> = OnceLock::new();
552    RE.get_or_init(|| Regex::new(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$").unwrap())
553}
554
555fn matches_format(format: &FieldFormat, raw: &str) -> bool {
556    match format {
557        FieldFormat::Slug => slug_regex().is_match(raw),
558        FieldFormat::IpAddress => raw.parse::<IpAddr>().is_ok(),
559        FieldFormat::Cidr | FieldFormat::Prefix => raw.parse::<IpNet>().is_ok(),
560        FieldFormat::Mac => mac_regex().is_match(raw),
561        FieldFormat::Uuid => Uid::parse_str(raw).is_ok(),
562    }
563}
564
565fn format_label(format: &FieldFormat) -> String {
566    match format {
567        FieldFormat::Slug => "format(slug)".to_string(),
568        FieldFormat::IpAddress => "format(ip_address)".to_string(),
569        FieldFormat::Cidr => "format(cidr)".to_string(),
570        FieldFormat::Prefix => "format(prefix)".to_string(),
571        FieldFormat::Mac => "format(mac)".to_string(),
572        FieldFormat::Uuid => "format(uuid)".to_string(),
573    }
574}
575
576fn value_matches_type(value: &Value, field_type: &FieldType) -> bool {
577    match field_type {
578        FieldType::String
579        | FieldType::Text
580        | FieldType::Date
581        | FieldType::Datetime
582        | FieldType::Time
583        | FieldType::IpAddress
584        | FieldType::Cidr
585        | FieldType::Prefix
586        | FieldType::Mac
587        | FieldType::Slug => value.is_string(),
588        FieldType::Uuid => value
589            .as_str()
590            .map(|raw| Uid::parse_str(raw).is_ok())
591            .unwrap_or(false),
592        FieldType::Int => value.is_i64() || value.is_u64(),
593        FieldType::Float => value.as_f64().is_some() || value.is_i64() || value.is_u64(),
594        FieldType::Bool => value.is_boolean(),
595        FieldType::Json => true,
596        FieldType::Enum { .. } => value.is_string(),
597        FieldType::List { .. } => value.is_array(),
598        FieldType::Map { .. } => value.is_object(),
599        FieldType::Ref { .. } | FieldType::ListRef { .. } => true,
600    }
601}
602
603fn field_type_label(field_type: &FieldType) -> String {
604    match field_type {
605        FieldType::String => "string".to_string(),
606        FieldType::Text => "text".to_string(),
607        FieldType::Int => "int".to_string(),
608        FieldType::Float => "float".to_string(),
609        FieldType::Bool => "bool".to_string(),
610        FieldType::Uuid => "uuid".to_string(),
611        FieldType::Date => "date".to_string(),
612        FieldType::Datetime => "datetime".to_string(),
613        FieldType::Time => "time".to_string(),
614        FieldType::Json => "json".to_string(),
615        FieldType::IpAddress => "ip_address".to_string(),
616        FieldType::Cidr => "cidr".to_string(),
617        FieldType::Prefix => "prefix".to_string(),
618        FieldType::Mac => "mac".to_string(),
619        FieldType::Slug => "slug".to_string(),
620        FieldType::Enum { .. } => "enum".to_string(),
621        FieldType::List { .. } => "list".to_string(),
622        FieldType::Map { .. } => "map".to_string(),
623        FieldType::Ref { target } => format!("ref({target})"),
624        FieldType::ListRef { target } => format!("list_ref({target})"),
625    }
626}
627
628fn value_type_label(value: &Value) -> String {
629    match value {
630        Value::Null => "null".to_string(),
631        Value::Bool(_) => "bool".to_string(),
632        Value::Number(_) => "number".to_string(),
633        Value::String(_) => "string".to_string(),
634        Value::Array(_) => "array".to_string(),
635        Value::Object(_) => "object".to_string(),
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::ir::{
643        FieldFormat, FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema,
644    };
645    use serde_json::json;
646    use std::collections::BTreeMap;
647    use uuid::Uuid;
648
649    fn uid(value: u128) -> Uid {
650        Uuid::from_u128(value)
651    }
652
653    #[test]
654    fn detects_duplicate_keys() {
655        let mut key = BTreeMap::new();
656        key.insert("slug".to_string(), serde_json::json!("fra1"));
657        let key = Key::from(key);
658        let type_schema = TypeSchema {
659            key: BTreeMap::from([(
660                "slug".to_string(),
661                FieldSchema {
662                    r#type: FieldType::Slug,
663                    required: true,
664                    nullable: false,
665                    description: None,
666                    format: None,
667                    pattern: None,
668                },
669            )]),
670            fields: BTreeMap::new(),
671        };
672        let objects = vec![
673            Object::new(
674                uid(1),
675                TypeName::new("site"),
676                key.clone(),
677                JsonMap::default(),
678            )
679            .unwrap(),
680            Object::new(uid(2), TypeName::new("site"), key, JsonMap::default()).unwrap(),
681        ];
682        let report = validate_inventory(&Inventory {
683            schema: Schema {
684                types: BTreeMap::from([("site".to_string(), type_schema)]),
685            },
686            objects,
687        });
688        assert!(report
689            .errors
690            .iter()
691            .any(|err| matches!(err, ValidationError::DuplicateKey(_))));
692    }
693
694    #[test]
695    fn detects_missing_key() {
696        let objects = vec![Object {
697            uid: uid(30),
698            type_name: TypeName::new("site"),
699            key: Key::default(),
700            attrs: JsonMap::default(),
701            source: None,
702        }];
703        let report = validate_inventory(&Inventory {
704            schema: Schema {
705                types: BTreeMap::from([(
706                    "site".to_string(),
707                    TypeSchema {
708                        key: BTreeMap::new(),
709                        fields: BTreeMap::new(),
710                    },
711                )]),
712            },
713            objects,
714        });
715        assert!(report
716            .errors
717            .iter()
718            .any(|err| matches!(err, ValidationError::MissingKey)));
719    }
720
721    #[test]
722    fn detects_missing_kind() {
723        let mut key = BTreeMap::new();
724        key.insert("slug".to_string(), serde_json::json!("fra1"));
725        let objects = vec![Object {
726            uid: uid(31),
727            type_name: TypeName::new(""),
728            key: Key::from(key),
729            attrs: JsonMap::default(),
730            source: None,
731        }];
732        let report = validate_inventory(&Inventory {
733            schema: Schema {
734                types: BTreeMap::new(),
735            },
736            objects,
737        });
738        assert!(report
739            .errors
740            .iter()
741            .any(|err| matches!(err, ValidationError::MissingType)));
742    }
743
744    #[test]
745    fn detects_unknown_type() {
746        let mut key = BTreeMap::new();
747        key.insert("slug".to_string(), serde_json::json!("leaf01"));
748        let objects = vec![Object::new(
749            uid(40),
750            TypeName::new("device"),
751            Key::from(key),
752            JsonMap::default(),
753        )
754        .unwrap()];
755        let schema = Schema {
756            types: BTreeMap::new(),
757        };
758        let report = validate_inventory(&Inventory { schema, objects });
759        assert!(report
760            .errors
761            .iter()
762            .any(|err| matches!(err, ValidationError::UnknownType(_))));
763    }
764
765    #[test]
766    fn detects_missing_references_with_schema() {
767        let mut key_fields = BTreeMap::new();
768        key_fields.insert(
769            "slug".to_string(),
770            FieldSchema {
771                r#type: FieldType::Slug,
772                required: true,
773                nullable: false,
774                description: None,
775                format: None,
776                pattern: None,
777            },
778        );
779        let mut fields = BTreeMap::new();
780        fields.insert(
781            "owner".to_string(),
782            FieldSchema {
783                r#type: FieldType::Ref {
784                    target: "person".to_string(),
785                },
786                required: false,
787                nullable: false,
788                description: None,
789                format: None,
790                pattern: None,
791            },
792        );
793        let mut types = BTreeMap::new();
794        types.insert(
795            "device".to_string(),
796            TypeSchema {
797                key: key_fields,
798                fields,
799            },
800        );
801        let schema = Schema { types };
802
803        let mut attrs = BTreeMap::new();
804        attrs.insert(
805            "owner".to_string(),
806            serde_json::json!(Uuid::from_u128(99).to_string()),
807        );
808        let mut key = BTreeMap::new();
809        key.insert("slug".to_string(), serde_json::json!("leaf01"));
810        let objects = vec![Object::new(
811            uid(41),
812            TypeName::new("device"),
813            Key::from(key),
814            attrs.into(),
815        )
816        .unwrap()];
817        let report = validate_inventory(&Inventory { schema, objects });
818        assert!(report
819            .errors
820            .iter()
821            .any(|err| matches!(err, ValidationError::MissingReference { .. })));
822    }
823
824    #[test]
825    fn with_sources_attaches_location_for_dotted_type() {
826        let mut key = BTreeMap::new();
827        key.insert("slug".to_string(), serde_json::json!("fra1"));
828        let object = Object::new(
829            uid(50),
830            TypeName::new("dcim.site"),
831            Key::from(key),
832            JsonMap::default(),
833        )
834        .unwrap()
835        .with_source(SourceLocation::file_line("inventory.yaml", 42));
836
837        let report = ValidationReport {
838            errors: vec![ValidationError::InvalidValue {
839                field: "dcim.site.key.slug".to_string(),
840                expected: "slug".to_string(),
841                actual: "FRA1".to_string(),
842            }],
843        };
844
845        let located = report.with_sources(&[object]);
846        assert_eq!(located.len(), 1);
847        assert_eq!(
848            located[0].source,
849            Some(SourceLocation::file_line("inventory.yaml", 42))
850        );
851    }
852
853    #[test]
854    fn test_field_value_validation() {
855        let uid_to_type = BTreeMap::from([(uid(1), TypeName::new("target"))]);
856        let mut report = ValidationReport::default();
857
858        // test Type Mismatch
859        let schema = FieldSchema {
860            r#type: FieldType::Int,
861            required: true,
862            nullable: false,
863            description: None,
864            format: None,
865            pattern: None,
866        };
867        validate_field_value(
868            &TypeName::new("test"),
869            "field",
870            &schema,
871            &json!("not-int"),
872            &uid_to_type,
873            &mut report,
874        );
875        assert!(report
876            .errors
877            .iter()
878            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
879
880        // test Enum
881        let schema = FieldSchema {
882            r#type: FieldType::Enum {
883                values: vec!["a".to_string(), "b".to_string()],
884            },
885            required: true,
886            nullable: false,
887            description: None,
888            format: None,
889            pattern: None,
890        };
891        report.errors.clear();
892        validate_field_value(
893            &TypeName::new("test"),
894            "field",
895            &schema,
896            &json!("c"),
897            &uid_to_type,
898            &mut report,
899        );
900        assert!(report
901            .errors
902            .iter()
903            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
904
905        // test Reference Type Mismatch
906        let schema = FieldSchema {
907            r#type: FieldType::Ref {
908                target: "wrong".to_string(),
909            },
910            required: true,
911            nullable: false,
912            description: None,
913            format: None,
914            pattern: None,
915        };
916        report.errors.clear();
917        validate_field_value(
918            &TypeName::new("test"),
919            "field",
920            &schema,
921            &json!(uid(1).to_string()),
922            &uid_to_type,
923            &mut report,
924        );
925        assert!(report
926            .errors
927            .iter()
928            .any(|e| matches!(e, ValidationError::ReferenceTypeMismatch { .. })));
929
930        // test ListRef
931        let schema = FieldSchema {
932            r#type: FieldType::ListRef {
933                target: "target".to_string(),
934            },
935            required: true,
936            nullable: false,
937            description: None,
938            format: None,
939            pattern: None,
940        };
941        report.errors.clear();
942        validate_field_value(
943            &TypeName::new("test"),
944            "field",
945            &schema,
946            &json!([uid(1).to_string()]),
947            &uid_to_type,
948            &mut report,
949        );
950        assert!(report.errors.is_empty());
951
952        // test Map
953        let schema = FieldSchema {
954            r#type: FieldType::Map {
955                value: Box::new(FieldType::Int),
956            },
957            required: true,
958            nullable: false,
959            description: None,
960            format: None,
961            pattern: None,
962        };
963        report.errors.clear();
964        validate_field_value(
965            &TypeName::new("test"),
966            "field",
967            &schema,
968            &json!({"a": 1, "b": "not-int"}),
969            &uid_to_type,
970            &mut report,
971        );
972        assert!(report
973            .errors
974            .iter()
975            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
976
977        // test Uuid
978        let schema = FieldSchema {
979            r#type: FieldType::Uuid,
980            required: true,
981            nullable: false,
982            description: None,
983            format: None,
984            pattern: None,
985        };
986        report.errors.clear();
987        validate_field_value(
988            &TypeName::new("test"),
989            "field",
990            &schema,
991            &json!("not-a-uuid"),
992            &uid_to_type,
993            &mut report,
994        );
995        assert!(report
996            .errors
997            .iter()
998            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
999
1000        // test List of Refs
1001        let schema = FieldSchema {
1002            r#type: FieldType::List {
1003                item: Box::new(FieldType::Ref {
1004                    target: "target".to_string(),
1005                }),
1006            },
1007            required: true,
1008            nullable: false,
1009            description: None,
1010            format: None,
1011            pattern: None,
1012        };
1013        report.errors.clear();
1014        validate_field_value(
1015            &TypeName::new("test"),
1016            "field",
1017            &schema,
1018            &json!([uid(1).to_string()]),
1019            &uid_to_type,
1020            &mut report,
1021        );
1022        assert!(report.errors.is_empty());
1023    }
1024
1025    // ----- string constraint (format / pattern) tests -----
1026
1027    /// build a string-typed field carrying a `format` constraint.
1028    fn fmt_field(format: FieldFormat) -> FieldSchema {
1029        FieldSchema {
1030            r#type: FieldType::String,
1031            required: true,
1032            nullable: false,
1033            description: None,
1034            format: Some(format),
1035            pattern: None,
1036        }
1037    }
1038
1039    /// build a string-typed field carrying a `pattern` constraint.
1040    fn pattern_field(pattern: &str) -> FieldSchema {
1041        FieldSchema {
1042            r#type: FieldType::String,
1043            required: true,
1044            nullable: false,
1045            description: None,
1046            format: None,
1047            pattern: Some(pattern.to_string()),
1048        }
1049    }
1050
1051    /// run `validate_field_value` against a value and return the report.
1052    fn check(schema: &FieldSchema, value: &serde_json::Value) -> ValidationReport {
1053        let uid_to_type: BTreeMap<Uid, TypeName> = BTreeMap::new();
1054        let mut report = ValidationReport::default();
1055        validate_field_value(
1056            &TypeName::new("test"),
1057            "field",
1058            schema,
1059            value,
1060            &uid_to_type,
1061            &mut report,
1062        );
1063        report
1064    }
1065
1066    fn has_invalid_value(report: &ValidationReport) -> bool {
1067        report
1068            .errors
1069            .iter()
1070            .any(|e| matches!(e, ValidationError::InvalidValue { .. }))
1071    }
1072
1073    #[test]
1074    fn format_slug_accepts_valid_and_rejects_invalid() {
1075        assert!(check(&fmt_field(FieldFormat::Slug), &json!("leaf-01"))
1076            .errors
1077            .is_empty());
1078        // trailing hyphen is not allowed by the slug regex.
1079        assert!(has_invalid_value(&check(
1080            &fmt_field(FieldFormat::Slug),
1081            &json!("leaf01-")
1082        )));
1083        // uppercase is not allowed either.
1084        assert!(has_invalid_value(&check(
1085            &fmt_field(FieldFormat::Slug),
1086            &json!("Leaf01")
1087        )));
1088    }
1089
1090    #[test]
1091    fn format_ip_address_accepts_valid_and_rejects_invalid() {
1092        assert!(
1093            check(&fmt_field(FieldFormat::IpAddress), &json!("10.0.0.1"))
1094                .errors
1095                .is_empty()
1096        );
1097        assert!(has_invalid_value(&check(
1098            &fmt_field(FieldFormat::IpAddress),
1099            &json!("not-an-ip")
1100        )));
1101    }
1102
1103    #[test]
1104    fn format_cidr_and_prefix_accept_valid_and_reject_invalid() {
1105        assert!(check(&fmt_field(FieldFormat::Cidr), &json!("10.0.0.0/24"))
1106            .errors
1107            .is_empty());
1108        assert!(
1109            check(&fmt_field(FieldFormat::Prefix), &json!("10.0.0.0/24"))
1110                .errors
1111                .is_empty()
1112        );
1113        assert!(has_invalid_value(&check(
1114            &fmt_field(FieldFormat::Cidr),
1115            &json!("not-a-cidr")
1116        )));
1117    }
1118
1119    #[test]
1120    fn format_mac_accepts_valid_and_rejects_invalid() {
1121        assert!(
1122            check(&fmt_field(FieldFormat::Mac), &json!("aa:bb:cc:dd:ee:ff"))
1123                .errors
1124                .is_empty()
1125        );
1126        // too short to be a full mac address.
1127        assert!(has_invalid_value(&check(
1128            &fmt_field(FieldFormat::Mac),
1129            &json!("aa:bb")
1130        )));
1131    }
1132
1133    #[test]
1134    fn format_uuid_accepts_valid_and_rejects_invalid() {
1135        assert!(
1136            check(&fmt_field(FieldFormat::Uuid), &json!(uid(1).to_string()))
1137                .errors
1138                .is_empty()
1139        );
1140        assert!(has_invalid_value(&check(
1141            &fmt_field(FieldFormat::Uuid),
1142            &json!("not-a-uuid")
1143        )));
1144    }
1145
1146    #[test]
1147    fn pattern_matches_and_mismatches() {
1148        assert!(check(&pattern_field(r"^[a-z]+$"), &json!("abc"))
1149            .errors
1150            .is_empty());
1151        assert!(has_invalid_value(&check(
1152            &pattern_field(r"^[a-z]+$"),
1153            &json!("ABC")
1154        )));
1155    }
1156
1157    #[test]
1158    fn invalid_pattern_reports_error_without_panicking() {
1159        // an unparsable regex must surface a clean InvalidValue, not panic.
1160        let report = check(&pattern_field("["), &json!("anything"));
1161        assert!(report.errors.iter().any(|e| matches!(
1162            e,
1163            ValidationError::InvalidValue { actual, .. } if actual.contains("invalid pattern")
1164        )));
1165    }
1166
1167    #[test]
1168    fn format_or_pattern_requires_string_value() {
1169        // a json base type accepts the number through the type check, so the
1170        // only error comes from the string-constraint `as_str` else-branch.
1171        let mut schema = fmt_field(FieldFormat::Slug);
1172        schema.r#type = FieldType::Json;
1173        let report = check(&schema, &json!(42));
1174        assert_eq!(report.errors.len(), 1);
1175        assert!(report.errors.iter().any(|e| matches!(
1176            e,
1177            ValidationError::InvalidValue { expected, .. } if expected == "string"
1178        )));
1179
1180        let mut schema = pattern_field(r"^\d+$");
1181        schema.r#type = FieldType::Json;
1182        let report = check(&schema, &json!(42));
1183        assert_eq!(report.errors.len(), 1);
1184        assert!(report.errors.iter().any(|e| matches!(
1185            e,
1186            ValidationError::InvalidValue { expected, .. } if expected == "string"
1187        )));
1188    }
1189}