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