Skip to main content

mecha10_behavior_runtime/config/
validator.rs

1//! Validation for behavior tree configurations
2//!
3//! Ensures that behavior tree JSON is well-formed and references valid node types.
4//!
5//! Structural validation runs against the real `schemars`-derived Draft-7 JSON
6//! Schema for [`BehaviorConfig`] (the same schema committed to
7//! `schemas/behavior_composition_v1.json` via `cargo run --example generate_schema`,
8//! see that file's regression test below) via the `jsonschema` crate -- the same
9//! pattern `mecha10-schema-registry::validate_model_catalog_json` uses for
10//! `model_catalog.toml` (see `docs/validate-configs.md`'s "Adding a new config
11//! kind" section). The schema is derived in-process from [`BehaviorConfig`] itself
12//! rather than read from that file at validation time, so the enforced schema can
13//! never drift from the real struct -- same reasoning as
14//! `mecha10-schema-registry`'s node/model-catalog schemas.
15//!
16//! On top of that structural check, [`validate_behavior_config`] also enforces one
17//! rule JSON Schema can't express: every leaf node's `node` identifier must be
18//! registered in a live [`NodeRegistry`] -- schema validation only knows it's a
19//! string, not whether it's an actually-registered behavior node type.
20
21use super::types::BehaviorConfig;
22use crate::registry::NodeRegistry;
23use anyhow::{anyhow, Result};
24use serde_json::Value;
25use std::sync::LazyLock;
26
27/// The `schemars`-derived JSON Schema for [`BehaviorConfig`], built once and reused
28/// across calls.
29static BEHAVIOR_CONFIG_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
30    let root_schema = schemars::schema_for!(BehaviorConfig);
31    serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
32});
33
34/// A compiled validator for [`BEHAVIOR_CONFIG_SCHEMA`], built once and reused across
35/// calls. Draft is auto-detected from the schema's own `$schema` field (via
36/// `jsonschema::validator_for`) rather than hardcoded, since `schemars` picks the
37/// dialect it emits.
38static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
39    jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
40});
41
42/// Validation result with detailed error information
43#[derive(Debug, Clone)]
44pub struct ValidationResult {
45    pub valid: bool,
46    pub errors: Vec<String>,
47    pub warnings: Vec<String>,
48}
49
50impl ValidationResult {
51    /// Create a successful validation result
52    pub fn success() -> Self {
53        Self {
54            valid: true,
55            errors: Vec::new(),
56            warnings: Vec::new(),
57        }
58    }
59
60    /// Create a failed validation result with errors
61    pub fn with_errors(errors: Vec<String>) -> Self {
62        Self {
63            valid: false,
64            errors,
65            warnings: Vec::new(),
66        }
67    }
68
69    /// Add a warning to the result
70    pub fn add_warning(&mut self, warning: String) {
71        self.warnings.push(warning);
72    }
73
74    /// Add an error to the result
75    pub fn add_error(&mut self, error: String) {
76        self.errors.push(error);
77        self.valid = false;
78    }
79}
80
81/// Validate a behavior composition JSON value against the real
82/// `schemars`-derived [`BehaviorConfig`] JSON Schema -- structural validation only,
83/// no [`NodeRegistry`] required. This is the piece [`validate_behavior_config`]
84/// layers its registry-based node-type check on top of, and is also what
85/// `mecha10 config validate --path <file>` calls directly for a standalone behavior
86/// config file with no project/registry context available (see
87/// `packages/cli/src/config_validation.rs`).
88///
89/// # Errors
90///
91/// Returns an error describing every schema violation found if `config` doesn't
92/// match [`BehaviorConfig`]'s shape.
93pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
94    if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
95        let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
96            .iter_errors(config)
97            .map(|e| format!("  - {}: {} ({})", e.instance_path(), e, e.instance()))
98            .collect();
99        return Err(anyhow!(
100            "Behavior config schema validation failed:\n{}",
101            messages.join("\n")
102        ));
103    }
104
105    Ok(())
106}
107
108/// Validate a behavior tree configuration
109///
110/// Checks:
111/// - The config matches the real `BehaviorConfig` JSON Schema (required fields,
112///   composition node shapes, etc.) -- see [`validate_behavior_composition_json`].
113/// - Every leaf node's `node` type exists in `registry` -- a rule JSON Schema can't
114///   express, since it depends on which behavior nodes are registered at runtime.
115///
116/// # Arguments
117///
118/// * `config` - Behavior tree configuration JSON
119/// * `registry` - Node registry for validating node types
120///
121/// # Example
122///
123/// ```rust,ignore
124/// use mecha10_behavior_runtime::config::validate_behavior_config;
125/// use mecha10_behavior_runtime::NodeRegistry;
126///
127/// let registry = NodeRegistry::new();
128/// let config = serde_json::json!({
129///     "name": "test_behavior",
130///     "root": {
131///         "type": "sequence",
132///         "children": []
133///     }
134/// });
135///
136/// let result = validate_behavior_config(&config, &registry)?;
137/// assert!(result.valid);
138/// ```
139pub fn validate_behavior_config(config: &Value, registry: &NodeRegistry) -> Result<ValidationResult> {
140    let mut result = ValidationResult::success();
141
142    if let Err(schema_err) = validate_behavior_composition_json(config) {
143        result.add_error(schema_err.to_string());
144    }
145
146    // Custom rule JSON Schema can't express: leaf node `node` identifiers must be
147    // registered. Runs regardless of schema-validity above (best-effort) so a
148    // config with both a schema error and an unregistered node type reports both.
149    if let Some(root) = config.get("root") {
150        validate_node_types(root, registry, &mut result, "root");
151    }
152
153    // Check for JSON schema reference (optional but recommended)
154    if config.get("$schema").is_none() {
155        result
156            .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
157    }
158
159    Ok(result)
160}
161
162/// Walk the composition tree checking that every leaf node's `node` identifier is
163/// registered in `registry` -- the one rule JSON Schema can't express (it doesn't
164/// know what's registered at runtime). Skips anything schema validation already
165/// reports (non-object nodes, missing/invalid `type`/`children`) rather than
166/// duplicating those structural checks here.
167fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
168    let Some(obj) = node.as_object() else {
169        return;
170    };
171
172    let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
173        return;
174    };
175
176    match node_type {
177        "node" => {
178            if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
179                if !registry.has_node(node_name) {
180                    result.add_error(format!(
181                        "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
182                        path, node_name
183                    ));
184                }
185            }
186        }
187        "sequence" | "selector" | "parallel" => {
188            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
189                if children.is_empty() {
190                    result.add_warning(format!(
191                        "Composition node at '{}' has no children (will immediately return success/failure)",
192                        path
193                    ));
194                }
195
196                for (i, child) in children.iter().enumerate() {
197                    let child_path = format!("{}.children[{}]", path, i);
198                    validate_node_types(child, registry, result, &child_path);
199                }
200            }
201        }
202        // Anything else is an invalid `type` tag -- already reported by schema
203        // validation (the `CompositionConfig` `oneOf`), nothing further to check.
204        _ => {}
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::NodeRegistry;
212    use serde_json::json;
213
214    #[test]
215    fn test_validate_missing_name() {
216        let registry = NodeRegistry::new();
217        let config = json!({
218            "root": {
219                "type": "sequence",
220                "children": []
221            }
222        });
223
224        let result = validate_behavior_config(&config, &registry).unwrap();
225        assert!(!result.valid);
226        assert!(result.errors.iter().any(|e| e.contains("name")));
227    }
228
229    #[test]
230    fn test_validate_missing_root() {
231        let registry = NodeRegistry::new();
232        let config = json!({
233            "name": "test"
234        });
235
236        let result = validate_behavior_config(&config, &registry).unwrap();
237        assert!(!result.valid);
238        assert!(result.errors.iter().any(|e| e.contains("root")));
239    }
240
241    #[test]
242    fn test_validate_valid_config() {
243        let registry = NodeRegistry::new();
244        let config = json!({
245            "name": "test",
246            "root": {
247                "type": "sequence",
248                "children": []
249            }
250        });
251
252        let result = validate_behavior_config(&config, &registry).unwrap();
253        assert!(result.valid, "{:?}", result.errors);
254    }
255
256    #[test]
257    fn test_validate_unknown_node_type() {
258        let registry = NodeRegistry::new();
259        let config = json!({
260            "name": "test",
261            "root": {
262                "type": "node",
263                "node": "unknown_type"
264            }
265        });
266
267        let result = validate_behavior_config(&config, &registry).unwrap();
268        assert!(!result.valid);
269        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
270    }
271
272    /// LAB-2054: before this, the hand-rolled validator accepted a leaf node shaped
273    /// as `{"type": "<node_type>"}` directly -- a shape that never matched the real
274    /// `BehaviorConfig`/`CompositionConfig` struct at all (its `Node` variant tags
275    /// as `"type": "node"` with the actual identifier in a separate `node` field,
276    /// per `#[serde(tag = "type")]`). Real schema enforcement must reject it.
277    #[test]
278    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
279        let registry = NodeRegistry::new();
280        let config = json!({
281            "name": "test",
282            "root": {
283                "type": "wander",
284                "config": {}
285            }
286        });
287
288        let result = validate_behavior_config(&config, &registry).unwrap();
289        assert!(!result.valid);
290    }
291
292    #[test]
293    fn test_validate_valid_leaf_node_config() {
294        let mut registry = NodeRegistry::new();
295        crate::register_builtin_actions(&mut registry);
296
297        let config = json!({
298            "name": "test",
299            "root": {
300                "type": "node",
301                "node": "wander",
302                "config": { "speed": 0.5 }
303            }
304        });
305
306        let result = validate_behavior_config(&config, &registry).unwrap();
307        assert!(result.valid, "{:?}", result.errors);
308    }
309
310    #[test]
311    fn test_validate_empty_children_warns() {
312        let registry = NodeRegistry::new();
313        let config = json!({
314            "name": "test",
315            "root": {
316                "type": "sequence",
317                "children": []
318            }
319        });
320
321        let result = validate_behavior_config(&config, &registry).unwrap();
322        assert!(result.valid);
323        assert!(result.warnings.iter().any(|w| w.contains("no children")));
324    }
325
326    #[test]
327    fn validate_behavior_composition_json_rejects_non_object() {
328        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
329    }
330
331    #[test]
332    fn validate_behavior_composition_json_rejects_wrong_children_type() {
333        let config = json!({
334            "name": "test",
335            "root": {
336                "type": "sequence",
337                "children": "not an array"
338            }
339        });
340
341        assert!(validate_behavior_composition_json(&config).is_err());
342    }
343
344    #[test]
345    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
346        // Regression guard against the schema drifting from real, committed
347        // behavior configs (LAB-2054) -- mirrors
348        // `mecha10-schema-registry`'s `validate_model_catalog_file_validates_the_real_repo_catalog`.
349        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
350            .join("../mecha10-templates/templates/configs/behaviors/@mecha10")
351            .canonicalize()
352            .unwrap();
353
354        let mut checked = 0;
355        for entry in std::fs::read_dir(&templates_dir).unwrap() {
356            let config_path = entry.unwrap().path().join("config.json");
357            if !config_path.is_file() {
358                continue;
359            }
360            let content = std::fs::read_to_string(&config_path).unwrap();
361            let value: Value = serde_json::from_str(&content).unwrap();
362            assert!(
363                validate_behavior_composition_json(&value).is_ok(),
364                "{} should validate against the real BehaviorConfig schema",
365                config_path.display()
366            );
367            checked += 1;
368        }
369        assert!(
370            checked > 0,
371            "expected at least one seed behavior config template to check"
372        );
373    }
374}