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 ──────────────────────────────────────────────────── // Optional fields (not required) may be null; required fields may not. if value.is_null() { if fd.required { errors.push(ValidationError { field: field_name.to_string(), message: "Field is required".to_string(), }); } return; } // ── Type check ────────────────────────────────────────────────────── let type_ok = match fd.field_type.as_str() { "string" | "richtext" | "html" | "markdown" | "datetime" | "textOrRef" => 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" | "multiSelect" => value.is_array(), "object" => value.is_object(), "reference" => value.is_string(), "referenceOrInline" => value.is_string() || value.is_object(), _ => 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 (single value) ─────────────────────────────────── // Optional string+enum: empty string = no selection, allowed if fd.field_type != "multiSelect" { if let Some(ref allowed) = fd.enum_values { if !allowed.contains(value) { let empty_ok = !fd.required && value.as_str().map_or(false, |s| s.is_empty()); if !empty_ok { errors.push(ValidationError { field: field_name.to_string(), message: format!("Value must be one of: {:?}", allowed), }); } } } } // ── multiSelect: array of strings, each must be in enum ────────────── if fd.field_type == "multiSelect" { if let Some(arr) = value.as_array() { let allowed: Vec<&Value> = fd.enum_values.as_ref().map(|e| e.iter().collect()).unwrap_or_default(); for (i, item) in arr.iter().enumerate() { if !item.is_string() { errors.push(ValidationError { field: format!("{}[{}]", field_name, i), message: "Each item must be a string".to_string(), }); } else if !allowed.is_empty() && !allowed.iter().any(|v| v.as_str() == item.as_str()) { errors.push(ValidationError { field: format!("{}[{}]", field_name, i), message: format!("Item must be one of: {:?}", fd.enum_values), }); } } } } // ── 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" || fd.field_type == "referenceOrInline" { if value.is_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(), }); } } } else if let (Some(ref ap), Some(obj)) = (&fd.additional_properties, value.as_object()) { for (key, val) in obj { let full_name = format!("{}.{}", field_name, key); validate_field(&full_name, ap, val, errors); } } } } } 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 fd.field_type == "multiSelect" { obj.insert(name.clone(), Value::Array(vec![])); 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()), ); } } } } // --------------------------------------------------------------------------- // Normalize multiSelect: string (e.g. "") → [] before validation // --------------------------------------------------------------------------- /// Coerce multiSelect fields that have a string value (e.g. from old data or form) to []. pub fn normalize_multi_select(schema: &SchemaDefinition, content: &mut Value) { let obj = match content.as_object_mut() { Some(o) => o, None => return, }; for (field_name, fd) in &schema.fields { if fd.field_type == "multiSelect" { if let Some(v) = obj.get_mut(field_name) { if v.is_string() { *v = Value::Array(vec![]); } } } } } // --------------------------------------------------------------------------- // Normalize reference arrays (used before validation on create/update) // --------------------------------------------------------------------------- /// Convert reference-array items from objects `{ _slug, ... }` to slug strings. /// So clients can send either slugs or resolved refs; we always store slugs. pub fn normalize_reference_arrays(schema: &SchemaDefinition, content: &mut Value) { let obj = match content.as_object_mut() { Some(o) => o, None => return, }; for (field_name, fd) in &schema.fields { // Single reference / referenceOrInline: object with _slug → slug string if fd.field_type == "reference" || fd.field_type == "referenceOrInline" { let slug_opt = obj .get(field_name) .and_then(|v| v.as_object()) .and_then(|o| o.get("_slug")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); if let Some(s) = slug_opt { obj.insert(field_name.clone(), Value::String(s)); } continue; } if fd.field_type != "array" { continue; } let items = match &fd.items { Some(it) => it, None => continue, }; let is_ref_array = items.field_type == "reference" || items.field_type == "referenceOrInline"; if !is_ref_array { continue; } let arr = match obj.get_mut(field_name).and_then(|v| v.as_array_mut()) { Some(a) => a, None => continue, }; for item in arr.iter_mut() { let slug_opt = item .as_object() .and_then(|o| o.get("_slug")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); if let Some(s) = slug_opt { *item = Value::String(s); } } } } // --------------------------------------------------------------------------- // 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 { let is_ref = fd.field_type == "reference" || (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string())); if is_ref { let colls = fd.reference_collections(); if colls.is_empty() { continue; } if let Some(Value::String(slug)) = obj.get(field_name) { if slug.trim().is_empty() { continue; // empty = no selection, valid for optional reference } 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 ), }); } else if let Some(ref allowed) = fd.allowed_slugs { if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) { errors.push(ValidationError { field: field_name.clone(), message: format!( "Slug '{}' is not in the allowed list for this reference", slug ), }); } } } continue; } if fd.field_type == "array" { if let Some(ref items) = fd.items { let is_ref_item = items.field_type == "reference" || items.field_type == "referenceOrInline"; if !is_ref_item { 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() { if slug.trim().is_empty() { continue; // empty = no selection in list } 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 ), }); } else if let Some(ref allowed) = items.allowed_slugs { if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) { errors.push(ValidationError { field: format!("{}[{}]", field_name, i), message: format!( "Slug '{}' is not in the allowed list for this reference", slug ), }); } } } } } } } } } errors } // --------------------------------------------------------------------------- // Extract references (for reverse index / referrers) // --------------------------------------------------------------------------- /// One referenced entry: (collection, slug). Used to update the referrer index. fn parse_ref_value(value: &str, collections: &[&str]) -> Option<(String, String)> { let value = value.trim(); if value.is_empty() { return None; } if let Some((coll, slug)) = value.split_once(':') { let slug = slug.trim(); if !slug.is_empty() && collections.contains(&coll) { return Some((coll.to_string(), slug.to_string())); } } if let Some(&coll) = collections.first() { return Some((coll.to_string(), value.to_string())); } None } /// One reference: (referenced collection, referenced slug, field name). pub type ExtractedRef = (String, String, String); /// Extract all (referenced collection, referenced slug, field name) from content for referrer index updates. /// Only considers reference and referenceOrInline (string) fields; referenceOrInline as object is inline, not a ref. pub fn extract_references(schema: &SchemaDefinition, content: &Value) -> Vec { let mut out = Vec::new(); let obj = match content.as_object() { Some(o) => o, None => return out, }; for (field_name, fd) in &schema.fields { let is_ref = fd.field_type == "reference" || (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string())); if is_ref { let colls = fd.reference_collections(); if colls.is_empty() { continue; } if let Some(Value::String(s)) = obj.get(field_name) { if let Some((c, slug)) = parse_ref_value(s, &colls) { out.push((c, slug, field_name.clone())); } } continue; } if fd.field_type == "array" { if let Some(ref items) = fd.items { let is_ref_item = items.field_type == "reference" || items.field_type == "referenceOrInline"; if !is_ref_item { continue; } let colls = items.reference_collections(); if colls.is_empty() { continue; } if let Some(Value::Array(arr)) = obj.get(field_name) { for item in arr.iter() { if let Some(s) = item.as_str() { if let Some((c, slug)) = parse_ref_value(s, &colls) { out.push((c, slug, field_name.clone())); } } } } } } } out }