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::{
620        FieldFormat, FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema,
621    };
622    use serde_json::json;
623    use std::collections::BTreeMap;
624    use uuid::Uuid;
625
626    fn uid(value: u128) -> Uid {
627        Uuid::from_u128(value)
628    }
629
630    #[test]
631    fn detects_duplicate_keys() {
632        let mut key = BTreeMap::new();
633        key.insert("slug".to_string(), serde_json::json!("fra1"));
634        let key = Key::from(key);
635        let type_schema = TypeSchema {
636            key: BTreeMap::from([(
637                "slug".to_string(),
638                FieldSchema {
639                    r#type: FieldType::Slug,
640                    required: true,
641                    nullable: false,
642                    description: None,
643                    format: None,
644                    pattern: None,
645                },
646            )]),
647            fields: BTreeMap::new(),
648        };
649        let objects = vec![
650            Object::new(
651                uid(1),
652                TypeName::new("site"),
653                key.clone(),
654                JsonMap::default(),
655            )
656            .unwrap(),
657            Object::new(uid(2), TypeName::new("site"), key, JsonMap::default()).unwrap(),
658        ];
659        let report = validate_inventory(&Inventory {
660            schema: Schema {
661                types: BTreeMap::from([("site".to_string(), type_schema)]),
662            },
663            objects,
664        });
665        assert!(report
666            .errors
667            .iter()
668            .any(|err| matches!(err, ValidationError::DuplicateKey(_))));
669    }
670
671    #[test]
672    fn detects_missing_key() {
673        let objects = vec![Object {
674            uid: uid(30),
675            type_name: TypeName::new("site"),
676            key: Key::default(),
677            attrs: JsonMap::default(),
678            source: None,
679        }];
680        let report = validate_inventory(&Inventory {
681            schema: Schema {
682                types: BTreeMap::from([(
683                    "site".to_string(),
684                    TypeSchema {
685                        key: BTreeMap::new(),
686                        fields: BTreeMap::new(),
687                    },
688                )]),
689            },
690            objects,
691        });
692        assert!(report
693            .errors
694            .iter()
695            .any(|err| matches!(err, ValidationError::MissingKey)));
696    }
697
698    #[test]
699    fn detects_missing_kind() {
700        let mut key = BTreeMap::new();
701        key.insert("slug".to_string(), serde_json::json!("fra1"));
702        let objects = vec![Object {
703            uid: uid(31),
704            type_name: TypeName::new(""),
705            key: Key::from(key),
706            attrs: JsonMap::default(),
707            source: None,
708        }];
709        let report = validate_inventory(&Inventory {
710            schema: Schema {
711                types: BTreeMap::new(),
712            },
713            objects,
714        });
715        assert!(report
716            .errors
717            .iter()
718            .any(|err| matches!(err, ValidationError::MissingType)));
719    }
720
721    #[test]
722    fn detects_unknown_type() {
723        let mut key = BTreeMap::new();
724        key.insert("slug".to_string(), serde_json::json!("leaf01"));
725        let objects = vec![Object::new(
726            uid(40),
727            TypeName::new("device"),
728            Key::from(key),
729            JsonMap::default(),
730        )
731        .unwrap()];
732        let schema = Schema {
733            types: BTreeMap::new(),
734        };
735        let report = validate_inventory(&Inventory { schema, objects });
736        assert!(report
737            .errors
738            .iter()
739            .any(|err| matches!(err, ValidationError::UnknownType(_))));
740    }
741
742    #[test]
743    fn detects_missing_references_with_schema() {
744        let mut key_fields = BTreeMap::new();
745        key_fields.insert(
746            "slug".to_string(),
747            FieldSchema {
748                r#type: FieldType::Slug,
749                required: true,
750                nullable: false,
751                description: None,
752                format: None,
753                pattern: None,
754            },
755        );
756        let mut fields = BTreeMap::new();
757        fields.insert(
758            "owner".to_string(),
759            FieldSchema {
760                r#type: FieldType::Ref {
761                    target: "person".to_string(),
762                },
763                required: false,
764                nullable: false,
765                description: None,
766                format: None,
767                pattern: None,
768            },
769        );
770        let mut types = BTreeMap::new();
771        types.insert(
772            "device".to_string(),
773            TypeSchema {
774                key: key_fields,
775                fields,
776            },
777        );
778        let schema = Schema { types };
779
780        let mut attrs = BTreeMap::new();
781        attrs.insert(
782            "owner".to_string(),
783            serde_json::json!(Uuid::from_u128(99).to_string()),
784        );
785        let mut key = BTreeMap::new();
786        key.insert("slug".to_string(), serde_json::json!("leaf01"));
787        let objects = vec![Object::new(
788            uid(41),
789            TypeName::new("device"),
790            Key::from(key),
791            attrs.into(),
792        )
793        .unwrap()];
794        let report = validate_inventory(&Inventory { schema, objects });
795        assert!(report
796            .errors
797            .iter()
798            .any(|err| matches!(err, ValidationError::MissingReference { .. })));
799    }
800
801    #[test]
802    fn test_field_value_validation() {
803        let uid_to_type = BTreeMap::from([(uid(1), TypeName::new("target"))]);
804        let mut report = ValidationReport::default();
805
806        // test Type Mismatch
807        let schema = FieldSchema {
808            r#type: FieldType::Int,
809            required: true,
810            nullable: false,
811            description: None,
812            format: None,
813            pattern: None,
814        };
815        validate_field_value(
816            &TypeName::new("test"),
817            "field",
818            &schema,
819            &json!("not-int"),
820            &uid_to_type,
821            &mut report,
822        );
823        assert!(report
824            .errors
825            .iter()
826            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
827
828        // test Enum
829        let schema = FieldSchema {
830            r#type: FieldType::Enum {
831                values: vec!["a".to_string(), "b".to_string()],
832            },
833            required: true,
834            nullable: false,
835            description: None,
836            format: None,
837            pattern: None,
838        };
839        report.errors.clear();
840        validate_field_value(
841            &TypeName::new("test"),
842            "field",
843            &schema,
844            &json!("c"),
845            &uid_to_type,
846            &mut report,
847        );
848        assert!(report
849            .errors
850            .iter()
851            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
852
853        // test Reference Type Mismatch
854        let schema = FieldSchema {
855            r#type: FieldType::Ref {
856                target: "wrong".to_string(),
857            },
858            required: true,
859            nullable: false,
860            description: None,
861            format: None,
862            pattern: None,
863        };
864        report.errors.clear();
865        validate_field_value(
866            &TypeName::new("test"),
867            "field",
868            &schema,
869            &json!(uid(1).to_string()),
870            &uid_to_type,
871            &mut report,
872        );
873        assert!(report
874            .errors
875            .iter()
876            .any(|e| matches!(e, ValidationError::ReferenceTypeMismatch { .. })));
877
878        // test ListRef
879        let schema = FieldSchema {
880            r#type: FieldType::ListRef {
881                target: "target".to_string(),
882            },
883            required: true,
884            nullable: false,
885            description: None,
886            format: None,
887            pattern: None,
888        };
889        report.errors.clear();
890        validate_field_value(
891            &TypeName::new("test"),
892            "field",
893            &schema,
894            &json!([uid(1).to_string()]),
895            &uid_to_type,
896            &mut report,
897        );
898        assert!(report.errors.is_empty());
899
900        // test Map
901        let schema = FieldSchema {
902            r#type: FieldType::Map {
903                value: Box::new(FieldType::Int),
904            },
905            required: true,
906            nullable: false,
907            description: None,
908            format: None,
909            pattern: None,
910        };
911        report.errors.clear();
912        validate_field_value(
913            &TypeName::new("test"),
914            "field",
915            &schema,
916            &json!({"a": 1, "b": "not-int"}),
917            &uid_to_type,
918            &mut report,
919        );
920        assert!(report
921            .errors
922            .iter()
923            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
924
925        // test Uuid
926        let schema = FieldSchema {
927            r#type: FieldType::Uuid,
928            required: true,
929            nullable: false,
930            description: None,
931            format: None,
932            pattern: None,
933        };
934        report.errors.clear();
935        validate_field_value(
936            &TypeName::new("test"),
937            "field",
938            &schema,
939            &json!("not-a-uuid"),
940            &uid_to_type,
941            &mut report,
942        );
943        assert!(report
944            .errors
945            .iter()
946            .any(|e| matches!(e, ValidationError::InvalidValue { .. })));
947
948        // test List of Refs
949        let schema = FieldSchema {
950            r#type: FieldType::List {
951                item: Box::new(FieldType::Ref {
952                    target: "target".to_string(),
953                }),
954            },
955            required: true,
956            nullable: false,
957            description: None,
958            format: None,
959            pattern: None,
960        };
961        report.errors.clear();
962        validate_field_value(
963            &TypeName::new("test"),
964            "field",
965            &schema,
966            &json!([uid(1).to_string()]),
967            &uid_to_type,
968            &mut report,
969        );
970        assert!(report.errors.is_empty());
971    }
972
973    // ----- string constraint (format / pattern) tests -----
974
975    /// build a string-typed field carrying a `format` constraint.
976    fn fmt_field(format: FieldFormat) -> FieldSchema {
977        FieldSchema {
978            r#type: FieldType::String,
979            required: true,
980            nullable: false,
981            description: None,
982            format: Some(format),
983            pattern: None,
984        }
985    }
986
987    /// build a string-typed field carrying a `pattern` constraint.
988    fn pattern_field(pattern: &str) -> FieldSchema {
989        FieldSchema {
990            r#type: FieldType::String,
991            required: true,
992            nullable: false,
993            description: None,
994            format: None,
995            pattern: Some(pattern.to_string()),
996        }
997    }
998
999    /// run `validate_field_value` against a value and return the report.
1000    fn check(schema: &FieldSchema, value: &serde_json::Value) -> ValidationReport {
1001        let uid_to_type: BTreeMap<Uid, TypeName> = BTreeMap::new();
1002        let mut report = ValidationReport::default();
1003        validate_field_value(
1004            &TypeName::new("test"),
1005            "field",
1006            schema,
1007            value,
1008            &uid_to_type,
1009            &mut report,
1010        );
1011        report
1012    }
1013
1014    fn has_invalid_value(report: &ValidationReport) -> bool {
1015        report
1016            .errors
1017            .iter()
1018            .any(|e| matches!(e, ValidationError::InvalidValue { .. }))
1019    }
1020
1021    #[test]
1022    fn format_slug_accepts_valid_and_rejects_invalid() {
1023        assert!(check(&fmt_field(FieldFormat::Slug), &json!("leaf-01"))
1024            .errors
1025            .is_empty());
1026        // trailing hyphen is not allowed by the slug regex.
1027        assert!(has_invalid_value(&check(
1028            &fmt_field(FieldFormat::Slug),
1029            &json!("leaf01-")
1030        )));
1031        // uppercase is not allowed either.
1032        assert!(has_invalid_value(&check(
1033            &fmt_field(FieldFormat::Slug),
1034            &json!("Leaf01")
1035        )));
1036    }
1037
1038    #[test]
1039    fn format_ip_address_accepts_valid_and_rejects_invalid() {
1040        assert!(
1041            check(&fmt_field(FieldFormat::IpAddress), &json!("10.0.0.1"))
1042                .errors
1043                .is_empty()
1044        );
1045        assert!(has_invalid_value(&check(
1046            &fmt_field(FieldFormat::IpAddress),
1047            &json!("not-an-ip")
1048        )));
1049    }
1050
1051    #[test]
1052    fn format_cidr_and_prefix_accept_valid_and_reject_invalid() {
1053        assert!(check(&fmt_field(FieldFormat::Cidr), &json!("10.0.0.0/24"))
1054            .errors
1055            .is_empty());
1056        assert!(
1057            check(&fmt_field(FieldFormat::Prefix), &json!("10.0.0.0/24"))
1058                .errors
1059                .is_empty()
1060        );
1061        assert!(has_invalid_value(&check(
1062            &fmt_field(FieldFormat::Cidr),
1063            &json!("not-a-cidr")
1064        )));
1065    }
1066
1067    #[test]
1068    fn format_mac_accepts_valid_and_rejects_invalid() {
1069        assert!(
1070            check(&fmt_field(FieldFormat::Mac), &json!("aa:bb:cc:dd:ee:ff"))
1071                .errors
1072                .is_empty()
1073        );
1074        // too short to be a full mac address.
1075        assert!(has_invalid_value(&check(
1076            &fmt_field(FieldFormat::Mac),
1077            &json!("aa:bb")
1078        )));
1079    }
1080
1081    #[test]
1082    fn format_uuid_accepts_valid_and_rejects_invalid() {
1083        assert!(
1084            check(&fmt_field(FieldFormat::Uuid), &json!(uid(1).to_string()))
1085                .errors
1086                .is_empty()
1087        );
1088        assert!(has_invalid_value(&check(
1089            &fmt_field(FieldFormat::Uuid),
1090            &json!("not-a-uuid")
1091        )));
1092    }
1093
1094    #[test]
1095    fn pattern_matches_and_mismatches() {
1096        assert!(check(&pattern_field(r"^[a-z]+$"), &json!("abc"))
1097            .errors
1098            .is_empty());
1099        assert!(has_invalid_value(&check(
1100            &pattern_field(r"^[a-z]+$"),
1101            &json!("ABC")
1102        )));
1103    }
1104
1105    #[test]
1106    fn invalid_pattern_reports_error_without_panicking() {
1107        // an unparsable regex must surface a clean InvalidValue, not panic.
1108        let report = check(&pattern_field("["), &json!("anything"));
1109        assert!(report.errors.iter().any(|e| matches!(
1110            e,
1111            ValidationError::InvalidValue { actual, .. } if actual.contains("invalid pattern")
1112        )));
1113    }
1114
1115    #[test]
1116    fn format_or_pattern_requires_string_value() {
1117        // a json base type accepts the number through the type check, so the
1118        // only error comes from the string-constraint `as_str` else-branch.
1119        let mut schema = fmt_field(FieldFormat::Slug);
1120        schema.r#type = FieldType::Json;
1121        let report = check(&schema, &json!(42));
1122        assert_eq!(report.errors.len(), 1);
1123        assert!(report.errors.iter().any(|e| matches!(
1124            e,
1125            ValidationError::InvalidValue { expected, .. } if expected == "string"
1126        )));
1127
1128        let mut schema = pattern_field(r"^\d+$");
1129        schema.r#type = FieldType::Json;
1130        let report = check(&schema, &json!(42));
1131        assert_eq!(report.errors.len(), 1);
1132        assert!(report.errors.iter().any(|e| matches!(
1133            e,
1134            ValidationError::InvalidValue { expected, .. } if expected == "string"
1135        )));
1136    }
1137}