mecha10-behavior-runtime 0.6.5

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Validation for behavior tree configurations
//!
//! Ensures that behavior tree JSON is well-formed and references valid node types.
//!
//! Structural validation runs against the real `schemars`-derived Draft-7 JSON
//! Schema for [`BehaviorConfig`] (the same schema committed to
//! `schemas/behavior_composition_v1.json` via `cargo run --example generate_schema`,
//! see that file's regression test below) via the `jsonschema` crate -- the same
//! pattern `mecha10-schema-registry::validate_model_catalog_json` uses for
//! `model_catalog.toml` (see `docs/validate-configs.md`'s "Adding a new config
//! kind" section). The schema is derived in-process from [`BehaviorConfig`] itself
//! rather than read from that file at validation time, so the enforced schema can
//! never drift from the real struct -- same reasoning as
//! `mecha10-schema-registry`'s node/model-catalog schemas.
//!
//! On top of that structural check, [`validate_behavior_config`] also enforces one
//! rule JSON Schema can't express: every leaf node's `node` identifier must be
//! registered in a live [`NodeRegistry`] -- schema validation only knows it's a
//! string, not whether it's an actually-registered behavior node type.

use super::types::BehaviorConfig;
use crate::registry::NodeRegistry;
use anyhow::{anyhow, Result};
use serde_json::Value;
use std::sync::LazyLock;

/// The `schemars`-derived JSON Schema for [`BehaviorConfig`], built once and reused
/// across calls.
static BEHAVIOR_CONFIG_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
    let root_schema = schemars::schema_for!(BehaviorConfig);
    serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
});

/// A compiled validator for [`BEHAVIOR_CONFIG_SCHEMA`], built once and reused across
/// calls. Draft is auto-detected from the schema's own `$schema` field (via
/// `jsonschema::validator_for`) rather than hardcoded, since `schemars` picks the
/// dialect it emits.
static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
    jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
});

/// Validation result with detailed error information
#[derive(Debug, Clone)]
pub struct ValidationResult {
    pub valid: bool,
    pub errors: Vec<String>,
    pub warnings: Vec<String>,
}

impl ValidationResult {
    /// Create a successful validation result
    pub fn success() -> Self {
        Self {
            valid: true,
            errors: Vec::new(),
            warnings: Vec::new(),
        }
    }

    /// Create a failed validation result with errors
    pub fn with_errors(errors: Vec<String>) -> Self {
        Self {
            valid: false,
            errors,
            warnings: Vec::new(),
        }
    }

    /// Add a warning to the result
    pub fn add_warning(&mut self, warning: String) {
        self.warnings.push(warning);
    }

    /// Add an error to the result
    pub fn add_error(&mut self, error: String) {
        self.errors.push(error);
        self.valid = false;
    }
}

/// Validate a behavior composition JSON value against the real
/// `schemars`-derived [`BehaviorConfig`] JSON Schema -- structural validation only,
/// no [`NodeRegistry`] required. This is the piece [`validate_behavior_config`]
/// layers its registry-based node-type check on top of, and is also what
/// `mecha10 config validate --path <file>` calls directly for a standalone behavior
/// config file with no project/registry context available (see
/// `packages/cli/src/config_validation.rs`).
///
/// # Errors
///
/// Returns an error describing every schema violation found if `config` doesn't
/// match [`BehaviorConfig`]'s shape.
pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
    if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
        let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
            .iter_errors(config)
            .map(|e| format!("  - {}: {} ({})", e.instance_path(), e, e.instance()))
            .collect();
        return Err(anyhow!(
            "Behavior config schema validation failed:\n{}",
            messages.join("\n")
        ));
    }

    Ok(())
}

/// Validate a behavior tree configuration
///
/// Checks:
/// - The config matches the real `BehaviorConfig` JSON Schema (required fields,
///   composition node shapes, etc.) -- see [`validate_behavior_composition_json`].
/// - Every leaf node's `node` type exists in `registry` -- a rule JSON Schema can't
///   express, since it depends on which behavior nodes are registered at runtime.
///
/// # Arguments
///
/// * `config` - Behavior tree configuration JSON
/// * `registry` - Node registry for validating node types
///
/// # Example
///
/// ```rust,ignore
/// use mecha10_behavior_runtime::config::validate_behavior_config;
/// use mecha10_behavior_runtime::NodeRegistry;
///
/// let registry = NodeRegistry::new();
/// let config = serde_json::json!({
///     "name": "test_behavior",
///     "root": {
///         "type": "sequence",
///         "children": []
///     }
/// });
///
/// let result = validate_behavior_config(&config, &registry)?;
/// assert!(result.valid);
/// ```
pub fn validate_behavior_config(config: &Value, registry: &NodeRegistry) -> Result<ValidationResult> {
    let mut result = ValidationResult::success();

    if let Err(schema_err) = validate_behavior_composition_json(config) {
        result.add_error(schema_err.to_string());
    }

    if let Some(root) = config.get("root") {
        validate_node_types(root, registry, &mut result, "root");
    }

    if config.get("$schema").is_none() {
        result
            .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
    }

    Ok(result)
}

/// Walk the composition tree checking that every leaf node's `node` identifier is
/// registered in `registry` -- the one rule JSON Schema can't express (it doesn't
/// know what's registered at runtime). Skips anything schema validation already
/// reports (non-object nodes, missing/invalid `type`/`children`) rather than
/// duplicating those structural checks here.
fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
    let Some(obj) = node.as_object() else {
        return;
    };

    let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
        return;
    };

    match node_type {
        "node" => {
            if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
                if !registry.has_node(node_name) {
                    result.add_error(format!(
                        "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
                        path, node_name
                    ));
                }
            }
        }
        "sequence" | "selector" | "parallel" => {
            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
                if children.is_empty() {
                    result.add_warning(format!(
                        "Composition node at '{}' has no children (will immediately return success/failure)",
                        path
                    ));
                }

                for (i, child) in children.iter().enumerate() {
                    let child_path = format!("{}.children[{}]", path, i);
                    validate_node_types(child, registry, result, &child_path);
                }
            }
        }
        _ => {}
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::NodeRegistry;
    use serde_json::json;

    #[test]
    fn test_validate_missing_name() {
        let registry = NodeRegistry::new();
        let config = json!({
            "root": {
                "type": "sequence",
                "children": []
            }
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(!result.valid);
        assert!(result.errors.iter().any(|e| e.contains("name")));
    }

    #[test]
    fn test_validate_missing_root() {
        let registry = NodeRegistry::new();
        let config = json!({
            "name": "test"
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(!result.valid);
        assert!(result.errors.iter().any(|e| e.contains("root")));
    }

    #[test]
    fn test_validate_valid_config() {
        let registry = NodeRegistry::new();
        let config = json!({
            "name": "test",
            "root": {
                "type": "sequence",
                "children": []
            }
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(result.valid, "{:?}", result.errors);
    }

    #[test]
    fn test_validate_unknown_node_type() {
        let registry = NodeRegistry::new();
        let config = json!({
            "name": "test",
            "root": {
                "type": "node",
                "node": "unknown_type"
            }
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(!result.valid);
        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
    }

    /// LAB-2054: before this, the hand-rolled validator accepted a leaf node shaped
    /// as `{"type": "<node_type>"}` directly -- a shape that never matched the real
    /// `BehaviorConfig`/`CompositionConfig` struct at all (its `Node` variant tags
    /// as `"type": "node"` with the actual identifier in a separate `node` field,
    /// per `#[serde(tag = "type")]`). Real schema enforcement must reject it.
    #[test]
    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
        let registry = NodeRegistry::new();
        let config = json!({
            "name": "test",
            "root": {
                "type": "wander",
                "config": {}
            }
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(!result.valid);
    }

    #[test]
    fn test_validate_valid_leaf_node_config() {
        let mut registry = NodeRegistry::new();
        crate::register_builtin_actions(&mut registry);

        let config = json!({
            "name": "test",
            "root": {
                "type": "node",
                "node": "wander",
                "config": { "speed": 0.5 }
            }
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(result.valid, "{:?}", result.errors);
    }

    #[test]
    fn test_validate_empty_children_warns() {
        let registry = NodeRegistry::new();
        let config = json!({
            "name": "test",
            "root": {
                "type": "sequence",
                "children": []
            }
        });

        let result = validate_behavior_config(&config, &registry).unwrap();
        assert!(result.valid);
        assert!(result.warnings.iter().any(|w| w.contains("no children")));
    }

    #[test]
    fn validate_behavior_composition_json_rejects_non_object() {
        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
    }

    #[test]
    fn validate_behavior_composition_json_rejects_wrong_children_type() {
        let config = json!({
            "name": "test",
            "root": {
                "type": "sequence",
                "children": "not an array"
            }
        });

        assert!(validate_behavior_composition_json(&config).is_err());
    }

    #[test]
    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../mecha10-templates/templates/configs/behaviors/@mecha10")
            .canonicalize()
            .unwrap();

        let mut checked = 0;
        for entry in std::fs::read_dir(&templates_dir).unwrap() {
            let config_path = entry.unwrap().path().join("config.json");
            if !config_path.is_file() {
                continue;
            }
            let content = std::fs::read_to_string(&config_path).unwrap();
            let value: Value = serde_json::from_str(&content).unwrap();
            assert!(
                validate_behavior_composition_json(&value).is_ok(),
                "{} should validate against the real BehaviorConfig schema",
                config_path.display()
            );
            checked += 1;
        }
        assert!(
            checked > 0,
            "expected at least one seed behavior config template to check"
        );
    }
}