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