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
99#[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#[derive(Debug, Default, Clone)]
131pub struct ValidationReport {
132 pub errors: Vec<ValidationError>,
133}
134
135impl ValidationReport {
136 pub fn is_ok(&self) -> bool {
138 self.errors.is_empty()
139 }
140
141 pub fn is_err(&self) -> bool {
143 !self.errors.is_empty()
144 }
145
146 pub fn with_sources(self, objects: &[Object]) -> Vec<LocatedError> {
151 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 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 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
194pub 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 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 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 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 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 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 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 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 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 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 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 assert!(has_invalid_value(&check(
1028 &fmt_field(FieldFormat::Slug),
1029 &json!("leaf01-")
1030 )));
1031 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 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 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 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}