mecha10_behavior_runtime/config/
validator.rs1use 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 pub fn success() -> Self {
30 Self {
31 valid: true,
32 errors: Vec::new(),
33 warnings: Vec::new(),
34 }
35 }
36
37 pub fn with_errors(errors: Vec<String>) -> Self {
39 Self {
40 valid: false,
41 errors,
42 warnings: Vec::new(),
43 }
44 }
45
46 pub fn add_warning(&mut self, warning: String) {
48 self.warnings.push(warning);
49 }
50
51 pub fn add_error(&mut self, error: String) {
53 self.errors.push(error);
54 self.valid = false;
55 }
56}
57
58pub 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
76pub 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
98fn 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, ®istry).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, ®istry).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, ®istry).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, ®istry).unwrap();
223 assert!(!result.valid);
224 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
225 }
226
227 #[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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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}