use serde_json::Value; use super::types::{FieldDefinition, SchemaDefinition}; // --------------------------------------------------------------------------- // ValidationError // --------------------------------------------------------------------------- #[derive(Debug)] pub struct ValidationError { pub field: String, pub message: String, } impl std::fmt::Display for ValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Field '{}': {}", self.field, self.message) } } // --------------------------------------------------------------------------- // Core validation (pure – no store dependency) // --------------------------------------------------------------------------- /// Validate content against a schema definition. /// Returns a list of validation errors (empty = valid). pub fn validate_content( schema: &SchemaDefinition, content: &Value, ) -> Vec { let mut errors = Vec::new(); let obj = match content.as_object() { Some(obj) => obj, None => { errors.push(ValidationError { field: "_root".to_string(), message: "Content must be a JSON object".to_string(), }); return errors; } }; // ── Strict mode: reject unknown fields ─────────────────────────────── if schema.strict { for key in obj.keys() { if key.starts_with('_') { continue; } if !schema.fields.contains_key(key) { errors.push(ValidationError { field: key.clone(), message: "Unknown field (strict mode is enabled)".to_string(), }); } } } // ── Required fields ────────────────────────────────────────────────── for (name, field_def) in &schema.fields { if field_def.required && !field_def.auto { if !obj.contains_key(name) || obj[name].is_null() { // null is OK if the field is nullable if obj.get(name).map_or(false, |v| v.is_null()) && field_def.nullable { continue; } errors.push(ValidationError { field: name.clone(), message: "Field is required".to_string(), }); } } } // ── Per-field type & constraint validation ─────────────────────────── for (name, value) in obj { if name.starts_with('_') { continue; } if let Some(field_def) = schema.fields.get(name) { validate_field(name, field_def, value, &mut errors); } } errors } // --------------------------------------------------------------------------- // Field-level validation // --------------------------------------------------------------------------- fn validate_field( field_name: &str, fd: &FieldDefinition, value: &Value, errors: &mut Vec, ) { // ── Null handling ──────────────────────────────────────────────────── if value.is_null() { if !fd.nullable { errors.push(ValidationError { field: field_name.to_string(), message: "Field does not allow null (set nullable: true to permit)".to_string(), }); } return; } // ── Type check ────────────────────────────────────────────────────── let type_ok = match fd.field_type.as_str() { "string" | "richtext" | "html" | "markdown" | "datetime" => value.is_string(), "number" => value.is_number(), "integer" => value.is_i64() || value.is_u64() || value.as_f64().map(|f| f.fract() == 0.0 && f.is_finite()).unwrap_or(false), "boolean" => value.is_boolean(), "array" => value.is_array(), "object" => value.is_object(), "reference" => value.is_string(), _ => true, }; if !type_ok { errors.push(ValidationError { field: field_name.to_string(), message: format!( "Expected type '{}', got '{}'", fd.field_type, type_name(value) ), }); return; // no point checking constraints if type is wrong } // ── Enum constraint ───────────────────────────────────────────────── if let Some(ref allowed) = fd.enum_values { if !allowed.contains(value) { errors.push(ValidationError { field: field_name.to_string(), message: format!("Value must be one of: {:?}", allowed), }); } } // ── String constraints ────────────────────────────────────────────── if let Some(s) = value.as_str() { if let Some(min) = fd.min_length { if s.len() < min { errors.push(ValidationError { field: field_name.to_string(), message: format!("String too short (min {} characters)", min), }); } } if let Some(max) = fd.max_length { if s.len() > max { errors.push(ValidationError { field: field_name.to_string(), message: format!("String too long (max {} characters)", max), }); } } if let Some(ref pattern) = fd.pattern { // Pattern was validated at schema load time, so unwrap is safe if let Ok(re) = regex::Regex::new(pattern) { if !re.is_match(s) { errors.push(ValidationError { field: field_name.to_string(), message: format!("Value does not match pattern '{}'", pattern), }); } } } } // ── Number constraints ────────────────────────────────────────────── if let Some(n) = value.as_f64() { if let Some(min) = fd.min { if n < min { errors.push(ValidationError { field: field_name.to_string(), message: format!("Value {} is below minimum {}", n, min), }); } } if let Some(max) = fd.max { if n > max { errors.push(ValidationError { field: field_name.to_string(), message: format!("Value {} exceeds maximum {}", n, max), }); } } } // ── Array constraints & item validation ───────────────────────────── if let Some(arr) = value.as_array() { if let Some(min) = fd.min_items { if arr.len() < min { errors.push(ValidationError { field: field_name.to_string(), message: format!("Array too short (min {} items)", min), }); } } if let Some(max) = fd.max_items { if arr.len() > max { errors.push(ValidationError { field: field_name.to_string(), message: format!("Array too long (max {} items)", max), }); } } if let Some(ref items_def) = fd.items { for (i, item) in arr.iter().enumerate() { validate_field( &format!("{}[{}]", field_name, i), items_def, item, errors, ); } } } // ── Nested object validation ──────────────────────────────────────── if fd.field_type == "object" { if let (Some(ref nested_fields), Some(obj)) = (&fd.fields, value.as_object()) { for (nested_name, nested_def) in nested_fields { let full_name = format!("{}.{}", field_name, nested_name); if let Some(nested_value) = obj.get(nested_name) { validate_field(&full_name, nested_def, nested_value, errors); } else if nested_def.required { errors.push(ValidationError { field: full_name, message: "Field is required".to_string(), }); } } } } } fn type_name(value: &Value) -> &'static str { match value { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", } } // --------------------------------------------------------------------------- // Defaults & auto-generation // --------------------------------------------------------------------------- /// Apply default values and auto-generated fields to content. pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) { if let Some(obj) = content.as_object_mut() { for (name, fd) in &schema.fields { if obj.contains_key(name) { continue; } if let Some(ref default_value) = fd.default { obj.insert(name.clone(), default_value.clone()); continue; } if fd.auto && fd.field_type == "datetime" { obj.insert( name.clone(), Value::String(chrono::Utc::now().to_rfc3339()), ); } } } } // --------------------------------------------------------------------------- // Readonly check (used by update handler) // --------------------------------------------------------------------------- /// Check whether any readonly field has been changed. /// `old` is the current stored content, `new` is the incoming update. pub fn check_readonly_violations( schema: &SchemaDefinition, old: &Value, new: &Value, ) -> Vec { let mut errors = Vec::new(); for (name, fd) in &schema.fields { if !fd.readonly { continue; } let old_val = old.get(name); let new_val = new.get(name); // If the incoming data includes the readonly field with a different value, reject if let Some(nv) = new_val { if let Some(ov) = old_val { if nv != ov { errors.push(ValidationError { field: name.clone(), message: "Field is readonly and cannot be changed after creation" .to_string(), }); } } } } errors } // --------------------------------------------------------------------------- // Unique validation (needs list of existing entries from the handler) // --------------------------------------------------------------------------- /// Check that all unique fields have values that don't collide with existing entries. /// `current_slug` should be `Some(slug)` on update (to exclude self) or `None` on create. pub fn validate_unique( schema: &SchemaDefinition, content: &Value, current_slug: Option<&str>, existing_entries: &[(String, Value)], ) -> Vec { let mut errors = Vec::new(); let unique_fields: Vec<&String> = schema .fields .iter() .filter(|(_, fd)| fd.unique) .map(|(name, _)| name) .collect(); if unique_fields.is_empty() { return errors; } for field_name in &unique_fields { let Some(value) = content.get(field_name.as_str()) else { continue; }; if value.is_null() { continue; } for (slug, entry) in existing_entries { // Skip self on update if Some(slug.as_str()) == current_slug { continue; } if let Some(existing_val) = entry.get(field_name.as_str()) { if existing_val == value { errors.push(ValidationError { field: field_name.to_string(), message: format!( "Value must be unique – '{}' is already used by entry '{}'", value, slug ), }); break; } } } } errors } // --------------------------------------------------------------------------- // Reference validation (handler provides a lookup function) // --------------------------------------------------------------------------- /// Check that all reference fields point to existing entries. /// `entry_exists(collection, slug) -> bool` is provided by the handler. /// Supports single collection and polymorphic `collections` (slug valid if found in any). pub fn validate_references( schema: &SchemaDefinition, content: &Value, entry_exists: &dyn Fn(&str, &str) -> bool, ) -> Vec { let mut errors = Vec::new(); if let Some(obj) = content.as_object() { for (field_name, fd) in &schema.fields { if fd.field_type == "reference" { let colls = fd.reference_collections(); if colls.is_empty() { continue; } if let Some(Value::String(slug)) = obj.get(field_name) { let found = colls.iter().any(|c| entry_exists(c, slug)); if !found { errors.push(ValidationError { field: field_name.clone(), message: format!( "Referenced entry '{}' not found in any of {:?}", slug, colls ), }); } } continue; } if fd.field_type == "array" { if let Some(ref items) = fd.items { if items.field_type != "reference" { continue; } let colls = items.reference_collections(); if colls.is_empty() { continue; } if let Some(Value::Array(arr)) = obj.get(field_name) { for (i, item) in arr.iter().enumerate() { if let Some(slug) = item.as_str() { let found = colls.iter().any(|c| entry_exists(c, slug)); if !found { errors.push(ValidationError { field: format!("{}[{}]", field_name, i), message: format!( "Referenced entry '{}' not found in any of {:?}", slug, colls ), }); } } } } } } } } errors }