use super::types::BehaviorConfig;
use crate::registry::NodeRegistry;
use anyhow::{anyhow, Result};
use serde_json::Value;
use std::sync::LazyLock;
static BEHAVIOR_CONFIG_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
let root_schema = schemars::schema_for!(BehaviorConfig);
serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
});
static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
});
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl ValidationResult {
pub fn success() -> Self {
Self {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn with_errors(errors: Vec<String>) -> Self {
Self {
valid: false,
errors,
warnings: Vec::new(),
}
}
pub fn add_warning(&mut self, warning: String) {
self.warnings.push(warning);
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
self.valid = false;
}
}
pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
.iter_errors(config)
.map(|e| format!(" - {}: {} ({})", e.instance_path(), e, e.instance()))
.collect();
return Err(anyhow!(
"Behavior config schema validation failed:\n{}",
messages.join("\n")
));
}
Ok(())
}
pub fn validate_behavior_config(config: &Value, registry: &NodeRegistry) -> Result<ValidationResult> {
let mut result = ValidationResult::success();
if let Err(schema_err) = validate_behavior_composition_json(config) {
result.add_error(schema_err.to_string());
}
if let Some(root) = config.get("root") {
validate_node_types(root, registry, &mut result, "root");
}
if config.get("$schema").is_none() {
result
.add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
}
Ok(result)
}
fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
let Some(obj) = node.as_object() else {
return;
};
let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
return;
};
match node_type {
"node" => {
if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
if !registry.has_node(node_name) {
result.add_error(format!(
"Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
path, node_name
));
}
}
}
"sequence" | "selector" | "parallel" | "watch" => {
if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
if children.is_empty() {
result.add_warning(format!(
"Composition node at '{}' has no children (will immediately return success/failure)",
path
));
}
for (i, child) in children.iter().enumerate() {
let child_path = format!("{}.children[{}]", path, i);
validate_node_types(child, registry, result, &child_path);
}
}
}
"inverter" | "force_success" | "force_failure" | "repeat" | "retry" | "timeout" => {
if let Some(child) = obj.get("child") {
let child_path = format!("{}.child", path);
validate_node_types(child, registry, result, &child_path);
}
}
"subsumption" => {
if let Some(layers) = obj.get("layers").and_then(|v| v.as_array()) {
if layers.is_empty() {
result.add_warning(format!(
"Subsumption node at '{}' has no layers (will immediately return failure)",
path
));
}
for (i, layer) in layers.iter().enumerate() {
if let Some(node) = layer.get("node") {
let child_path = format!("{}.layers[{}].node", path, i);
validate_node_types(node, registry, result, &child_path);
}
}
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NodeRegistry;
use serde_json::json;
#[test]
fn test_validate_missing_name() {
let registry = NodeRegistry::new();
let config = json!({
"root": {
"type": "sequence",
"children": []
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("name")));
}
#[test]
fn test_validate_missing_root() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test"
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("root")));
}
#[test]
fn test_validate_valid_config() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "sequence",
"children": []
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(result.valid, "{:?}", result.errors);
}
#[test]
fn test_validate_unknown_node_type() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "node",
"node": "unknown_type"
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
}
#[test]
fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "wander",
"config": {}
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(!result.valid);
}
#[test]
fn test_validate_valid_leaf_node_config() {
let mut registry = NodeRegistry::new();
crate::register_builtin_actions(&mut registry);
let config = json!({
"name": "test",
"root": {
"type": "node",
"node": "wander",
"config": { "speed": 0.5 }
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(result.valid, "{:?}", result.errors);
}
#[test]
fn test_validate_empty_children_warns() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "sequence",
"children": []
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(result.valid);
assert!(result.warnings.iter().any(|w| w.contains("no children")));
}
#[test]
fn test_validate_watch_recurses_into_children_like_parallel() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "watch",
"policy": "require_one",
"children": [
{ "type": "node", "node": "unknown_type" }
]
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
}
#[test]
fn test_validate_subsumption_recurses_into_layer_nodes() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "subsumption",
"layers": [
{ "priority": 10, "node": { "type": "node", "node": "unknown_type" } },
{ "priority": 0, "node": { "type": "sequence", "children": [] } }
]
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
}
#[test]
fn test_validate_decorator_recurses_into_child() {
let registry = NodeRegistry::new();
for decorator_type in [
"inverter",
"force_success",
"force_failure",
"repeat",
"retry",
"timeout",
] {
let config = json!({
"name": "test",
"root": {
"type": decorator_type,
"max_attempts": 1,
"timeout_secs": 1.0,
"child": { "type": "node", "node": "unknown_type" }
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(
result.errors.iter().any(|e| e.contains("unknown_type")),
"decorator '{}' should recurse into its child: {:?}",
decorator_type,
result.errors
);
}
}
#[test]
fn test_validate_subsumption_empty_layers_warns() {
let registry = NodeRegistry::new();
let config = json!({
"name": "test",
"root": {
"type": "subsumption",
"layers": []
}
});
let result = validate_behavior_config(&config, ®istry).unwrap();
assert!(result.valid);
assert!(result.warnings.iter().any(|w| w.contains("no layers")));
}
#[test]
fn validate_behavior_composition_json_rejects_non_object() {
assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
}
#[test]
fn validate_behavior_composition_json_rejects_wrong_children_type() {
let config = json!({
"name": "test",
"root": {
"type": "sequence",
"children": "not an array"
}
});
assert!(validate_behavior_composition_json(&config).is_err());
}
#[test]
fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/seed_behaviors")
.canonicalize()
.unwrap();
let mut checked = 0;
for entry in std::fs::read_dir(&templates_dir).unwrap() {
let config_path = entry.unwrap().path().join("config.json");
if !config_path.is_file() {
continue;
}
let content = std::fs::read_to_string(&config_path).unwrap();
let value: Value = serde_json::from_str(&content).unwrap();
assert!(
validate_behavior_composition_json(&value).is_ok(),
"{} should validate against the real BehaviorConfig schema",
config_path.display()
);
checked += 1;
}
assert!(
checked > 0,
"expected at least one seed behavior config template to check"
);
}
}