use crate::ir::{
key_string, FieldFormat, FieldType, Inventory, Object, Schema, SourceLocation, TypeName, Uid,
};
use ipnet::IpNet;
use regex::Regex;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::net::IpAddr;
use std::sync::OnceLock;
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ValidationError {
#[error("duplicate uid: {0}")]
DuplicateUid(Uid),
#[error("duplicate key: {0}")]
DuplicateKey(String),
#[error("missing type on object")]
MissingType,
#[error("missing key on object")]
MissingKey,
#[error("missing key field {type_name}.{field}")]
MissingKeyField { type_name: String, field: String },
#[error("extra key field {type_name}.{field}")]
ExtraKeyField { type_name: String, field: String },
#[error("missing attr field {type_name}.{field}")]
MissingAttrField { type_name: String, field: String },
#[error("extra attr field {type_name}.{field}")]
ExtraAttrField { type_name: String, field: String },
#[error("invalid value for {field}: expected {expected}, got {actual}")]
InvalidValue {
field: String,
expected: String,
actual: String,
},
#[error("unknown type: {0}")]
UnknownType(String),
#[error("missing reference {field} -> {target}")]
MissingReference { field: String, target: Uid },
#[error("reference type mismatch {field} -> {target} (expected {expected}, got {actual})")]
ReferenceTypeMismatch {
field: String,
target: Uid,
expected: String,
actual: String,
},
#[error("unknown ref target {type_name}.{field} -> {target}")]
UnknownRefTarget {
type_name: String,
field: String,
target: String,
},
#[error("invalid pattern for {type_name}.{field}: {error} (pattern: {pattern})")]
InvalidSchemaPattern {
type_name: String,
field: String,
pattern: String,
error: String,
},
#[error("{constraint} constraint on non-string field {type_name}.{field} (type {field_type})")]
ConstraintOnNonStringField {
type_name: String,
field: String,
constraint: String,
field_type: String,
},
#[error("empty enum for {type_name}.{field}: an enum with no values is unsatisfiable")]
EmptyEnum { type_name: String, field: String },
}
impl ValidationError {
pub fn uid(&self) -> Option<Uid> {
match self {
ValidationError::DuplicateUid(uid) => Some(*uid),
ValidationError::MissingReference { target, .. } => Some(*target),
ValidationError::ReferenceTypeMismatch { target, .. } => Some(*target),
_ => None,
}
}
pub fn key_hint(&self) -> Option<String> {
match self {
ValidationError::DuplicateKey(key) => {
if let Some((_, k)) = key.split_once("::") {
Some(k.to_string())
} else {
Some(key.clone())
}
}
_ => None,
}
}
pub fn type_hint(&self) -> Option<String> {
match self {
ValidationError::UnknownType(t) => Some(t.clone()),
ValidationError::MissingKeyField { type_name, .. }
| ValidationError::ExtraKeyField { type_name, .. }
| ValidationError::MissingAttrField { type_name, .. }
| ValidationError::ExtraAttrField { type_name, .. } => Some(type_name.clone()),
ValidationError::InvalidValue { field, .. } => {
field.split('.').next().map(|s| s.to_string())
}
ValidationError::MissingReference { field, .. }
| ValidationError::ReferenceTypeMismatch { field, .. } => {
field.split('.').next().map(|s| s.to_string())
}
ValidationError::DuplicateKey(key) => key.split("::").next().map(|s| s.to_string()),
_ => None,
}
}
pub fn field(&self) -> Option<&str> {
match self {
ValidationError::InvalidValue { field, .. }
| ValidationError::MissingReference { field, .. }
| ValidationError::ReferenceTypeMismatch { field, .. } => Some(field),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct LocatedError {
pub error: ValidationError,
pub source: Option<SourceLocation>,
}
impl LocatedError {
pub fn new(error: ValidationError) -> Self {
Self {
error,
source: None,
}
}
pub fn with_source(error: ValidationError, source: Option<SourceLocation>) -> Self {
Self { error, source }
}
}
impl fmt::Display for LocatedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(source) = &self.source {
write!(f, "{}: {}", source, self.error)
} else {
write!(f, "{}", self.error)
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ValidationReport {
pub errors: Vec<ValidationError>,
}
impl ValidationReport {
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
pub fn is_err(&self) -> bool {
!self.errors.is_empty()
}
pub fn with_sources(self, objects: &[Object]) -> Vec<LocatedError> {
let uid_to_source: BTreeMap<Uid, Option<SourceLocation>> =
objects.iter().map(|o| (o.uid, o.source.clone())).collect();
let key_to_source: BTreeMap<String, Option<SourceLocation>> = objects
.iter()
.map(|o| {
let key = format!("{}::{}", o.type_name, key_string(&o.key));
(key, o.source.clone())
})
.collect();
let type_to_source: BTreeMap<String, Option<SourceLocation>> = objects
.iter()
.filter_map(|o| o.source.clone().map(|s| (o.type_name.to_string(), Some(s))))
.collect();
let known_types: BTreeSet<&str> = objects.iter().map(|o| o.type_name.as_str()).collect();
self.errors
.into_iter()
.map(|error| {
let source = error
.uid()
.filter(|_| matches!(error, ValidationError::DuplicateUid(_)))
.and_then(|uid| uid_to_source.get(&uid).cloned().flatten())
.or_else(|| {
error.key_hint().and_then(|_| {
if let ValidationError::DuplicateKey(key) = &error {
key_to_source.get(key).cloned().flatten()
} else {
None
}
})
})
.or_else(|| {
if let Some(field) = error.field() {
known_types
.iter()
.filter(|t| {
field.strip_prefix(**t).is_some_and(|rest| {
rest.is_empty() || rest.starts_with('.')
})
})
.max_by_key(|t| t.len())
.and_then(|t| type_to_source.get(*t).cloned().flatten())
} else {
error
.type_hint()
.and_then(|t| type_to_source.get(&t).cloned().flatten())
}
});
LocatedError::with_source(error, source)
})
.collect()
}
}
pub fn validate_inventory(inventory: &Inventory) -> ValidationReport {
let mut report = ValidationReport::default();
let mut seen_uids = BTreeSet::new();
let mut seen_keys = BTreeSet::new();
let mut uid_to_type = BTreeMap::new();
for object in &inventory.objects {
if object.key.is_empty() {
report.errors.push(ValidationError::MissingKey);
}
if object.type_name.is_empty() {
report.errors.push(ValidationError::MissingType);
}
if !seen_uids.insert(object.uid) {
report
.errors
.push(ValidationError::DuplicateUid(object.uid));
}
let key = format!("{}::{}", object.type_name, key_string(&object.key));
if !seen_keys.insert(key.clone()) {
report.errors.push(ValidationError::DuplicateKey(key));
}
uid_to_type.insert(object.uid, object.type_name.clone());
}
validate_schema_ref_targets(&inventory.schema, &mut report);
validate_schema_patterns(&inventory.schema, &mut report);
validate_schema_constraint_types(&inventory.schema, &mut report);
validate_schema_enums(&inventory.schema, &mut report);
validate_schema_types(&inventory.schema, &inventory.objects, &mut report);
for object in &inventory.objects {
validate_object(object, &inventory.schema, &uid_to_type, &mut report);
}
report
}
fn validate_schema_ref_targets(schema: &Schema, report: &mut ValidationReport) {
for (type_name, type_schema) in &schema.types {
for (field, field_schema) in &type_schema.key {
validate_field_ref_targets(
schema,
type_name,
&format!("key.{field}"),
&field_schema.r#type,
report,
);
}
for (field, field_schema) in &type_schema.fields {
validate_field_ref_targets(schema, type_name, field, &field_schema.r#type, report);
}
}
}
fn validate_field_ref_targets(
schema: &Schema,
type_name: &str,
field: &str,
field_type: &FieldType,
report: &mut ValidationReport,
) {
match field_type {
FieldType::Ref { target } | FieldType::ListRef { target } => {
if !schema.types.contains_key(target) {
report.errors.push(ValidationError::UnknownRefTarget {
type_name: type_name.to_string(),
field: field.to_string(),
target: target.to_string(),
});
}
}
FieldType::List { item } => {
validate_field_ref_targets(schema, type_name, field, item, report);
}
FieldType::Map { value } => {
validate_field_ref_targets(schema, type_name, field, value, report);
}
FieldType::String
| FieldType::Text
| FieldType::Int
| FieldType::Float
| FieldType::Bool
| FieldType::Uuid
| FieldType::Date
| FieldType::Datetime
| FieldType::Time
| FieldType::Json
| FieldType::IpAddress
| FieldType::Cidr
| FieldType::Prefix
| FieldType::Mac
| FieldType::Slug
| FieldType::Enum { .. } => {}
}
}
fn validate_schema_patterns(schema: &Schema, report: &mut ValidationReport) {
for (type_name, type_schema) in &schema.types {
for (field, field_schema) in &type_schema.key {
validate_field_pattern(type_name, &format!("key.{field}"), field_schema, report);
}
for (field, field_schema) in &type_schema.fields {
validate_field_pattern(type_name, field, field_schema, report);
}
}
}
fn validate_field_pattern(
type_name: &str,
field: &str,
field_schema: &crate::ir::FieldSchema,
report: &mut ValidationReport,
) {
let Some(pattern) = &field_schema.pattern else {
return;
};
if let Err(err) = Regex::new(pattern) {
report.errors.push(ValidationError::InvalidSchemaPattern {
type_name: type_name.to_string(),
field: field.to_string(),
pattern: pattern.to_string(),
error: err.to_string(),
});
}
}
fn validate_schema_constraint_types(schema: &Schema, report: &mut ValidationReport) {
for (type_name, type_schema) in &schema.types {
for (field, field_schema) in &type_schema.key {
validate_field_constraint_type(
type_name,
&format!("key.{field}"),
field_schema,
report,
);
}
for (field, field_schema) in &type_schema.fields {
validate_field_constraint_type(type_name, field, field_schema, report);
}
}
}
fn validate_field_constraint_type(
type_name: &str,
field: &str,
field_schema: &crate::ir::FieldSchema,
report: &mut ValidationReport,
) {
if !is_never_string_type(&field_schema.r#type) {
return;
}
let field_type = field_type_label(&field_schema.r#type);
for (present, constraint) in [
(field_schema.format.is_some(), "format"),
(field_schema.pattern.is_some(), "pattern"),
] {
if present {
report
.errors
.push(ValidationError::ConstraintOnNonStringField {
type_name: type_name.to_string(),
field: field.to_string(),
constraint: constraint.to_string(),
field_type: field_type.clone(),
});
}
}
}
fn is_never_string_type(field_type: &FieldType) -> bool {
match field_type {
FieldType::Int
| FieldType::Float
| FieldType::Bool
| FieldType::List { .. }
| FieldType::Map { .. }
| FieldType::ListRef { .. } => true,
FieldType::String
| FieldType::Text
| FieldType::Date
| FieldType::Datetime
| FieldType::Time
| FieldType::IpAddress
| FieldType::Uuid
| FieldType::Cidr
| FieldType::Prefix
| FieldType::Mac
| FieldType::Slug
| FieldType::Enum { .. }
| FieldType::Ref { .. }
| FieldType::Json => false,
}
}
fn validate_schema_enums(schema: &Schema, report: &mut ValidationReport) {
for (type_name, type_schema) in &schema.types {
for (field, field_schema) in &type_schema.key {
validate_field_enum(
type_name,
&format!("key.{field}"),
&field_schema.r#type,
report,
);
}
for (field, field_schema) in &type_schema.fields {
validate_field_enum(type_name, field, &field_schema.r#type, report);
}
}
}
fn validate_field_enum(
type_name: &str,
field: &str,
field_type: &FieldType,
report: &mut ValidationReport,
) {
match field_type {
FieldType::Enum { values } => {
if values.is_empty() {
report.errors.push(ValidationError::EmptyEnum {
type_name: type_name.to_string(),
field: field.to_string(),
});
}
}
FieldType::List { item } => {
validate_field_enum(type_name, field, item, report);
}
FieldType::Map { value } => {
validate_field_enum(type_name, field, value, report);
}
FieldType::String
| FieldType::Text
| FieldType::Int
| FieldType::Float
| FieldType::Bool
| FieldType::Uuid
| FieldType::Date
| FieldType::Datetime
| FieldType::Time
| FieldType::Json
| FieldType::IpAddress
| FieldType::Cidr
| FieldType::Prefix
| FieldType::Mac
| FieldType::Slug
| FieldType::Ref { .. }
| FieldType::ListRef { .. } => {}
}
}
fn validate_schema_types(schema: &Schema, objects: &[Object], report: &mut ValidationReport) {
for object in objects {
if !schema.types.contains_key(object.type_name.as_str()) {
report
.errors
.push(ValidationError::UnknownType(object.type_name.to_string()));
}
}
}
fn validate_object(
object: &Object,
schema: &Schema,
uid_to_type: &BTreeMap<Uid, TypeName>,
report: &mut ValidationReport,
) {
let Some(type_schema) = schema.types.get(object.type_name.as_str()) else {
return;
};
validate_key_fields(object, type_schema, uid_to_type, report);
validate_attr_fields(object, type_schema, uid_to_type, report);
}
fn validate_key_fields(
object: &Object,
type_schema: &crate::ir::TypeSchema,
uid_to_type: &BTreeMap<Uid, TypeName>,
report: &mut ValidationReport,
) {
for (field, field_schema) in &type_schema.key {
let Some(value) = object.key.get(field) else {
report.errors.push(ValidationError::MissingKeyField {
type_name: object.type_name.to_string(),
field: field.to_string(),
});
continue;
};
validate_field_value(
&object.type_name,
&format!("key.{field}"),
field_schema,
value,
uid_to_type,
report,
);
}
for field in object.key.keys() {
if !type_schema.key.contains_key(field) {
report.errors.push(ValidationError::ExtraKeyField {
type_name: object.type_name.to_string(),
field: field.to_string(),
});
}
}
}
fn validate_attr_fields(
object: &Object,
type_schema: &crate::ir::TypeSchema,
uid_to_type: &BTreeMap<Uid, TypeName>,
report: &mut ValidationReport,
) {
for (field, field_schema) in &type_schema.fields {
let Some(value) = object.attrs.get(field) else {
if field_schema.required {
report.errors.push(ValidationError::MissingAttrField {
type_name: object.type_name.to_string(),
field: field.to_string(),
});
}
continue;
};
validate_field_value(
&object.type_name,
field,
field_schema,
value,
uid_to_type,
report,
);
}
for field in object.attrs.keys() {
if !type_schema.fields.contains_key(field) {
report.errors.push(ValidationError::ExtraAttrField {
type_name: object.type_name.to_string(),
field: field.to_string(),
});
}
}
}
fn validate_field_value(
type_name: &TypeName,
field: &str,
field_schema: &crate::ir::FieldSchema,
value: &Value,
uid_to_type: &BTreeMap<Uid, TypeName>,
report: &mut ValidationReport,
) {
if value.is_null() {
if field_schema.nullable {
return;
}
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: field_type_label(&field_schema.r#type),
actual: "null".to_string(),
});
return;
}
match &field_schema.r#type {
FieldType::Ref { target } => {
validate_ref(type_name, field, target, value, uid_to_type, report);
}
FieldType::ListRef { target } => {
if let Some(entries) = value.as_array() {
for entry in entries {
validate_ref(type_name, field, target, entry, uid_to_type, report);
}
} else {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: "list_ref".to_string(),
actual: value_type_label(value),
});
}
}
FieldType::List { item } => {
if let Some(entries) = value.as_array() {
for entry in entries {
let schema = crate::ir::FieldSchema {
r#type: (**item).clone(),
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
}
} else {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: "list".to_string(),
actual: value_type_label(value),
});
}
}
FieldType::Map { value: inner } => {
if let Some(entries) = value.as_object() {
for entry in entries.values() {
let schema = crate::ir::FieldSchema {
r#type: (**inner).clone(),
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
validate_field_value(type_name, field, &schema, entry, uid_to_type, report);
}
} else {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: "map".to_string(),
actual: value_type_label(value),
});
}
}
FieldType::Enum { values } => {
if let Some(raw) = value.as_str() {
if !values.contains(&raw.to_string()) {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: format!("enum({})", values.join("|")),
actual: raw.to_string(),
});
}
} else {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: "enum".to_string(),
actual: value_type_label(value),
});
}
}
_ => {
if !value_matches_type(value, &field_schema.r#type) {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: field_type_label(&field_schema.r#type),
actual: value_type_label(value),
});
}
}
}
validate_string_constraints(type_name, field, field_schema, value, report);
}
fn parse_uid(value: &Value) -> Option<Uid> {
let raw = value.as_str()?;
Uid::parse_str(raw).ok()
}
fn validate_ref(
type_name: &TypeName,
field: &str,
target: &str,
value: &Value,
uid_to_type: &BTreeMap<Uid, TypeName>,
report: &mut ValidationReport,
) {
let Some(uid) = parse_uid(value) else {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: "uuid".to_string(),
actual: value_type_label(value),
});
return;
};
let Some(actual) = uid_to_type.get(&uid) else {
report.errors.push(ValidationError::MissingReference {
field: format!("{type_name}.{field}"),
target: uid,
});
return;
};
if actual.as_str() != target {
report.errors.push(ValidationError::ReferenceTypeMismatch {
field: format!("{type_name}.{field}"),
target: uid,
expected: target.to_string(),
actual: actual.to_string(),
});
}
}
fn validate_string_constraints(
type_name: &TypeName,
field: &str,
field_schema: &crate::ir::FieldSchema,
value: &Value,
report: &mut ValidationReport,
) {
if field_schema.format.is_none() && field_schema.pattern.is_none() {
return;
}
let Some(raw) = value.as_str() else {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: "string".to_string(),
actual: value_type_label(value),
});
return;
};
if let Some(format) = &field_schema.format {
if !matches_format(format, raw) {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: format_label(format),
actual: raw.to_string(),
});
}
}
if let Some(pattern) = &field_schema.pattern {
match Regex::new(pattern) {
Ok(regex) => {
if !regex.is_match(raw) {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: format!("pattern({pattern})"),
actual: raw.to_string(),
});
}
}
Err(err) => {
report.errors.push(ValidationError::InvalidValue {
field: format!("{type_name}.{field}"),
expected: format!("pattern({pattern})"),
actual: format!("invalid pattern: {err}"),
});
}
}
}
}
fn slug_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$").unwrap())
}
fn mac_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$").unwrap())
}
fn matches_format(format: &FieldFormat, raw: &str) -> bool {
match format {
FieldFormat::Slug => slug_regex().is_match(raw),
FieldFormat::IpAddress => raw.parse::<IpAddr>().is_ok(),
FieldFormat::Cidr | FieldFormat::Prefix => raw.parse::<IpNet>().is_ok(),
FieldFormat::Mac => mac_regex().is_match(raw),
FieldFormat::Uuid => Uid::parse_str(raw).is_ok(),
}
}
fn format_label(format: &FieldFormat) -> String {
match format {
FieldFormat::Slug => "format(slug)".to_string(),
FieldFormat::IpAddress => "format(ip_address)".to_string(),
FieldFormat::Cidr => "format(cidr)".to_string(),
FieldFormat::Prefix => "format(prefix)".to_string(),
FieldFormat::Mac => "format(mac)".to_string(),
FieldFormat::Uuid => "format(uuid)".to_string(),
}
}
fn value_matches_type(value: &Value, field_type: &FieldType) -> bool {
match field_type {
FieldType::String
| FieldType::Text
| FieldType::Date
| FieldType::Datetime
| FieldType::Time
| FieldType::IpAddress => value.is_string(),
FieldType::Uuid => value_matches_format(value, &FieldFormat::Uuid),
FieldType::Cidr => value_matches_format(value, &FieldFormat::Cidr),
FieldType::Prefix => value_matches_format(value, &FieldFormat::Prefix),
FieldType::Mac => value_matches_format(value, &FieldFormat::Mac),
FieldType::Slug => value_matches_format(value, &FieldFormat::Slug),
FieldType::Int => value.is_i64() || value.is_u64(),
FieldType::Float => value.as_f64().is_some() || value.is_i64() || value.is_u64(),
FieldType::Bool => value.is_boolean(),
FieldType::Json => true,
FieldType::Enum { .. } => value.is_string(),
FieldType::List { .. } => value.is_array(),
FieldType::Map { .. } => value.is_object(),
FieldType::Ref { .. } | FieldType::ListRef { .. } => true,
}
}
fn value_matches_format(value: &Value, format: &FieldFormat) -> bool {
value
.as_str()
.map(|raw| matches_format(format, raw))
.unwrap_or(false)
}
fn field_type_label(field_type: &FieldType) -> String {
match field_type {
FieldType::String => "string".to_string(),
FieldType::Text => "text".to_string(),
FieldType::Int => "int".to_string(),
FieldType::Float => "float".to_string(),
FieldType::Bool => "bool".to_string(),
FieldType::Uuid => "uuid".to_string(),
FieldType::Date => "date".to_string(),
FieldType::Datetime => "datetime".to_string(),
FieldType::Time => "time".to_string(),
FieldType::Json => "json".to_string(),
FieldType::IpAddress => "ip_address".to_string(),
FieldType::Cidr => "cidr".to_string(),
FieldType::Prefix => "prefix".to_string(),
FieldType::Mac => "mac".to_string(),
FieldType::Slug => "slug".to_string(),
FieldType::Enum { .. } => "enum".to_string(),
FieldType::List { .. } => "list".to_string(),
FieldType::Map { .. } => "map".to_string(),
FieldType::Ref { target } => format!("ref({target})"),
FieldType::ListRef { target } => format!("list_ref({target})"),
}
}
fn value_type_label(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(_) => "bool".to_string(),
Value::Number(_) => "number".to_string(),
Value::String(_) => "string".to_string(),
Value::Array(_) => "array".to_string(),
Value::Object(_) => "object".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{
FieldFormat, FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema,
};
use serde_json::json;
use std::collections::BTreeMap;
use uuid::Uuid;
fn uid(value: u128) -> Uid {
Uuid::from_u128(value)
}
#[test]
fn detects_duplicate_keys() {
let mut key = BTreeMap::new();
key.insert("slug".to_string(), serde_json::json!("fra1"));
let key = Key::from(key);
let type_schema = TypeSchema {
key: BTreeMap::from([(
"slug".to_string(),
FieldSchema {
r#type: FieldType::Slug,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
},
)]),
fields: BTreeMap::new(),
};
let objects = vec![
Object::new(
uid(1),
TypeName::new("site"),
key.clone(),
JsonMap::default(),
)
.unwrap(),
Object::new(uid(2), TypeName::new("site"), key, JsonMap::default()).unwrap(),
];
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::from([("site".to_string(), type_schema)]),
},
objects,
});
assert!(report
.errors
.iter()
.any(|err| matches!(err, ValidationError::DuplicateKey(_))));
}
#[test]
fn detects_missing_key() {
let objects = vec![Object {
uid: uid(30),
type_name: TypeName::new("site"),
key: Key::default(),
attrs: JsonMap::default(),
source: None,
}];
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::from([(
"site".to_string(),
TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::new(),
},
)]),
},
objects,
});
assert!(report
.errors
.iter()
.any(|err| matches!(err, ValidationError::MissingKey)));
}
#[test]
fn detects_missing_kind() {
let mut key = BTreeMap::new();
key.insert("slug".to_string(), serde_json::json!("fra1"));
let objects = vec![Object {
uid: uid(31),
type_name: TypeName::new(""),
key: Key::from(key),
attrs: JsonMap::default(),
source: None,
}];
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::new(),
},
objects,
});
assert!(report
.errors
.iter()
.any(|err| matches!(err, ValidationError::MissingType)));
}
#[test]
fn detects_unknown_type() {
let mut key = BTreeMap::new();
key.insert("slug".to_string(), serde_json::json!("leaf01"));
let objects = vec![Object::new(
uid(40),
TypeName::new("device"),
Key::from(key),
JsonMap::default(),
)
.unwrap()];
let schema = Schema {
types: BTreeMap::new(),
};
let report = validate_inventory(&Inventory { schema, objects });
assert!(report
.errors
.iter()
.any(|err| matches!(err, ValidationError::UnknownType(_))));
}
#[test]
fn detects_missing_references_with_schema() {
let mut key_fields = BTreeMap::new();
key_fields.insert(
"slug".to_string(),
FieldSchema {
r#type: FieldType::Slug,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
},
);
let mut fields = BTreeMap::new();
fields.insert(
"owner".to_string(),
FieldSchema {
r#type: FieldType::Ref {
target: "person".to_string(),
},
required: false,
nullable: false,
description: None,
format: None,
pattern: None,
},
);
let mut types = BTreeMap::new();
types.insert(
"device".to_string(),
TypeSchema {
key: key_fields,
fields,
},
);
let schema = Schema { types };
let mut attrs = BTreeMap::new();
attrs.insert(
"owner".to_string(),
serde_json::json!(Uuid::from_u128(99).to_string()),
);
let mut key = BTreeMap::new();
key.insert("slug".to_string(), serde_json::json!("leaf01"));
let objects = vec![Object::new(
uid(41),
TypeName::new("device"),
Key::from(key),
attrs.into(),
)
.unwrap()];
let report = validate_inventory(&Inventory { schema, objects });
assert!(report
.errors
.iter()
.any(|err| matches!(err, ValidationError::MissingReference { .. })));
}
fn schema_field(r#type: FieldType) -> FieldSchema {
FieldSchema {
r#type,
required: false,
nullable: false,
description: None,
format: None,
pattern: None,
}
}
fn validate_schema(types: BTreeMap<String, TypeSchema>) -> ValidationReport {
validate_inventory(&Inventory {
schema: Schema { types },
objects: vec![],
})
}
#[test]
fn detects_unknown_ref_target_in_attr_field() {
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([(
"owner".to_string(),
schema_field(FieldType::Ref {
target: "person".to_string(),
}),
)]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::UnknownRefTarget { type_name, field, target }
if type_name == "device" && field == "owner" && target == "person"
)));
}
#[test]
fn detects_unknown_ref_target_in_key_field() {
let device = TypeSchema {
key: BTreeMap::from([(
"site".to_string(),
schema_field(FieldType::Ref {
target: "place".to_string(),
}),
)]),
fields: BTreeMap::new(),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::UnknownRefTarget { field, target, .. }
if field == "key.site" && target == "place"
)));
}
#[test]
fn detects_unknown_ref_target_nested_in_list_and_map() {
let group = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([
(
"members".to_string(),
schema_field(FieldType::List {
item: Box::new(FieldType::Ref {
target: "ghost".to_string(),
}),
}),
),
(
"roles".to_string(),
schema_field(FieldType::Map {
value: Box::new(FieldType::ListRef {
target: "phantom".to_string(),
}),
}),
),
]),
};
let report = validate_schema(BTreeMap::from([("group".to_string(), group)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::UnknownRefTarget { target, .. } if target == "ghost"
)));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::UnknownRefTarget { target, .. } if target == "phantom"
)));
}
#[test]
fn valid_ref_targets_pass_schema_validation() {
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([
(
"owner".to_string(),
schema_field(FieldType::Ref {
target: "person".to_string(),
}),
),
(
"watchers".to_string(),
schema_field(FieldType::List {
item: Box::new(FieldType::ListRef {
target: "person".to_string(),
}),
}),
),
]),
};
let person = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::new(),
};
let report = validate_schema(BTreeMap::from([
("device".to_string(), device),
("person".to_string(), person),
]));
assert!(!report
.errors
.iter()
.any(|e| matches!(e, ValidationError::UnknownRefTarget { .. })));
}
#[test]
fn detects_invalid_pattern_in_attr_field() {
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("name".to_string(), pattern_field("[unclosed"))]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
let count = report
.errors
.iter()
.filter(|e| {
matches!(
e,
ValidationError::InvalidSchemaPattern { type_name, field, .. }
if type_name == "device" && field == "name"
)
})
.count();
assert_eq!(count, 1);
}
#[test]
fn detects_invalid_pattern_in_key_field() {
let device = TypeSchema {
key: BTreeMap::from([("slug".to_string(), pattern_field("(unbalanced"))]),
fields: BTreeMap::new(),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::InvalidSchemaPattern { type_name, field, .. }
if type_name == "device" && field == "key.slug"
)));
}
#[test]
fn detects_invalid_pattern_for_type_with_no_objects() {
let ghost = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("name".to_string(), pattern_field("[bad"))]),
};
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::from([("ghost".to_string(), ghost)]),
},
objects: vec![],
});
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::InvalidSchemaPattern { type_name, field, .. }
if type_name == "ghost" && field == "name"
)));
}
#[test]
fn accumulates_invalid_patterns_across_fields_and_types() {
let device = TypeSchema {
key: BTreeMap::from([("slug".to_string(), pattern_field("(bad"))]),
fields: BTreeMap::from([("name".to_string(), pattern_field("[bad"))]),
};
let site = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("code".to_string(), pattern_field("*bad"))]),
};
let report = validate_schema(BTreeMap::from([
("device".to_string(), device),
("site".to_string(), site),
]));
let count = report
.errors
.iter()
.filter(|e| matches!(e, ValidationError::InvalidSchemaPattern { .. }))
.count();
assert_eq!(count, 3);
}
#[test]
fn valid_and_absent_patterns_pass_schema_validation() {
let device = TypeSchema {
key: BTreeMap::from([("slug".to_string(), pattern_field("^[a-z0-9-]+$"))]),
fields: BTreeMap::from([
("name".to_string(), pattern_field("^[A-Za-z ]+$")),
("count".to_string(), schema_field(FieldType::Int)),
]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(!report
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidSchemaPattern { .. })));
}
#[test]
fn detects_format_on_int_field() {
let mut count = schema_field(FieldType::Int);
count.format = Some(FieldFormat::Slug);
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("count".to_string(), count)]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::ConstraintOnNonStringField { type_name, field, constraint, field_type }
if type_name == "device"
&& field == "count"
&& constraint == "format"
&& field_type == "int"
)));
}
#[test]
fn detects_pattern_on_list_field() {
let mut tags = schema_field(FieldType::List {
item: Box::new(FieldType::String),
});
tags.pattern = Some("^x$".to_string());
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("tags".to_string(), tags)]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::ConstraintOnNonStringField { type_name, field, constraint, field_type }
if type_name == "device"
&& field == "tags"
&& constraint == "pattern"
&& field_type == "list"
)));
}
#[test]
fn format_and_pattern_on_non_string_field_yield_two_errors() {
let mut flag = schema_field(FieldType::Bool);
flag.format = Some(FieldFormat::Slug);
flag.pattern = Some("^x$".to_string());
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("flag".to_string(), flag)]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
let count = report
.errors
.iter()
.filter(|e| matches!(e, ValidationError::ConstraintOnNonStringField { .. }))
.count();
assert_eq!(count, 2);
}
#[test]
fn detects_constraint_on_non_string_field_for_type_with_no_objects() {
let mut count = schema_field(FieldType::Int);
count.pattern = Some("^[0-9]+$".to_string());
let ghost = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([("count".to_string(), count)]),
};
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::from([("ghost".to_string(), ghost)]),
},
objects: vec![],
});
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::ConstraintOnNonStringField { type_name, field, .. }
if type_name == "ghost" && field == "count"
)));
}
#[test]
fn string_valued_fields_accept_format_and_pattern() {
let mut name = schema_field(FieldType::String);
name.pattern = Some("^[a-z]+$".to_string());
let mut handle = schema_field(FieldType::Slug);
handle.format = Some(FieldFormat::Slug);
let mut role = schema_field(FieldType::Enum {
values: vec!["leaf".to_string()],
});
role.pattern = Some("^[a-z]+$".to_string());
let mut owner = schema_field(FieldType::Ref {
target: "person".to_string(),
});
owner.format = Some(FieldFormat::Uuid);
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([
("name".to_string(), name),
("handle".to_string(), handle),
("role".to_string(), role),
("owner".to_string(), owner),
]),
};
let person = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::new(),
};
let report = validate_schema(BTreeMap::from([
("device".to_string(), device),
("person".to_string(), person),
]));
assert!(!report
.errors
.iter()
.any(|e| matches!(e, ValidationError::ConstraintOnNonStringField { .. })));
}
#[test]
fn detects_empty_enum_in_attr_field() {
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([(
"role".to_string(),
schema_field(FieldType::Enum { values: vec![] }),
)]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::EmptyEnum { type_name, field }
if type_name == "device" && field == "role"
)));
}
#[test]
fn detects_empty_enum_nested_in_list_field() {
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([(
"roles".to_string(),
schema_field(FieldType::List {
item: Box::new(FieldType::Enum { values: vec![] }),
}),
)]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::EmptyEnum { type_name, field }
if type_name == "device" && field == "roles"
)));
}
#[test]
fn non_empty_enum_passes_schema_validation() {
let device = TypeSchema {
key: BTreeMap::new(),
fields: BTreeMap::from([
(
"role".to_string(),
schema_field(FieldType::Enum {
values: vec!["leaf".to_string(), "spine".to_string()],
}),
),
(
"roles".to_string(),
schema_field(FieldType::List {
item: Box::new(FieldType::Enum {
values: vec!["a".to_string()],
}),
}),
),
]),
};
let report = validate_schema(BTreeMap::from([("device".to_string(), device)]));
assert!(!report
.errors
.iter()
.any(|e| matches!(e, ValidationError::EmptyEnum { .. })));
}
#[test]
fn detects_empty_enum_for_type_with_no_objects() {
let ghost = TypeSchema {
key: BTreeMap::from([(
"role".to_string(),
schema_field(FieldType::Enum { values: vec![] }),
)]),
fields: BTreeMap::new(),
};
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::from([("ghost".to_string(), ghost)]),
},
objects: vec![],
});
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::EmptyEnum { type_name, field }
if type_name == "ghost" && field == "key.role"
)));
}
#[test]
fn accumulates_errors_for_multiple_invalid_fields() {
let field = |r#type| FieldSchema {
r#type,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
let type_schema = TypeSchema {
key: BTreeMap::from([("slug".to_string(), field(FieldType::Slug))]),
fields: BTreeMap::from([
("count".to_string(), field(FieldType::Int)),
("enabled".to_string(), field(FieldType::Bool)),
]),
};
let mut key = BTreeMap::new();
key.insert("slug".to_string(), json!("leaf01"));
let mut attrs = BTreeMap::new();
attrs.insert("count".to_string(), json!("not-an-int"));
attrs.insert("enabled".to_string(), json!("not-a-bool"));
let objects = vec![Object::new(
uid(1),
TypeName::new("device"),
Key::from(key),
attrs.into(),
)
.unwrap()];
let report = validate_inventory(&Inventory {
schema: Schema {
types: BTreeMap::from([("device".to_string(), type_schema)]),
},
objects,
});
let invalid = report
.errors
.iter()
.filter(|e| matches!(e, ValidationError::InvalidValue { .. }))
.count();
assert_eq!(invalid, 2);
}
#[test]
fn with_sources_attaches_location_for_dotted_type() {
let mut key = BTreeMap::new();
key.insert("slug".to_string(), serde_json::json!("fra1"));
let object = Object::new(
uid(50),
TypeName::new("dcim.site"),
Key::from(key),
JsonMap::default(),
)
.unwrap()
.with_source(SourceLocation::file_line("inventory.yaml", 42));
let report = ValidationReport {
errors: vec![ValidationError::InvalidValue {
field: "dcim.site.key.slug".to_string(),
expected: "slug".to_string(),
actual: "FRA1".to_string(),
}],
};
let located = report.with_sources(&[object]);
assert_eq!(located.len(), 1);
assert_eq!(
located[0].source,
Some(SourceLocation::file_line("inventory.yaml", 42))
);
}
#[test]
fn with_sources_attributes_ref_mismatch_to_referencing_object() {
let mut dkey = BTreeMap::new();
dkey.insert("name".to_string(), serde_json::json!("leaf1"));
let device = Object::new(
uid(1),
TypeName::new("dcim.device"),
Key::from(dkey),
JsonMap::default(),
)
.unwrap()
.with_source(SourceLocation::file_line("inventory.yaml", 20));
let mut skey = BTreeMap::new();
skey.insert("slug".to_string(), serde_json::json!("fra1"));
let site = Object::new(
uid(2),
TypeName::new("dcim.site"),
Key::from(skey),
JsonMap::default(),
)
.unwrap()
.with_source(SourceLocation::file_line("inventory.yaml", 5));
let report = ValidationReport {
errors: vec![ValidationError::ReferenceTypeMismatch {
field: "dcim.device.owner".to_string(),
target: uid(2),
expected: "tenancy.tenant".to_string(),
actual: "dcim.site".to_string(),
}],
};
let located = report.with_sources(&[device, site]);
assert_eq!(located.len(), 1);
assert_eq!(
located[0].source,
Some(SourceLocation::file_line("inventory.yaml", 20))
);
}
#[test]
fn test_field_value_validation() {
let uid_to_type = BTreeMap::from([(uid(1), TypeName::new("target"))]);
let mut report = ValidationReport::default();
let schema = FieldSchema {
r#type: FieldType::Int,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!("not-int"),
&uid_to_type,
&mut report,
);
assert!(report
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidValue { .. })));
let schema = FieldSchema {
r#type: FieldType::Enum {
values: vec!["a".to_string(), "b".to_string()],
},
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
report.errors.clear();
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!("c"),
&uid_to_type,
&mut report,
);
assert!(report
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidValue { .. })));
let schema = FieldSchema {
r#type: FieldType::Ref {
target: "wrong".to_string(),
},
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
report.errors.clear();
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!(uid(1).to_string()),
&uid_to_type,
&mut report,
);
assert!(report
.errors
.iter()
.any(|e| matches!(e, ValidationError::ReferenceTypeMismatch { .. })));
let schema = FieldSchema {
r#type: FieldType::ListRef {
target: "target".to_string(),
},
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
report.errors.clear();
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!([uid(1).to_string()]),
&uid_to_type,
&mut report,
);
assert!(report.errors.is_empty());
let schema = FieldSchema {
r#type: FieldType::Map {
value: Box::new(FieldType::Int),
},
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
report.errors.clear();
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!({"a": 1, "b": "not-int"}),
&uid_to_type,
&mut report,
);
assert!(report
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidValue { .. })));
let schema = FieldSchema {
r#type: FieldType::Uuid,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
report.errors.clear();
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!("not-a-uuid"),
&uid_to_type,
&mut report,
);
assert!(report
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidValue { .. })));
let schema = FieldSchema {
r#type: FieldType::List {
item: Box::new(FieldType::Ref {
target: "target".to_string(),
}),
},
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
report.errors.clear();
validate_field_value(
&TypeName::new("test"),
"field",
&schema,
&json!([uid(1).to_string()]),
&uid_to_type,
&mut report,
);
assert!(report.errors.is_empty());
}
#[test]
fn validate_field_value_null_tristate() {
let nullable = FieldSchema {
r#type: FieldType::String,
required: false,
nullable: true,
description: None,
format: None,
pattern: None,
};
assert!(check(&nullable, &json!(null)).errors.is_empty());
let non_nullable = FieldSchema {
r#type: FieldType::String,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
};
assert!(check(&non_nullable, &json!(null))
.errors
.iter()
.any(|e| matches!(
e,
ValidationError::InvalidValue { actual, .. } if actual == "null"
)));
}
fn fmt_field(format: FieldFormat) -> FieldSchema {
FieldSchema {
r#type: FieldType::String,
required: true,
nullable: false,
description: None,
format: Some(format),
pattern: None,
}
}
fn pattern_field(pattern: &str) -> FieldSchema {
FieldSchema {
r#type: FieldType::String,
required: true,
nullable: false,
description: None,
format: None,
pattern: Some(pattern.to_string()),
}
}
fn typed_field(field_type: FieldType) -> FieldSchema {
FieldSchema {
r#type: field_type,
required: true,
nullable: false,
description: None,
format: None,
pattern: None,
}
}
fn check(schema: &FieldSchema, value: &serde_json::Value) -> ValidationReport {
let uid_to_type: BTreeMap<Uid, TypeName> = BTreeMap::new();
let mut report = ValidationReport::default();
validate_field_value(
&TypeName::new("test"),
"field",
schema,
value,
&uid_to_type,
&mut report,
);
report
}
fn has_invalid_value(report: &ValidationReport) -> bool {
report
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidValue { .. }))
}
#[test]
fn format_slug_accepts_valid_and_rejects_invalid() {
assert!(check(&fmt_field(FieldFormat::Slug), &json!("leaf-01"))
.errors
.is_empty());
assert!(has_invalid_value(&check(
&fmt_field(FieldFormat::Slug),
&json!("leaf01-")
)));
assert!(has_invalid_value(&check(
&fmt_field(FieldFormat::Slug),
&json!("Leaf01")
)));
}
#[test]
fn format_ip_address_accepts_valid_and_rejects_invalid() {
assert!(
check(&fmt_field(FieldFormat::IpAddress), &json!("10.0.0.1"))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&fmt_field(FieldFormat::IpAddress),
&json!("not-an-ip")
)));
}
#[test]
fn format_cidr_and_prefix_accept_valid_and_reject_invalid() {
assert!(check(&fmt_field(FieldFormat::Cidr), &json!("10.0.0.0/24"))
.errors
.is_empty());
assert!(
check(&fmt_field(FieldFormat::Prefix), &json!("10.0.0.0/24"))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&fmt_field(FieldFormat::Cidr),
&json!("not-a-cidr")
)));
}
#[test]
fn format_mac_accepts_valid_and_rejects_invalid() {
assert!(
check(&fmt_field(FieldFormat::Mac), &json!("aa:bb:cc:dd:ee:ff"))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&fmt_field(FieldFormat::Mac),
&json!("aa:bb")
)));
}
#[test]
fn format_uuid_accepts_valid_and_rejects_invalid() {
assert!(
check(&fmt_field(FieldFormat::Uuid), &json!(uid(1).to_string()))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&fmt_field(FieldFormat::Uuid),
&json!("not-a-uuid")
)));
}
#[test]
fn type_uuid_enforces_format() {
assert!(
check(&typed_field(FieldType::Uuid), &json!(uid(1).to_string()))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&typed_field(FieldType::Uuid),
&json!("not-a-uuid")
)));
}
#[test]
fn type_ip_address_accepts_any_string_including_masked() {
assert!(
check(&typed_field(FieldType::IpAddress), &json!("10.0.0.10/24"))
.errors
.is_empty()
);
assert!(
check(&typed_field(FieldType::IpAddress), &json!("10.0.0.1"))
.errors
.is_empty()
);
}
#[test]
fn type_cidr_and_prefix_enforce_format() {
assert!(check(&typed_field(FieldType::Cidr), &json!("10.0.0.0/24"))
.errors
.is_empty());
assert!(
check(&typed_field(FieldType::Prefix), &json!("10.0.0.0/24"))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&typed_field(FieldType::Cidr),
&json!("not-a-cidr")
)));
assert!(has_invalid_value(&check(
&typed_field(FieldType::Prefix),
&json!("10.0.0.1")
)));
}
#[test]
fn type_mac_enforces_format() {
assert!(
check(&typed_field(FieldType::Mac), &json!("aa:bb:cc:dd:ee:ff"))
.errors
.is_empty()
);
assert!(has_invalid_value(&check(
&typed_field(FieldType::Mac),
&json!("aa:bb")
)));
}
#[test]
fn type_slug_enforces_format() {
assert!(check(&typed_field(FieldType::Slug), &json!("leaf-01"))
.errors
.is_empty());
assert!(has_invalid_value(&check(
&typed_field(FieldType::Slug),
&json!("Leaf01")
)));
}
#[test]
fn pattern_matches_and_mismatches() {
assert!(check(&pattern_field(r"^[a-z]+$"), &json!("abc"))
.errors
.is_empty());
assert!(has_invalid_value(&check(
&pattern_field(r"^[a-z]+$"),
&json!("ABC")
)));
}
#[test]
fn invalid_pattern_reports_error_without_panicking() {
let report = check(&pattern_field("["), &json!("anything"));
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::InvalidValue { actual, .. } if actual.contains("invalid pattern")
)));
}
#[test]
fn format_or_pattern_requires_string_value() {
let mut schema = fmt_field(FieldFormat::Slug);
schema.r#type = FieldType::Json;
let report = check(&schema, &json!(42));
assert_eq!(report.errors.len(), 1);
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::InvalidValue { expected, .. } if expected == "string"
)));
let mut schema = pattern_field(r"^\d+$");
schema.r#type = FieldType::Json;
let report = check(&schema, &json!(42));
assert_eq!(report.errors.len(), 1);
assert!(report.errors.iter().any(|e| matches!(
e,
ValidationError::InvalidValue { expected, .. } if expected == "string"
)));
}
}