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") {
147 validate_node_types(root, registry, &mut result, "root");
148 }
149
150 if config.get("$schema").is_none() {
151 result
152 .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
153 }
154
155 Ok(result)
156}
157
158fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
164 let Some(obj) = node.as_object() else {
165 return;
166 };
167
168 let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
169 return;
170 };
171
172 match node_type {
173 "node" => {
174 if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
175 if !registry.has_node(node_name) {
176 result.add_error(format!(
177 "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
178 path, node_name
179 ));
180 }
181 }
182 }
183 "sequence" | "selector" | "parallel" => {
184 if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
185 if children.is_empty() {
186 result.add_warning(format!(
187 "Composition node at '{}' has no children (will immediately return success/failure)",
188 path
189 ));
190 }
191
192 for (i, child) in children.iter().enumerate() {
193 let child_path = format!("{}.children[{}]", path, i);
194 validate_node_types(child, registry, result, &child_path);
195 }
196 }
197 }
198 _ => {}
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::NodeRegistry;
206 use serde_json::json;
207
208 #[test]
209 fn test_validate_missing_name() {
210 let registry = NodeRegistry::new();
211 let config = json!({
212 "root": {
213 "type": "sequence",
214 "children": []
215 }
216 });
217
218 let result = validate_behavior_config(&config, ®istry).unwrap();
219 assert!(!result.valid);
220 assert!(result.errors.iter().any(|e| e.contains("name")));
221 }
222
223 #[test]
224 fn test_validate_missing_root() {
225 let registry = NodeRegistry::new();
226 let config = json!({
227 "name": "test"
228 });
229
230 let result = validate_behavior_config(&config, ®istry).unwrap();
231 assert!(!result.valid);
232 assert!(result.errors.iter().any(|e| e.contains("root")));
233 }
234
235 #[test]
236 fn test_validate_valid_config() {
237 let registry = NodeRegistry::new();
238 let config = json!({
239 "name": "test",
240 "root": {
241 "type": "sequence",
242 "children": []
243 }
244 });
245
246 let result = validate_behavior_config(&config, ®istry).unwrap();
247 assert!(result.valid, "{:?}", result.errors);
248 }
249
250 #[test]
251 fn test_validate_unknown_node_type() {
252 let registry = NodeRegistry::new();
253 let config = json!({
254 "name": "test",
255 "root": {
256 "type": "node",
257 "node": "unknown_type"
258 }
259 });
260
261 let result = validate_behavior_config(&config, ®istry).unwrap();
262 assert!(!result.valid);
263 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
264 }
265
266 #[test]
272 fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
273 let registry = NodeRegistry::new();
274 let config = json!({
275 "name": "test",
276 "root": {
277 "type": "wander",
278 "config": {}
279 }
280 });
281
282 let result = validate_behavior_config(&config, ®istry).unwrap();
283 assert!(!result.valid);
284 }
285
286 #[test]
287 fn test_validate_valid_leaf_node_config() {
288 let mut registry = NodeRegistry::new();
289 crate::register_builtin_actions(&mut registry);
290
291 let config = json!({
292 "name": "test",
293 "root": {
294 "type": "node",
295 "node": "wander",
296 "config": { "speed": 0.5 }
297 }
298 });
299
300 let result = validate_behavior_config(&config, ®istry).unwrap();
301 assert!(result.valid, "{:?}", result.errors);
302 }
303
304 #[test]
305 fn test_validate_empty_children_warns() {
306 let registry = NodeRegistry::new();
307 let config = json!({
308 "name": "test",
309 "root": {
310 "type": "sequence",
311 "children": []
312 }
313 });
314
315 let result = validate_behavior_config(&config, ®istry).unwrap();
316 assert!(result.valid);
317 assert!(result.warnings.iter().any(|w| w.contains("no children")));
318 }
319
320 #[test]
321 fn validate_behavior_composition_json_rejects_non_object() {
322 assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
323 }
324
325 #[test]
326 fn validate_behavior_composition_json_rejects_wrong_children_type() {
327 let config = json!({
328 "name": "test",
329 "root": {
330 "type": "sequence",
331 "children": "not an array"
332 }
333 });
334
335 assert!(validate_behavior_composition_json(&config).is_err());
336 }
337
338 #[test]
339 fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
340 let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
341 .join("../mecha10-templates/templates/configs/behaviors/@mecha10")
342 .canonicalize()
343 .unwrap();
344
345 let mut checked = 0;
346 for entry in std::fs::read_dir(&templates_dir).unwrap() {
347 let config_path = entry.unwrap().path().join("config.json");
348 if !config_path.is_file() {
349 continue;
350 }
351 let content = std::fs::read_to_string(&config_path).unwrap();
352 let value: Value = serde_json::from_str(&content).unwrap();
353 assert!(
354 validate_behavior_composition_json(&value).is_ok(),
355 "{} should validate against the real BehaviorConfig schema",
356 config_path.display()
357 );
358 checked += 1;
359 }
360 assert!(
361 checked > 0,
362 "expected at least one seed behavior config template to check"
363 );
364 }
365}