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(|| {
30 let root_schema = schemars::schema_for!(BehaviorConfig);
31 serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
32});
33
34static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
39 jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
40});
41
42#[derive(Debug, Clone)]
44pub struct ValidationResult {
45 pub valid: bool,
46 pub errors: Vec<String>,
47 pub warnings: Vec<String>,
48}
49
50impl ValidationResult {
51 pub fn success() -> Self {
53 Self {
54 valid: true,
55 errors: Vec::new(),
56 warnings: Vec::new(),
57 }
58 }
59
60 pub fn with_errors(errors: Vec<String>) -> Self {
62 Self {
63 valid: false,
64 errors,
65 warnings: Vec::new(),
66 }
67 }
68
69 pub fn add_warning(&mut self, warning: String) {
71 self.warnings.push(warning);
72 }
73
74 pub fn add_error(&mut self, error: String) {
76 self.errors.push(error);
77 self.valid = false;
78 }
79}
80
81pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
94 if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
95 let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
96 .iter_errors(config)
97 .map(|e| format!(" - {}: {} ({})", e.instance_path(), e, e.instance()))
98 .collect();
99 return Err(anyhow!(
100 "Behavior config schema validation failed:\n{}",
101 messages.join("\n")
102 ));
103 }
104
105 Ok(())
106}
107
108pub fn validate_behavior_config(config: &Value, registry: &NodeRegistry) -> Result<ValidationResult> {
140 let mut result = ValidationResult::success();
141
142 if let Err(schema_err) = validate_behavior_composition_json(config) {
143 result.add_error(schema_err.to_string());
144 }
145
146 if let Some(root) = config.get("root") {
150 validate_node_types(root, registry, &mut result, "root");
151 }
152
153 if config.get("$schema").is_none() {
155 result
156 .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
157 }
158
159 Ok(result)
160}
161
162fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
168 let Some(obj) = node.as_object() else {
169 return;
170 };
171
172 let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
173 return;
174 };
175
176 match node_type {
177 "node" => {
178 if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
179 if !registry.has_node(node_name) {
180 result.add_error(format!(
181 "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
182 path, node_name
183 ));
184 }
185 }
186 }
187 "sequence" | "selector" | "parallel" => {
188 if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
189 if children.is_empty() {
190 result.add_warning(format!(
191 "Composition node at '{}' has no children (will immediately return success/failure)",
192 path
193 ));
194 }
195
196 for (i, child) in children.iter().enumerate() {
197 let child_path = format!("{}.children[{}]", path, i);
198 validate_node_types(child, registry, result, &child_path);
199 }
200 }
201 }
202 _ => {}
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::NodeRegistry;
212 use serde_json::json;
213
214 #[test]
215 fn test_validate_missing_name() {
216 let registry = NodeRegistry::new();
217 let config = json!({
218 "root": {
219 "type": "sequence",
220 "children": []
221 }
222 });
223
224 let result = validate_behavior_config(&config, ®istry).unwrap();
225 assert!(!result.valid);
226 assert!(result.errors.iter().any(|e| e.contains("name")));
227 }
228
229 #[test]
230 fn test_validate_missing_root() {
231 let registry = NodeRegistry::new();
232 let config = json!({
233 "name": "test"
234 });
235
236 let result = validate_behavior_config(&config, ®istry).unwrap();
237 assert!(!result.valid);
238 assert!(result.errors.iter().any(|e| e.contains("root")));
239 }
240
241 #[test]
242 fn test_validate_valid_config() {
243 let registry = NodeRegistry::new();
244 let config = json!({
245 "name": "test",
246 "root": {
247 "type": "sequence",
248 "children": []
249 }
250 });
251
252 let result = validate_behavior_config(&config, ®istry).unwrap();
253 assert!(result.valid, "{:?}", result.errors);
254 }
255
256 #[test]
257 fn test_validate_unknown_node_type() {
258 let registry = NodeRegistry::new();
259 let config = json!({
260 "name": "test",
261 "root": {
262 "type": "node",
263 "node": "unknown_type"
264 }
265 });
266
267 let result = validate_behavior_config(&config, ®istry).unwrap();
268 assert!(!result.valid);
269 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
270 }
271
272 #[test]
278 fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
279 let registry = NodeRegistry::new();
280 let config = json!({
281 "name": "test",
282 "root": {
283 "type": "wander",
284 "config": {}
285 }
286 });
287
288 let result = validate_behavior_config(&config, ®istry).unwrap();
289 assert!(!result.valid);
290 }
291
292 #[test]
293 fn test_validate_valid_leaf_node_config() {
294 let mut registry = NodeRegistry::new();
295 crate::register_builtin_actions(&mut registry);
296
297 let config = json!({
298 "name": "test",
299 "root": {
300 "type": "node",
301 "node": "wander",
302 "config": { "speed": 0.5 }
303 }
304 });
305
306 let result = validate_behavior_config(&config, ®istry).unwrap();
307 assert!(result.valid, "{:?}", result.errors);
308 }
309
310 #[test]
311 fn test_validate_empty_children_warns() {
312 let registry = NodeRegistry::new();
313 let config = json!({
314 "name": "test",
315 "root": {
316 "type": "sequence",
317 "children": []
318 }
319 });
320
321 let result = validate_behavior_config(&config, ®istry).unwrap();
322 assert!(result.valid);
323 assert!(result.warnings.iter().any(|w| w.contains("no children")));
324 }
325
326 #[test]
327 fn validate_behavior_composition_json_rejects_non_object() {
328 assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
329 }
330
331 #[test]
332 fn validate_behavior_composition_json_rejects_wrong_children_type() {
333 let config = json!({
334 "name": "test",
335 "root": {
336 "type": "sequence",
337 "children": "not an array"
338 }
339 });
340
341 assert!(validate_behavior_composition_json(&config).is_err());
342 }
343
344 #[test]
345 fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
346 let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
350 .join("../mecha10-templates/templates/configs/behaviors/@mecha10")
351 .canonicalize()
352 .unwrap();
353
354 let mut checked = 0;
355 for entry in std::fs::read_dir(&templates_dir).unwrap() {
356 let config_path = entry.unwrap().path().join("config.json");
357 if !config_path.is_file() {
358 continue;
359 }
360 let content = std::fs::read_to_string(&config_path).unwrap();
361 let value: Value = serde_json::from_str(&content).unwrap();
362 assert!(
363 validate_behavior_composition_json(&value).is_ok(),
364 "{} should validate against the real BehaviorConfig schema",
365 config_path.display()
366 );
367 checked += 1;
368 }
369 assert!(
370 checked > 0,
371 "expected at least one seed behavior config template to check"
372 );
373 }
374}