1use 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#[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 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 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 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 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#[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#[derive(Debug, Default, Clone)]
141pub struct ValidationReport {
142 pub errors: Vec<ValidationError>,
143}
144
145impl ValidationReport {
146 pub fn is_ok(&self) -> bool {
148 self.errors.is_empty()
149 }
150
151 pub fn is_err(&self) -> bool {
153 !self.errors.is_empty()
154 }
155
156 pub fn with_sources(self, objects: &[Object]) -> Vec<LocatedError> {
161 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 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 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
217pub 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 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 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 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 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 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 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 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 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 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 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 assert!(has_invalid_value(&check(
1080 &fmt_field(FieldFormat::Slug),
1081 &json!("leaf01-")
1082 )));
1083 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 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 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 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}