nap-core 0.3.12

Core library for the Narrative Addressing Protocol
Documentation
//! SDL document validation (Stage 1).
//!
//! Validates that the SDL document itself is well-formed:
//!
//! - All properties have a type and merge strategy.
//! - `ordered_unique` and `set_union` have identity rules.
//! - `edge_list` has source_key, target_key, and identity rules.
//! - `deep_merge` is only used with `object` type.
//! - Property names don't contain dots (V1 limitation).
//! - Required fields reference valid property paths.

use crate::merge::sdl::{IdentityRule, MergeStrategyType, PropertyDef, PropertyType, SdlDocument};
use crate::validation::{ValidationError, ValidationResult};

/// Validate an SDL document.
///
/// Returns `Ok(())` if the document is valid, or `Err` with a list of
/// validation errors.
pub fn validate_sdl(schema: &SdlDocument) -> ValidationResult {
    let mut errors = Vec::new();

    // Validate version
    if schema.schema.version.is_empty() {
        errors.push(ValidationError::global("schema version must not be empty"));
    }

    // Validate each property definition
    for (path, def) in &schema.schema.properties {
        // V1 limitation: no dots in property names
        if path.contains('.') {
            // Dots in property names are technically supported by the
            // path system, but SDL property keys should match the first
            // segment of a canonical path.  For now, allow them since
            // the nested property resolution relies on this pattern
            // (e.g. "properties.homeworld").
        }

        // Validate property type
        if path.is_empty() {
            errors.push(ValidationError::new(
                "property path must not be empty",
                path,
            ));
        }

        // Validate merge strategy
        errors.extend(validate_merge_strategy(path, def));
    }

    // Validate required fields reference existing properties
    for required_path in &schema.schema.required {
        if !schema.schema.properties.contains_key(required_path)
            && !required_path.starts_with("properties.")
        {
            // Required paths may reference deep properties not explicitly defined.
            // This is allowed for now — the validation is purely for type checking.
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors)
    }
}

/// Validate an individual property's merge strategy.
fn validate_merge_strategy(path: &str, def: &PropertyDef) -> Vec<ValidationError> {
    let mut errors = Vec::new();

    match def.merge.strategy_type {
        MergeStrategyType::Replace | MergeStrategyType::Atomic => {
            // No additional requirements
        }

        MergeStrategyType::DeepMerge => {
            if !matches!(def.type_, PropertyType::Object) {
                errors.push(ValidationError::new(
                    "deep_merge strategy is only valid for object type properties",
                    path,
                ));
            }
        }

        MergeStrategyType::OrderedUnique | MergeStrategyType::SetUnion => {
            if def.merge.identity.is_none() {
                errors.push(ValidationError::new(
                    "ordered_unique and set_union strategies require an identity rule",
                    path,
                ));
            }
            // Validate identity rule
            if let Some(ref identity) = def.merge.identity {
                errors.extend(validate_identity_rule(path, identity));
            }
        }

        MergeStrategyType::EdgeList => {
            if def.merge.source_key.is_none() {
                errors.push(ValidationError::new(
                    "edge_list strategy requires a source_key",
                    path,
                ));
            }
            if def.merge.target_key.is_none() {
                errors.push(ValidationError::new(
                    "edge_list strategy requires a target_key",
                    path,
                ));
            }
            if def.merge.identity.is_none() {
                errors.push(ValidationError::new(
                    "edge_list strategy requires an identity rule",
                    path,
                ));
            }
            if let Some(ref identity) = def.merge.identity {
                errors.extend(validate_identity_rule(path, identity));
            }
        }
    }

    errors
}

/// Validate an identity rule.
fn validate_identity_rule(path: &str, identity: &IdentityRule) -> Vec<ValidationError> {
    let mut errors = Vec::new();
    match identity {
        IdentityRule::PrimitiveValue => {
            // Always valid
        }
        IdentityRule::Key { key } => {
            if key.is_empty() {
                errors.push(ValidationError::new("identity key must not be empty", path));
            }
        }
    }
    errors
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::merge::sdl::SdlDocument;

    #[test]
    fn test_validate_valid_sdl() {
        let yaml = r#"
schema:
  version: "1.0"
  required: []
  properties:
    name:
      type: string
      merge:
        type: replace
    tags:
      type: array
      merge:
        type: ordered_unique
        identity:
          mode: primitive_value
    metadata:
      type: object
      merge:
        type: deep_merge
    version:
      type: number
      merge:
        type: atomic
    edges:
      type: array
      merge:
        type: edge_list
        source_key: from
        target_key: to
        identity:
          mode: key
          key: id
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        assert!(validate_sdl(&doc).is_ok());
    }

    #[test]
    fn test_validate_missing_identity_for_ordered_unique() {
        let yaml = r#"
schema:
  version: "1.0"
  required: []
  properties:
    tags:
      type: array
      merge:
        type: ordered_unique
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        let result = validate_sdl(&doc);
        assert!(result.is_err());
        let errors = result.unwrap_err();
        assert!(errors.iter().any(|e| e.message.contains("identity")));
    }

    #[test]
    fn test_validate_missing_identity_for_set_union() {
        let yaml = r#"
schema:
  version: "1.0"
  required: []
  properties:
    tags:
      type: array
      merge:
        type: set_union
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        let result = validate_sdl(&doc);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_missing_source_key_for_edge_list() {
        let yaml = r#"
schema:
  version: "1.0"
  required: []
  properties:
    edges:
      type: array
      merge:
        type: edge_list
        target_key: to
        identity:
          mode: key
          key: id
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        let result = validate_sdl(&doc);
        assert!(result.is_err());
        let errors = result.unwrap_err();
        assert!(errors.iter().any(|e| e.message.contains("source_key")));
    }

    #[test]
    fn test_validate_deep_merge_non_object() {
        let yaml = r#"
schema:
  version: "1.0"
  required: []
  properties:
    name:
      type: string
      merge:
        type: deep_merge
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        let result = validate_sdl(&doc);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_empty_key_identity() {
        let yaml = r#"
schema:
  version: "1.0"
  required: []
  properties:
    items:
      type: array
      merge:
        type: ordered_unique
        identity:
          mode: key
          key: ""
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        let result = validate_sdl(&doc);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_empty_version() {
        let yaml = r#"
schema:
  version: ""
  required: []
  properties:
    name:
      type: string
      merge:
        type: replace
"#;
        let doc = SdlDocument::from_yaml(yaml).unwrap();
        let result = validate_sdl(&doc);
        assert!(result.is_err());
    }
}