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 `cargo run --example export_schemas`
7//! renders to `schemas/behavior.schema.json`, see that command's own regression tests)
8//! 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
27static BEHAVIOR_CONFIG_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
28    let root_schema = schemars::schema_for!(BehaviorConfig);
29    serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
30});
31
32static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
33    jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
34});
35
36#[derive(Debug, Clone)]
37pub struct ValidationResult {
38    pub valid: bool,
39    pub errors: Vec<String>,
40    pub warnings: Vec<String>,
41}
42
43impl ValidationResult {
44    /// Create a successful validation result
45    pub fn success() -> Self {
46        Self {
47            valid: true,
48            errors: Vec::new(),
49            warnings: Vec::new(),
50        }
51    }
52
53    /// Create a failed validation result with errors
54    pub fn with_errors(errors: Vec<String>) -> Self {
55        Self {
56            valid: false,
57            errors,
58            warnings: Vec::new(),
59        }
60    }
61
62    /// Add a warning to the result
63    pub fn add_warning(&mut self, warning: String) {
64        self.warnings.push(warning);
65    }
66
67    /// Add an error to the result
68    pub fn add_error(&mut self, error: String) {
69        self.errors.push(error);
70        self.valid = false;
71    }
72}
73
74/// Validate a behavior composition JSON value against the real
75/// `schemars`-derived [`BehaviorConfig`] JSON Schema -- structural validation only,
76/// no [`NodeRegistry`] required. This is the piece [`validate_behavior_config`]
77/// layers its registry-based node-type check on top of, and is also what
78/// `mecha10 config validate --path <file>` calls directly for a standalone behavior
79/// config file with no project/registry context available (see
80/// `packages/cli/src/config_validation.rs`).
81///
82/// # Errors
83///
84/// Returns an error describing every schema violation found if `config` doesn't
85/// match [`BehaviorConfig`]'s shape.
86pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
87    if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
88        let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
89            .iter_errors(config)
90            .map(|e| format!("  - {}: {} ({})", e.instance_path(), e, e.instance()))
91            .collect();
92        return Err(anyhow!(
93            "Behavior config schema validation failed:\n{}",
94            messages.join("\n")
95        ));
96    }
97
98    Ok(())
99}
100
101/// Validate a behavior tree configuration
102///
103/// Checks:
104/// - The config matches the real `BehaviorConfig` JSON Schema (required fields,
105///   composition node shapes, etc.) -- see [`validate_behavior_composition_json`].
106/// - Every leaf node's `node` type exists in `registry` -- a rule JSON Schema can't
107///   express, since it depends on which behavior nodes are registered at runtime.
108///
109/// # Arguments
110///
111/// * `config` - Behavior tree configuration JSON
112/// * `registry` - Node registry for validating node types
113///
114/// # Example
115///
116/// ```rust,ignore
117/// use mecha10_behavior_runtime::config::validate_behavior_config;
118/// use mecha10_behavior_runtime::NodeRegistry;
119///
120/// let registry = NodeRegistry::new();
121/// let config = serde_json::json!({
122///     "name": "test_behavior",
123///     "root": {
124///         "type": "sequence",
125///         "children": []
126///     }
127/// });
128///
129/// let result = validate_behavior_config(&config, &registry)?;
130/// assert!(result.valid);
131/// ```
132pub fn validate_behavior_config(config: &Value, registry: &NodeRegistry) -> Result<ValidationResult> {
133    let mut result = ValidationResult::success();
134
135    if let Err(schema_err) = validate_behavior_composition_json(config) {
136        result.add_error(schema_err.to_string());
137    }
138
139    if let Some(root) = config.get("root") {
140        validate_node_types(root, registry, &mut result, "root");
141    }
142
143    if config.get("$schema").is_none() {
144        result
145            .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
146    }
147
148    Ok(result)
149}
150
151/// Walk the composition tree checking that every leaf node's `node` identifier is
152/// registered in `registry` -- the one rule JSON Schema can't express (it doesn't
153/// know what's registered at runtime). Skips anything schema validation already
154/// reports (non-object nodes, missing/invalid `type`/`children`) rather than
155/// duplicating those structural checks here.
156fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
157    let Some(obj) = node.as_object() else {
158        return;
159    };
160
161    let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
162        return;
163    };
164
165    match node_type {
166        "node" => {
167            if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
168                if !registry.has_node(node_name) {
169                    result.add_error(format!(
170                        "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
171                        path, node_name
172                    ));
173                }
174            }
175        }
176        "sequence" | "selector" | "parallel" | "watch" => {
177            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
178                if children.is_empty() {
179                    result.add_warning(format!(
180                        "Composition node at '{}' has no children (will immediately return success/failure)",
181                        path
182                    ));
183                }
184
185                for (i, child) in children.iter().enumerate() {
186                    let child_path = format!("{}.children[{}]", path, i);
187                    validate_node_types(child, registry, result, &child_path);
188                }
189            }
190        }
191        "inverter" | "force_success" | "force_failure" | "repeat" | "retry" | "timeout" => {
192            if let Some(child) = obj.get("child") {
193                let child_path = format!("{}.child", path);
194                validate_node_types(child, registry, result, &child_path);
195            }
196        }
197        "subsumption" => {
198            if let Some(layers) = obj.get("layers").and_then(|v| v.as_array()) {
199                if layers.is_empty() {
200                    result.add_warning(format!(
201                        "Subsumption node at '{}' has no layers (will immediately return failure)",
202                        path
203                    ));
204                }
205
206                for (i, layer) in layers.iter().enumerate() {
207                    if let Some(node) = layer.get("node") {
208                        let child_path = format!("{}.layers[{}].node", path, i);
209                        validate_node_types(node, registry, result, &child_path);
210                    }
211                }
212            }
213        }
214        _ => {}
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::NodeRegistry;
222    use serde_json::json;
223
224    #[test]
225    fn test_validate_missing_name() {
226        let registry = NodeRegistry::new();
227        let config = json!({
228            "root": {
229                "type": "sequence",
230                "children": []
231            }
232        });
233
234        let result = validate_behavior_config(&config, &registry).unwrap();
235        assert!(!result.valid);
236        assert!(result.errors.iter().any(|e| e.contains("name")));
237    }
238
239    #[test]
240    fn test_validate_missing_root() {
241        let registry = NodeRegistry::new();
242        let config = json!({
243            "name": "test"
244        });
245
246        let result = validate_behavior_config(&config, &registry).unwrap();
247        assert!(!result.valid);
248        assert!(result.errors.iter().any(|e| e.contains("root")));
249    }
250
251    #[test]
252    fn test_validate_valid_config() {
253        let registry = NodeRegistry::new();
254        let config = json!({
255            "name": "test",
256            "root": {
257                "type": "sequence",
258                "children": []
259            }
260        });
261
262        let result = validate_behavior_config(&config, &registry).unwrap();
263        assert!(result.valid, "{:?}", result.errors);
264    }
265
266    #[test]
267    fn test_validate_unknown_node_type() {
268        let registry = NodeRegistry::new();
269        let config = json!({
270            "name": "test",
271            "root": {
272                "type": "node",
273                "node": "unknown_type"
274            }
275        });
276
277        let result = validate_behavior_config(&config, &registry).unwrap();
278        assert!(!result.valid);
279        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
280    }
281
282    /// LAB-2054: before this, the hand-rolled validator accepted a leaf node shaped
283    /// as `{"type": "<node_type>"}` directly -- a shape that never matched the real
284    /// `BehaviorConfig`/`CompositionConfig` struct at all (its `Node` variant tags
285    /// as `"type": "node"` with the actual identifier in a separate `node` field,
286    /// per `#[serde(tag = "type")]`). Real schema enforcement must reject it.
287    #[test]
288    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
289        let registry = NodeRegistry::new();
290        let config = json!({
291            "name": "test",
292            "root": {
293                "type": "wander",
294                "config": {}
295            }
296        });
297
298        let result = validate_behavior_config(&config, &registry).unwrap();
299        assert!(!result.valid);
300    }
301
302    #[test]
303    fn test_validate_valid_leaf_node_config() {
304        let mut registry = NodeRegistry::new();
305        crate::register_builtin_actions(&mut registry);
306
307        let config = json!({
308            "name": "test",
309            "root": {
310                "type": "node",
311                "node": "wander",
312                "config": { "speed": 0.5 }
313            }
314        });
315
316        let result = validate_behavior_config(&config, &registry).unwrap();
317        assert!(result.valid, "{:?}", result.errors);
318    }
319
320    #[test]
321    fn test_validate_empty_children_warns() {
322        let registry = NodeRegistry::new();
323        let config = json!({
324            "name": "test",
325            "root": {
326                "type": "sequence",
327                "children": []
328            }
329        });
330
331        let result = validate_behavior_config(&config, &registry).unwrap();
332        assert!(result.valid);
333        assert!(result.warnings.iter().any(|w| w.contains("no children")));
334    }
335
336    #[test]
337    fn test_validate_watch_recurses_into_children_like_parallel() {
338        let registry = NodeRegistry::new();
339        let config = json!({
340            "name": "test",
341            "root": {
342                "type": "watch",
343                "policy": "require_one",
344                "children": [
345                    { "type": "node", "node": "unknown_type" }
346                ]
347            }
348        });
349
350        let result = validate_behavior_config(&config, &registry).unwrap();
351        assert!(!result.valid);
352        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
353    }
354
355    #[test]
356    fn test_validate_subsumption_recurses_into_layer_nodes() {
357        let registry = NodeRegistry::new();
358        let config = json!({
359            "name": "test",
360            "root": {
361                "type": "subsumption",
362                "layers": [
363                    { "priority": 10, "node": { "type": "node", "node": "unknown_type" } },
364                    { "priority": 0, "node": { "type": "sequence", "children": [] } }
365                ]
366            }
367        });
368
369        let result = validate_behavior_config(&config, &registry).unwrap();
370        assert!(!result.valid);
371        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
372    }
373
374    #[test]
375    fn test_validate_decorator_recurses_into_child() {
376        let registry = NodeRegistry::new();
377        for decorator_type in [
378            "inverter",
379            "force_success",
380            "force_failure",
381            "repeat",
382            "retry",
383            "timeout",
384        ] {
385            let config = json!({
386                "name": "test",
387                "root": {
388                    "type": decorator_type,
389                    "max_attempts": 1,
390                    "timeout_secs": 1.0,
391                    "child": { "type": "node", "node": "unknown_type" }
392                }
393            });
394
395            let result = validate_behavior_config(&config, &registry).unwrap();
396            assert!(
397                result.errors.iter().any(|e| e.contains("unknown_type")),
398                "decorator '{}' should recurse into its child: {:?}",
399                decorator_type,
400                result.errors
401            );
402        }
403    }
404
405    #[test]
406    fn test_validate_subsumption_empty_layers_warns() {
407        let registry = NodeRegistry::new();
408        let config = json!({
409            "name": "test",
410            "root": {
411                "type": "subsumption",
412                "layers": []
413            }
414        });
415
416        let result = validate_behavior_config(&config, &registry).unwrap();
417        assert!(result.valid);
418        assert!(result.warnings.iter().any(|w| w.contains("no layers")));
419    }
420
421    #[test]
422    fn validate_behavior_composition_json_rejects_non_object() {
423        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
424    }
425
426    #[test]
427    fn validate_behavior_composition_json_rejects_wrong_children_type() {
428        let config = json!({
429            "name": "test",
430            "root": {
431                "type": "sequence",
432                "children": "not an array"
433            }
434        });
435
436        assert!(validate_behavior_composition_json(&config).is_err());
437    }
438
439    #[test]
440    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
441        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
442            .join("../mecha10-templates/templates/configs/behaviors/@mecha10")
443            .canonicalize()
444            .unwrap();
445
446        let mut checked = 0;
447        for entry in std::fs::read_dir(&templates_dir).unwrap() {
448            let config_path = entry.unwrap().path().join("config.json");
449            if !config_path.is_file() {
450                continue;
451            }
452            let content = std::fs::read_to_string(&config_path).unwrap();
453            let value: Value = serde_json::from_str(&content).unwrap();
454            assert!(
455                validate_behavior_composition_json(&value).is_ok(),
456                "{} should validate against the real BehaviorConfig schema",
457                config_path.display()
458            );
459            checked += 1;
460        }
461        assert!(
462            checked > 0,
463            "expected at least one seed behavior config template to check"
464        );
465    }
466}