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    if let Some(root) = config.get("root") {
147        validate_node_types(root, registry, &mut result, "root");
148    }
149
150    if config.get("$schema").is_none() {
151        result
152            .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
153    }
154
155    Ok(result)
156}
157
158/// Walk the composition tree checking that every leaf node's `node` identifier is
159/// registered in `registry` -- the one rule JSON Schema can't express (it doesn't
160/// know what's registered at runtime). Skips anything schema validation already
161/// reports (non-object nodes, missing/invalid `type`/`children`) rather than
162/// duplicating those structural checks here.
163fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
164    let Some(obj) = node.as_object() else {
165        return;
166    };
167
168    let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
169        return;
170    };
171
172    match node_type {
173        "node" => {
174            if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
175                if !registry.has_node(node_name) {
176                    result.add_error(format!(
177                        "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
178                        path, node_name
179                    ));
180                }
181            }
182        }
183        "sequence" | "selector" | "parallel" => {
184            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
185                if children.is_empty() {
186                    result.add_warning(format!(
187                        "Composition node at '{}' has no children (will immediately return success/failure)",
188                        path
189                    ));
190                }
191
192                for (i, child) in children.iter().enumerate() {
193                    let child_path = format!("{}.children[{}]", path, i);
194                    validate_node_types(child, registry, result, &child_path);
195                }
196            }
197        }
198        _ => {}
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::NodeRegistry;
206    use serde_json::json;
207
208    #[test]
209    fn test_validate_missing_name() {
210        let registry = NodeRegistry::new();
211        let config = json!({
212            "root": {
213                "type": "sequence",
214                "children": []
215            }
216        });
217
218        let result = validate_behavior_config(&config, &registry).unwrap();
219        assert!(!result.valid);
220        assert!(result.errors.iter().any(|e| e.contains("name")));
221    }
222
223    #[test]
224    fn test_validate_missing_root() {
225        let registry = NodeRegistry::new();
226        let config = json!({
227            "name": "test"
228        });
229
230        let result = validate_behavior_config(&config, &registry).unwrap();
231        assert!(!result.valid);
232        assert!(result.errors.iter().any(|e| e.contains("root")));
233    }
234
235    #[test]
236    fn test_validate_valid_config() {
237        let registry = NodeRegistry::new();
238        let config = json!({
239            "name": "test",
240            "root": {
241                "type": "sequence",
242                "children": []
243            }
244        });
245
246        let result = validate_behavior_config(&config, &registry).unwrap();
247        assert!(result.valid, "{:?}", result.errors);
248    }
249
250    #[test]
251    fn test_validate_unknown_node_type() {
252        let registry = NodeRegistry::new();
253        let config = json!({
254            "name": "test",
255            "root": {
256                "type": "node",
257                "node": "unknown_type"
258            }
259        });
260
261        let result = validate_behavior_config(&config, &registry).unwrap();
262        assert!(!result.valid);
263        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
264    }
265
266    /// LAB-2054: before this, the hand-rolled validator accepted a leaf node shaped
267    /// as `{"type": "<node_type>"}` directly -- a shape that never matched the real
268    /// `BehaviorConfig`/`CompositionConfig` struct at all (its `Node` variant tags
269    /// as `"type": "node"` with the actual identifier in a separate `node` field,
270    /// per `#[serde(tag = "type")]`). Real schema enforcement must reject it.
271    #[test]
272    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
273        let registry = NodeRegistry::new();
274        let config = json!({
275            "name": "test",
276            "root": {
277                "type": "wander",
278                "config": {}
279            }
280        });
281
282        let result = validate_behavior_config(&config, &registry).unwrap();
283        assert!(!result.valid);
284    }
285
286    #[test]
287    fn test_validate_valid_leaf_node_config() {
288        let mut registry = NodeRegistry::new();
289        crate::register_builtin_actions(&mut registry);
290
291        let config = json!({
292            "name": "test",
293            "root": {
294                "type": "node",
295                "node": "wander",
296                "config": { "speed": 0.5 }
297            }
298        });
299
300        let result = validate_behavior_config(&config, &registry).unwrap();
301        assert!(result.valid, "{:?}", result.errors);
302    }
303
304    #[test]
305    fn test_validate_empty_children_warns() {
306        let registry = NodeRegistry::new();
307        let config = json!({
308            "name": "test",
309            "root": {
310                "type": "sequence",
311                "children": []
312            }
313        });
314
315        let result = validate_behavior_config(&config, &registry).unwrap();
316        assert!(result.valid);
317        assert!(result.warnings.iter().any(|w| w.contains("no children")));
318    }
319
320    #[test]
321    fn validate_behavior_composition_json_rejects_non_object() {
322        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
323    }
324
325    #[test]
326    fn validate_behavior_composition_json_rejects_wrong_children_type() {
327        let config = json!({
328            "name": "test",
329            "root": {
330                "type": "sequence",
331                "children": "not an array"
332            }
333        });
334
335        assert!(validate_behavior_composition_json(&config).is_err());
336    }
337
338    #[test]
339    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
340        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
341            .join("../mecha10-templates/templates/configs/behaviors/@mecha10")
342            .canonicalize()
343            .unwrap();
344
345        let mut checked = 0;
346        for entry in std::fs::read_dir(&templates_dir).unwrap() {
347            let config_path = entry.unwrap().path().join("config.json");
348            if !config_path.is_file() {
349                continue;
350            }
351            let content = std::fs::read_to_string(&config_path).unwrap();
352            let value: Value = serde_json::from_str(&content).unwrap();
353            assert!(
354                validate_behavior_composition_json(&value).is_ok(),
355                "{} should validate against the real BehaviorConfig schema",
356                config_path.display()
357            );
358            checked += 1;
359        }
360        assert!(
361            checked > 0,
362            "expected at least one seed behavior config template to check"
363        );
364    }
365}