1use super::types::BehaviorConfig;
6use crate::registry::NodeRegistry;
7use anyhow::{anyhow, Result};
8use mecha10_core::node::Capability;
9use serde_json::Value;
10use std::collections::HashSet;
11use std::sync::LazyLock;
12
13static BEHAVIOR_CONFIG_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
14 let root_schema = schemars::schema_for!(BehaviorConfig);
15 serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
16});
17
18static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
19 jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
20});
21
22#[derive(Debug, Clone)]
23pub struct ValidationResult {
24 pub valid: bool,
25 pub errors: Vec<String>,
26 pub warnings: Vec<String>,
27}
28
29impl ValidationResult {
30 pub fn success() -> Self {
32 Self {
33 valid: true,
34 errors: Vec::new(),
35 warnings: Vec::new(),
36 }
37 }
38
39 pub fn with_errors(errors: Vec<String>) -> Self {
41 Self {
42 valid: false,
43 errors,
44 warnings: Vec::new(),
45 }
46 }
47
48 pub fn add_warning(&mut self, warning: String) {
50 self.warnings.push(warning);
51 }
52
53 pub fn add_error(&mut self, error: String) {
55 self.errors.push(error);
56 self.valid = false;
57 }
58}
59
60pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
64 if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
65 let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
66 .iter_errors(config)
67 .map(|e| format!(" - {}: {} ({})", e.instance_path(), e, e.instance()))
68 .collect();
69 return Err(anyhow!(
70 "Behavior config schema validation failed:\n{}",
71 messages.join("\n")
72 ));
73 }
74
75 Ok(())
76}
77
78pub fn validate_behavior_config(
88 config: &Value,
89 registry: &NodeRegistry,
90 available_capabilities: &HashSet<Capability>,
91) -> Result<ValidationResult> {
92 let mut result = ValidationResult::success();
93
94 if let Err(schema_err) = validate_behavior_composition_json(config) {
95 result.add_error(schema_err.to_string());
96 }
97
98 if let Some(root) = config.get("root") {
99 validate_node_types(root, registry, &mut result, "root");
100 }
101
102 validate_required_capabilities(config, available_capabilities, &mut result);
103
104 if config.get("$schema").is_none() {
105 result
106 .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
107 }
108
109 Ok(result)
110}
111
112fn validate_required_capabilities(
117 config: &Value,
118 available_capabilities: &HashSet<Capability>,
119 result: &mut ValidationResult,
120) {
121 let Some(required) = config.get("requires_capabilities").and_then(|v| v.as_array()) else {
122 return;
123 };
124
125 for value in required {
126 let Ok(capability) = serde_json::from_value::<Capability>(value.clone()) else {
129 continue;
130 };
131
132 if !available_capabilities.contains(&capability) {
133 result.add_error(format!(
134 "Behavior requires capability '{}', which is not provided by any enabled node in this project. \
135 Add a node that declares this capability, or remove it from 'requires_capabilities'.",
136 value.as_str().unwrap_or("<unknown>")
137 ));
138 }
139 }
140}
141
142fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
146 let Some(obj) = node.as_object() else {
147 return;
148 };
149
150 let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
151 return;
152 };
153
154 match node_type {
155 "node" => {
156 if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
157 if !registry.has_node(node_name) {
158 result.add_error(format!(
159 "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
160 path, node_name
161 ));
162 }
163 }
164 }
165 "sequence" | "selector" | "parallel" | "watch" => {
166 if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
167 if children.is_empty() {
168 result.add_warning(format!(
169 "Composition node at '{}' has no children (will immediately return success/failure)",
170 path
171 ));
172 }
173
174 for (i, child) in children.iter().enumerate() {
175 let child_path = format!("{}.children[{}]", path, i);
176 validate_node_types(child, registry, result, &child_path);
177 }
178 }
179 }
180 "inverter" | "force_success" | "force_failure" | "repeat" | "retry" | "timeout" => {
181 if let Some(child) = obj.get("child") {
182 let child_path = format!("{}.child", path);
183 validate_node_types(child, registry, result, &child_path);
184 }
185 }
186 "subsumption" => {
187 if let Some(layers) = obj.get("layers").and_then(|v| v.as_array()) {
188 if layers.is_empty() {
189 result.add_warning(format!(
190 "Subsumption node at '{}' has no layers (will immediately return failure)",
191 path
192 ));
193 }
194
195 for (i, layer) in layers.iter().enumerate() {
196 if let Some(node) = layer.get("node") {
197 let child_path = format!("{}.layers[{}].node", path, i);
198 validate_node_types(node, registry, result, &child_path);
199 }
200 }
201 }
202 }
203 _ => {}
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::NodeRegistry;
211 use serde_json::json;
212
213 #[test]
214 fn test_validate_missing_name() {
215 let registry = NodeRegistry::new();
216 let config = json!({
217 "root": {
218 "type": "sequence",
219 "children": []
220 }
221 });
222
223 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
224 assert!(!result.valid);
225 assert!(result.errors.iter().any(|e| e.contains("name")));
226 }
227
228 #[test]
229 fn test_validate_missing_root() {
230 let registry = NodeRegistry::new();
231 let config = json!({
232 "name": "test"
233 });
234
235 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
236 assert!(!result.valid);
237 assert!(result.errors.iter().any(|e| e.contains("root")));
238 }
239
240 #[test]
241 fn test_validate_valid_config() {
242 let registry = NodeRegistry::new();
243 let config = json!({
244 "name": "test",
245 "root": {
246 "type": "sequence",
247 "children": []
248 }
249 });
250
251 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
252 assert!(result.valid, "{:?}", result.errors);
253 }
254
255 #[test]
258 fn test_validate_behavior_with_no_requires_capabilities_is_unaffected() {
259 let registry = NodeRegistry::new();
260 let config = json!({
261 "name": "test",
262 "root": {
263 "type": "sequence",
264 "children": []
265 }
266 });
267
268 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
269 assert!(result.valid, "{:?}", result.errors);
270 }
271
272 #[test]
273 fn test_validate_requires_capabilities_passes_when_satisfied() {
274 let registry = NodeRegistry::new();
275 let config = json!({
276 "name": "test",
277 "root": {
278 "type": "sequence",
279 "children": []
280 },
281 "requires_capabilities": ["locomotion"]
282 });
283
284 let available = HashSet::from([Capability::Locomotion]);
285 let result = validate_behavior_config(&config, ®istry, &available).unwrap();
286 assert!(result.valid, "{:?}", result.errors);
287 }
288
289 #[test]
290 fn test_validate_requires_capabilities_fails_when_unsatisfied() {
291 let registry = NodeRegistry::new();
292 let config = json!({
293 "name": "test",
294 "root": {
295 "type": "sequence",
296 "children": []
297 },
298 "requires_capabilities": ["flight"]
299 });
300
301 let available = HashSet::from([Capability::Locomotion]);
303 let result = validate_behavior_config(&config, ®istry, &available).unwrap();
304 assert!(!result.valid);
305 assert!(
306 result.errors.iter().any(|e| e.contains("flight")),
307 "expected an error naming the missing 'flight' capability, got: {:?}",
308 result.errors
309 );
310 }
311
312 #[test]
313 fn test_validate_requires_capabilities_fails_naming_only_the_missing_one() {
314 let registry = NodeRegistry::new();
315 let config = json!({
316 "name": "test",
317 "root": {
318 "type": "sequence",
319 "children": []
320 },
321 "requires_capabilities": ["locomotion", "flight"]
322 });
323
324 let available = HashSet::from([Capability::Locomotion]);
325 let result = validate_behavior_config(&config, ®istry, &available).unwrap();
326 assert!(!result.valid);
327 assert_eq!(result.errors.len(), 1, "{:?}", result.errors);
328 assert!(result.errors[0].contains("flight"));
329 }
330
331 #[test]
332 fn test_validate_unknown_node_type() {
333 let registry = NodeRegistry::new();
334 let config = json!({
335 "name": "test",
336 "root": {
337 "type": "node",
338 "node": "unknown_type"
339 }
340 });
341
342 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
343 assert!(!result.valid);
344 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
345 }
346
347 #[test]
351 fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
352 let registry = NodeRegistry::new();
353 let config = json!({
354 "name": "test",
355 "root": {
356 "type": "wander",
357 "config": {}
358 }
359 });
360
361 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
362 assert!(!result.valid);
363 }
364
365 #[test]
366 fn test_validate_valid_leaf_node_config() {
367 let mut registry = NodeRegistry::new();
368 crate::register_builtin_actions(&mut registry);
369
370 let config = json!({
371 "name": "test",
372 "root": {
373 "type": "node",
374 "node": "wander",
375 "config": { "speed": 0.5 }
376 }
377 });
378
379 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
380 assert!(result.valid, "{:?}", result.errors);
381 }
382
383 #[test]
384 fn test_validate_empty_children_warns() {
385 let registry = NodeRegistry::new();
386 let config = json!({
387 "name": "test",
388 "root": {
389 "type": "sequence",
390 "children": []
391 }
392 });
393
394 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
395 assert!(result.valid);
396 assert!(result.warnings.iter().any(|w| w.contains("no children")));
397 }
398
399 #[test]
400 fn test_validate_watch_recurses_into_children_like_parallel() {
401 let registry = NodeRegistry::new();
402 let config = json!({
403 "name": "test",
404 "root": {
405 "type": "watch",
406 "policy": "require_one",
407 "children": [
408 { "type": "node", "node": "unknown_type" }
409 ]
410 }
411 });
412
413 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
414 assert!(!result.valid);
415 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
416 }
417
418 #[test]
419 fn test_validate_subsumption_recurses_into_layer_nodes() {
420 let registry = NodeRegistry::new();
421 let config = json!({
422 "name": "test",
423 "root": {
424 "type": "subsumption",
425 "layers": [
426 { "priority": 10, "node": { "type": "node", "node": "unknown_type" } },
427 { "priority": 0, "node": { "type": "sequence", "children": [] } }
428 ]
429 }
430 });
431
432 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
433 assert!(!result.valid);
434 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
435 }
436
437 #[test]
438 fn test_validate_decorator_recurses_into_child() {
439 let registry = NodeRegistry::new();
440 for decorator_type in [
441 "inverter",
442 "force_success",
443 "force_failure",
444 "repeat",
445 "retry",
446 "timeout",
447 ] {
448 let config = json!({
449 "name": "test",
450 "root": {
451 "type": decorator_type,
452 "max_attempts": 1,
453 "timeout_secs": 1.0,
454 "child": { "type": "node", "node": "unknown_type" }
455 }
456 });
457
458 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
459 assert!(
460 result.errors.iter().any(|e| e.contains("unknown_type")),
461 "decorator '{}' should recurse into its child: {:?}",
462 decorator_type,
463 result.errors
464 );
465 }
466 }
467
468 #[test]
469 fn test_validate_subsumption_empty_layers_warns() {
470 let registry = NodeRegistry::new();
471 let config = json!({
472 "name": "test",
473 "root": {
474 "type": "subsumption",
475 "layers": []
476 }
477 });
478
479 let result = validate_behavior_config(&config, ®istry, &HashSet::new()).unwrap();
480 assert!(result.valid);
481 assert!(result.warnings.iter().any(|w| w.contains("no layers")));
482 }
483
484 #[test]
485 fn validate_behavior_composition_json_rejects_non_object() {
486 assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
487 }
488
489 #[test]
490 fn validate_behavior_composition_json_rejects_wrong_children_type() {
491 let config = json!({
492 "name": "test",
493 "root": {
494 "type": "sequence",
495 "children": "not an array"
496 }
497 });
498
499 assert!(validate_behavior_composition_json(&config).is_err());
500 }
501
502 #[test]
503 fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
504 let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
505 .join("tests/fixtures/seed_behaviors")
506 .canonicalize()
507 .unwrap();
508
509 let mut checked = 0;
510 for entry in std::fs::read_dir(&templates_dir).unwrap() {
511 let config_path = entry.unwrap().path().join("config.json");
512 if !config_path.is_file() {
513 continue;
514 }
515 let content = std::fs::read_to_string(&config_path).unwrap();
516 let value: Value = serde_json::from_str(&content).unwrap();
517 assert!(
518 validate_behavior_composition_json(&value).is_ok(),
519 "{} should validate against the real BehaviorConfig schema",
520 config_path.display()
521 );
522 checked += 1;
523 }
524 assert!(
525 checked > 0,
526 "expected at least one seed behavior config template to check"
527 );
528 }
529
530 #[test]
536 fn find_target_seed_behavior_is_the_sole_rl_task_config_and_keeps_the_live_target_override() {
537 let seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
538 .join("tests/fixtures/seed_behaviors")
539 .canonicalize()
540 .unwrap();
541
542 assert!(
543 !seed_dir.join("rl").exists(),
544 "the old 'rl' seed behavior directory should have been folded into 'find_target'"
545 );
546 assert!(
547 !seed_dir.join("find_target_dynamic").exists(),
548 "find_target_dynamic should be folded into a single 'find_target' behavior, not kept alongside it"
549 );
550
551 let config_path = seed_dir.join("find_target/config.json");
552 let content = std::fs::read_to_string(&config_path)
553 .unwrap_or_else(|e| panic!("{} should exist: {}", config_path.display(), e));
554 let value: Value = serde_json::from_str(&content).unwrap();
555
556 assert_eq!(value["name"], "find_target");
557
558 let mut registry = crate::NodeRegistry::new();
559 crate::register_builtin_actions(&mut registry);
560 let result = validate_behavior_config(&value, ®istry, &HashSet::new()).unwrap();
561 assert!(result.valid, "{:?}", result.errors);
562
563 let serialized = content;
564 assert!(
565 serialized.contains("\"rl_task\""),
566 "find_target should still hand control to the rl node via the rl_task node"
567 );
568 assert!(
569 serialized.contains("\"set_rl_target\""),
570 "find_target should still relay live dashboard target-class overrides via set_rl_target, \
571 regardless of whether the rl node's own config.json selects onnx or act_torchscript"
572 );
573 }
574}