1use car_ir::{Action, Precondition, ToolSchema};
4use car_ir::precondition;
5use car_state::StateStore;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone)]
10pub struct ValidationError {
11 pub action_id: String,
12 pub reason: String,
13}
14
15#[derive(Debug)]
17pub struct ValidationResult {
18 pub action_id: String,
19 pub errors: Vec<ValidationError>,
20}
21
22impl ValidationResult {
23 pub fn valid(&self) -> bool {
24 self.errors.is_empty()
25 }
26}
27
28pub fn check_precondition(pre: &Precondition, state: &StateStore) -> Option<String> {
30 precondition::check_precondition(pre, state)
31}
32
33fn validate_parameters(
52 action: &car_ir::Action,
53 tool: &str,
54 schema: &ToolSchema,
55 result: &mut ValidationResult,
56) {
57 if let Some(required) = schema.parameters.get("required") {
60 if let Some(required_arr) = required.as_array() {
61 for req in required_arr {
62 if let Some(param_name) = req.as_str() {
63 if !action.parameters.contains_key(param_name) {
64 result.errors.push(ValidationError {
65 action_id: action.id.clone(),
66 reason: format!(
67 "missing required parameter '{}' for tool '{}'",
68 param_name, tool
69 ),
70 });
71 }
72 }
73 }
74 }
75 }
76
77 if !schema_is_empty_object(&schema.parameters) {
81 let params_value = parameters_to_value(&action.parameters);
82 match jsonschema::validator_for(&schema.parameters) {
83 Ok(validator) => {
84 for err in validator.iter_errors(¶ms_value) {
85 result.errors.push(ValidationError {
86 action_id: action.id.clone(),
87 reason: format!(
88 "tool '{}' parameter validation: {} (at {})",
89 tool, err, err.instance_path
90 ),
91 });
92 }
93 }
94 Err(e) => {
95 result.errors.push(ValidationError {
96 action_id: action.id.clone(),
97 reason: format!(
98 "tool '{}' has an invalid registered JSON Schema: {}",
99 tool, e
100 ),
101 });
102 }
103 }
104 }
105}
106
107fn schema_is_empty_object(schema: &serde_json::Value) -> bool {
108 match schema {
109 serde_json::Value::Object(map) => map.is_empty(),
110 _ => true,
111 }
112}
113
114fn parameters_to_value(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
115 let map: serde_json::Map<String, serde_json::Value> =
116 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
117 serde_json::Value::Object(map)
118}
119
120pub fn validate_action(
122 action: &Action,
123 state: &StateStore,
124 registered_tools: &HashMap<String, ToolSchema>,
125) -> ValidationResult {
126 let mut result = ValidationResult {
127 action_id: action.id.clone(),
128 errors: Vec::new(),
129 };
130
131 if let Some(ref tool) = action.tool {
133 if let Some(schema) = registered_tools.get(tool) {
134 validate_parameters(action, tool, schema, &mut result);
135 } else {
136 result.errors.push(ValidationError {
137 action_id: action.id.clone(),
138 reason: format!("tool '{}' is not registered", tool),
139 });
140 }
141 }
142
143 for pre in &action.preconditions {
145 if let Some(error) = check_precondition(pre, state) {
146 result.errors.push(ValidationError {
147 action_id: action.id.clone(),
148 reason: error,
149 });
150 }
151 }
152
153 for dep in &action.state_dependencies {
155 if !state.exists(dep) {
156 result.errors.push(ValidationError {
157 action_id: action.id.clone(),
158 reason: format!("state dependency '{}' not found", dep),
159 });
160 }
161 }
162
163 result
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use car_ir::{ActionType, FailureBehavior, Precondition, ToolSchema};
170 use serde_json::Value;
171 use std::collections::HashMap;
172
173 fn make_tool_call(tool: &str) -> Action {
174 Action {
175 id: "test".to_string(),
176 action_type: ActionType::ToolCall,
177 tool: Some(tool.to_string()),
178 parameters: HashMap::new(),
179 preconditions: vec![],
180 expected_effects: HashMap::new(),
181 state_dependencies: vec![],
182 idempotent: false,
183 max_retries: 3,
184 failure_behavior: FailureBehavior::Abort,
185 timeout_ms: None,
186 metadata: HashMap::new(),
187 }
188 }
189
190 fn simple_schema(name: &str) -> ToolSchema {
191 ToolSchema {
192 name: name.to_string(),
193 description: String::new(),
194 parameters: Value::Object(Default::default()),
195 returns: None,
196 idempotent: false,
197 cache_ttl_secs: None,
198 rate_limit: None,
199 }
200 }
201
202 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
203 names
204 .iter()
205 .map(|n| (n.to_string(), simple_schema(n)))
206 .collect()
207 }
208
209 #[test]
210 fn unknown_tool_rejected() {
211 let state = StateStore::new();
212 let tools = tools_map(&["echo"]);
213 let action = make_tool_call("nonexistent");
214 let result = validate_action(&action, &state, &tools);
215 assert!(!result.valid());
216 assert!(result.errors[0].reason.contains("not registered"));
217 }
218
219 #[test]
220 fn known_tool_passes() {
221 let state = StateStore::new();
222 let tools = tools_map(&["echo"]);
223 let action = make_tool_call("echo");
224 let result = validate_action(&action, &state, &tools);
225 assert!(result.valid());
226 }
227
228 #[test]
229 fn precondition_eq_fails() {
230 let state = StateStore::new();
231 let pre = Precondition {
232 key: "auth".to_string(),
233 operator: "eq".to_string(),
234 value: Value::Bool(true),
235 description: String::new(),
236 };
237 assert!(check_precondition(&pre, &state).is_some());
238 }
239
240 #[test]
241 fn precondition_eq_passes() {
242 let state = StateStore::new();
243 state.set("auth", Value::Bool(true), "setup");
244 let pre = Precondition {
245 key: "auth".to_string(),
246 operator: "eq".to_string(),
247 value: Value::Bool(true),
248 description: String::new(),
249 };
250 assert!(check_precondition(&pre, &state).is_none());
251 }
252
253 #[test]
254 fn precondition_exists() {
255 let state = StateStore::new();
256 let pre = Precondition {
257 key: "x".to_string(),
258 operator: "exists".to_string(),
259 value: Value::Null,
260 description: String::new(),
261 };
262 assert!(check_precondition(&pre, &state).is_some());
263
264 state.set("x", Value::from(1), "setup");
265 assert!(check_precondition(&pre, &state).is_none());
266 }
267
268 #[test]
269 fn state_dependency_missing() {
270 let state = StateStore::new();
271 let tools: HashMap<String, ToolSchema> = HashMap::new();
272 let mut action = make_tool_call("echo");
273 action.tool = None;
274 action.action_type = ActionType::StateRead;
275 action.state_dependencies = vec!["missing".to_string()];
276 let result = validate_action(&action, &state, &tools);
277 assert!(!result.valid());
278 assert!(result.errors[0].reason.contains("not found"));
279 }
280
281 #[test]
282 fn precondition_gt() {
283 let state = StateStore::new();
284 state.set("count", Value::from(10), "setup");
285 let pre = Precondition {
286 key: "count".to_string(),
287 operator: "gt".to_string(),
288 value: Value::from(5),
289 description: String::new(),
290 };
291 assert!(check_precondition(&pre, &state).is_none());
292
293 let pre_fail = Precondition {
294 key: "count".to_string(),
295 operator: "gt".to_string(),
296 value: Value::from(20),
297 description: String::new(),
298 };
299 assert!(check_precondition(&pre_fail, &state).is_some());
300 }
301
302 #[test]
303 fn missing_required_parameter_rejected() {
304 let state = StateStore::new();
305 let mut schema = simple_schema("add");
306 schema.parameters = serde_json::json!({
307 "type": "object",
308 "properties": {
309 "a": {"type": "number"},
310 "b": {"type": "number"}
311 },
312 "required": ["a", "b"]
313 });
314 let tools: HashMap<String, ToolSchema> =
315 [("add".to_string(), schema)].into_iter().collect();
316
317 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
319 assert!(!result.valid());
320 assert!(result.errors.iter().any(|e| e.reason.contains("missing required parameter 'a'")));
321 assert!(result.errors.iter().any(|e| e.reason.contains("missing required parameter 'b'")));
322 }
323
324 #[test]
325 fn required_parameters_provided_passes() {
326 let state = StateStore::new();
327 let mut schema = simple_schema("add");
328 schema.parameters = serde_json::json!({
329 "type": "object",
330 "properties": {
331 "a": {"type": "number"},
332 "b": {"type": "number"}
333 },
334 "required": ["a", "b"]
335 });
336 let tools: HashMap<String, ToolSchema> =
337 [("add".to_string(), schema)].into_iter().collect();
338
339 let mut action = make_tool_call("add");
340 action.parameters = [
341 ("a".to_string(), Value::from(1)),
342 ("b".to_string(), Value::from(2)),
343 ]
344 .into();
345 let result = validate_action(&action, &state, &tools);
346 assert!(result.valid());
347 }
348
349 #[test]
350 fn type_mismatch_rejected_when_schema_registered() {
351 let state = StateStore::new();
352 let mut schema = simple_schema("read");
353 schema.parameters = serde_json::json!({
354 "type": "object",
355 "properties": {
356 "path": {"type": "string"}
357 },
358 "required": ["path"]
359 });
360 let tools: HashMap<String, ToolSchema> =
361 [("read".to_string(), schema)].into_iter().collect();
362
363 let mut action = make_tool_call("read");
364 action.parameters = [("path".to_string(), Value::from(42))].into();
365 let result = validate_action(&action, &state, &tools);
366 assert!(!result.valid(), "type mismatch should be rejected");
367 assert!(
368 result.errors.iter().any(|e| e.reason.contains("parameter validation")),
369 "expected jsonschema parameter validation failure, got: {:?}",
370 result.errors
371 );
372 }
373
374 #[test]
375 fn empty_object_schema_is_treated_as_legacy() {
376 assert!(schema_is_empty_object(&Value::Object(Default::default())));
380 }
381
382 #[test]
383 fn legacy_schemaless_tool_accepts_any_parameters() {
384 let state = StateStore::new();
385 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
387 action.parameters = [
388 ("anything".to_string(), Value::from(42)),
389 ("else".to_string(), Value::from("string")),
390 ]
391 .into();
392 let result = validate_action(&action, &state, &tools);
393 assert!(result.valid(), "schemaless registration must accept anything");
394 }
395
396 #[test]
397 fn extra_parameters_allowed() {
398 let state = StateStore::new();
399 let mut schema = simple_schema("echo");
400 schema.parameters = serde_json::json!({
401 "type": "object",
402 "properties": {
403 "message": {"type": "string"}
404 },
405 "required": ["message"]
406 });
407 let tools: HashMap<String, ToolSchema> =
408 [("echo".to_string(), schema)].into_iter().collect();
409
410 let mut action = make_tool_call("echo");
411 action.parameters = [
412 ("message".to_string(), Value::from("hi")),
413 ("unexpected_extra".to_string(), Value::from(true)),
414 ]
415 .into();
416 let result = validate_action(&action, &state, &tools);
417 assert!(result.valid()); }
419}