use car_ir::{Action, Precondition, ToolSchema};
use car_ir::precondition;
use car_state::StateStore;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ValidationError {
pub action_id: String,
pub reason: String,
}
#[derive(Debug)]
pub struct ValidationResult {
pub action_id: String,
pub errors: Vec<ValidationError>,
}
impl ValidationResult {
pub fn valid(&self) -> bool {
self.errors.is_empty()
}
}
pub fn check_precondition(pre: &Precondition, state: &StateStore) -> Option<String> {
precondition::check_precondition(pre, state)
}
fn validate_parameters(
action: &car_ir::Action,
tool: &str,
schema: &ToolSchema,
result: &mut ValidationResult,
) {
if let Some(required) = schema.parameters.get("required") {
if let Some(required_arr) = required.as_array() {
for req in required_arr {
if let Some(param_name) = req.as_str() {
if !action.parameters.contains_key(param_name) {
result.errors.push(ValidationError {
action_id: action.id.clone(),
reason: format!(
"missing required parameter '{}' for tool '{}'",
param_name, tool
),
});
}
}
}
}
}
if !schema_is_empty_object(&schema.parameters) {
let params_value = parameters_to_value(&action.parameters);
match jsonschema::validator_for(&schema.parameters) {
Ok(validator) => {
for err in validator.iter_errors(¶ms_value) {
result.errors.push(ValidationError {
action_id: action.id.clone(),
reason: format!(
"tool '{}' parameter validation: {} (at {})",
tool, err, err.instance_path
),
});
}
}
Err(e) => {
result.errors.push(ValidationError {
action_id: action.id.clone(),
reason: format!(
"tool '{}' has an invalid registered JSON Schema: {}",
tool, e
),
});
}
}
}
}
fn schema_is_empty_object(schema: &serde_json::Value) -> bool {
match schema {
serde_json::Value::Object(map) => map.is_empty(),
_ => true,
}
}
fn parameters_to_value(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
let map: serde_json::Map<String, serde_json::Value> =
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
serde_json::Value::Object(map)
}
pub fn validate_action(
action: &Action,
state: &StateStore,
registered_tools: &HashMap<String, ToolSchema>,
) -> ValidationResult {
let mut result = ValidationResult {
action_id: action.id.clone(),
errors: Vec::new(),
};
if let Some(ref tool) = action.tool {
if let Some(schema) = registered_tools.get(tool) {
validate_parameters(action, tool, schema, &mut result);
} else {
result.errors.push(ValidationError {
action_id: action.id.clone(),
reason: format!("tool '{}' is not registered", tool),
});
}
}
for pre in &action.preconditions {
if let Some(error) = check_precondition(pre, state) {
result.errors.push(ValidationError {
action_id: action.id.clone(),
reason: error,
});
}
}
for dep in &action.state_dependencies {
if !state.exists(dep) {
result.errors.push(ValidationError {
action_id: action.id.clone(),
reason: format!("state dependency '{}' not found", dep),
});
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{ActionType, FailureBehavior, Precondition, ToolSchema};
use serde_json::Value;
use std::collections::HashMap;
fn make_tool_call(tool: &str) -> Action {
Action {
id: "test".to_string(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters: HashMap::new(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
idempotent: false,
max_retries: 3,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn simple_schema(name: &str) -> ToolSchema {
ToolSchema {
name: name.to_string(),
description: String::new(),
parameters: Value::Object(Default::default()),
returns: None,
idempotent: false,
cache_ttl_secs: None,
rate_limit: None,
}
}
fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
names
.iter()
.map(|n| (n.to_string(), simple_schema(n)))
.collect()
}
#[test]
fn unknown_tool_rejected() {
let state = StateStore::new();
let tools = tools_map(&["echo"]);
let action = make_tool_call("nonexistent");
let result = validate_action(&action, &state, &tools);
assert!(!result.valid());
assert!(result.errors[0].reason.contains("not registered"));
}
#[test]
fn known_tool_passes() {
let state = StateStore::new();
let tools = tools_map(&["echo"]);
let action = make_tool_call("echo");
let result = validate_action(&action, &state, &tools);
assert!(result.valid());
}
#[test]
fn precondition_eq_fails() {
let state = StateStore::new();
let pre = Precondition {
key: "auth".to_string(),
operator: "eq".to_string(),
value: Value::Bool(true),
description: String::new(),
};
assert!(check_precondition(&pre, &state).is_some());
}
#[test]
fn precondition_eq_passes() {
let state = StateStore::new();
state.set("auth", Value::Bool(true), "setup");
let pre = Precondition {
key: "auth".to_string(),
operator: "eq".to_string(),
value: Value::Bool(true),
description: String::new(),
};
assert!(check_precondition(&pre, &state).is_none());
}
#[test]
fn precondition_exists() {
let state = StateStore::new();
let pre = Precondition {
key: "x".to_string(),
operator: "exists".to_string(),
value: Value::Null,
description: String::new(),
};
assert!(check_precondition(&pre, &state).is_some());
state.set("x", Value::from(1), "setup");
assert!(check_precondition(&pre, &state).is_none());
}
#[test]
fn state_dependency_missing() {
let state = StateStore::new();
let tools: HashMap<String, ToolSchema> = HashMap::new();
let mut action = make_tool_call("echo");
action.tool = None;
action.action_type = ActionType::StateRead;
action.state_dependencies = vec!["missing".to_string()];
let result = validate_action(&action, &state, &tools);
assert!(!result.valid());
assert!(result.errors[0].reason.contains("not found"));
}
#[test]
fn precondition_gt() {
let state = StateStore::new();
state.set("count", Value::from(10), "setup");
let pre = Precondition {
key: "count".to_string(),
operator: "gt".to_string(),
value: Value::from(5),
description: String::new(),
};
assert!(check_precondition(&pre, &state).is_none());
let pre_fail = Precondition {
key: "count".to_string(),
operator: "gt".to_string(),
value: Value::from(20),
description: String::new(),
};
assert!(check_precondition(&pre_fail, &state).is_some());
}
#[test]
fn missing_required_parameter_rejected() {
let state = StateStore::new();
let mut schema = simple_schema("add");
schema.parameters = serde_json::json!({
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"}
},
"required": ["a", "b"]
});
let tools: HashMap<String, ToolSchema> =
[("add".to_string(), schema)].into_iter().collect();
let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
assert!(!result.valid());
assert!(result.errors.iter().any(|e| e.reason.contains("missing required parameter 'a'")));
assert!(result.errors.iter().any(|e| e.reason.contains("missing required parameter 'b'")));
}
#[test]
fn required_parameters_provided_passes() {
let state = StateStore::new();
let mut schema = simple_schema("add");
schema.parameters = serde_json::json!({
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"}
},
"required": ["a", "b"]
});
let tools: HashMap<String, ToolSchema> =
[("add".to_string(), schema)].into_iter().collect();
let mut action = make_tool_call("add");
action.parameters = [
("a".to_string(), Value::from(1)),
("b".to_string(), Value::from(2)),
]
.into();
let result = validate_action(&action, &state, &tools);
assert!(result.valid());
}
#[test]
fn type_mismatch_rejected_when_schema_registered() {
let state = StateStore::new();
let mut schema = simple_schema("read");
schema.parameters = serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"}
},
"required": ["path"]
});
let tools: HashMap<String, ToolSchema> =
[("read".to_string(), schema)].into_iter().collect();
let mut action = make_tool_call("read");
action.parameters = [("path".to_string(), Value::from(42))].into();
let result = validate_action(&action, &state, &tools);
assert!(!result.valid(), "type mismatch should be rejected");
assert!(
result.errors.iter().any(|e| e.reason.contains("parameter validation")),
"expected jsonschema parameter validation failure, got: {:?}",
result.errors
);
}
#[test]
fn empty_object_schema_is_treated_as_legacy() {
assert!(schema_is_empty_object(&Value::Object(Default::default())));
}
#[test]
fn legacy_schemaless_tool_accepts_any_parameters() {
let state = StateStore::new();
let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
action.parameters = [
("anything".to_string(), Value::from(42)),
("else".to_string(), Value::from("string")),
]
.into();
let result = validate_action(&action, &state, &tools);
assert!(result.valid(), "schemaless registration must accept anything");
}
#[test]
fn extra_parameters_allowed() {
let state = StateStore::new();
let mut schema = simple_schema("echo");
schema.parameters = serde_json::json!({
"type": "object",
"properties": {
"message": {"type": "string"}
},
"required": ["message"]
});
let tools: HashMap<String, ToolSchema> =
[("echo".to_string(), schema)].into_iter().collect();
let mut action = make_tool_call("echo");
action.parameters = [
("message".to_string(), Value::from("hi")),
("unexpected_extra".to_string(), Value::from(true)),
]
.into();
let result = validate_action(&action, &state, &tools);
assert!(result.valid()); }
}