mecha10_behavior_runtime/config/
validator.rs1use 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 pub fn success() -> Self {
46 Self {
47 valid: true,
48 errors: Vec::new(),
49 warnings: Vec::new(),
50 }
51 }
52
53 pub fn with_errors(errors: Vec<String>) -> Self {
55 Self {
56 valid: false,
57 errors,
58 warnings: Vec::new(),
59 }
60 }
61
62 pub fn add_warning(&mut self, warning: String) {
64 self.warnings.push(warning);
65 }
66
67 pub fn add_error(&mut self, error: String) {
69 self.errors.push(error);
70 self.valid = false;
71 }
72}
73
74pub 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
101pub 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
151fn 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, ®istry).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, ®istry).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, ®istry).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, ®istry).unwrap();
278 assert!(!result.valid);
279 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
280 }
281
282 #[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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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, ®istry).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}