1use car_ir::precondition;
4use car_ir::{Action, Precondition, ToolSchema};
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_tool_output(
127 tool: &str,
128 schema: &serde_json::Value,
129 output: &serde_json::Value,
130) -> Result<(), String> {
131 if schema_is_empty_object(schema) {
132 return Ok(());
133 }
134 let validator = jsonschema::validator_for(schema)
135 .map_err(|error| format!("tool '{tool}' has an invalid return JSON Schema: {error}"))?;
136 let errors: Vec<String> = validator
137 .iter_errors(output)
138 .map(|error| format!("{error} (at {})", error.instance_path))
139 .collect();
140 if errors.is_empty() {
141 Ok(())
142 } else {
143 Err(format!(
144 "tool '{tool}' output validation: {}",
145 errors.join("; ")
146 ))
147 }
148}
149
150pub fn validate_action(
152 action: &Action,
153 state: &StateStore,
154 registered_tools: &HashMap<String, ToolSchema>,
155) -> ValidationResult {
156 let mut result = ValidationResult {
157 action_id: action.id.clone(),
158 errors: Vec::new(),
159 };
160
161 if let Some(ref tool) = action.tool {
163 if let Some(schema) = registered_tools.get(tool) {
164 validate_parameters(action, tool, schema, &mut result);
165 } else {
166 result.errors.push(ValidationError {
167 action_id: action.id.clone(),
168 reason: format!("tool '{}' is not registered", tool),
169 });
170 }
171 }
172
173 if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
180 if !registered_tools.contains_key(tool) {
181 result.errors.push(ValidationError {
182 action_id: action.id.clone(),
183 reason: format!("compensation tool '{}' is not registered", tool),
184 });
185 }
186 }
187
188 for pre in &action.preconditions {
190 if let Some(error) = check_precondition(pre, state) {
191 result.errors.push(ValidationError {
192 action_id: action.id.clone(),
193 reason: error,
194 });
195 }
196 }
197
198 for dep in &action.state_dependencies {
200 if !state.exists(dep) {
201 result.errors.push(ValidationError {
202 action_id: action.id.clone(),
203 reason: format!("state dependency '{}' not found", dep),
204 });
205 }
206 }
207
208 result
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use car_ir::{ActionType, Precondition, ToolSchema};
215 use serde_json::Value;
216 use std::collections::HashMap;
217
218 fn make_tool_call(tool: &str) -> Action {
219 {
220 let mut a = Action::new(ActionType::ToolCall);
221 a.id = "test".to_string();
222 a.tool = Some(tool.to_string());
223 a
224 }
225 }
226
227 fn simple_schema(name: &str) -> ToolSchema {
228 ToolSchema {
229 name: name.to_string(),
230 description: String::new(),
231 parameters: Value::Object(Default::default()),
232 returns: None,
233 idempotent: false,
234 cache_ttl_secs: None,
235 rate_limit: None,
236 }
237 }
238
239 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
240 names
241 .iter()
242 .map(|n| (n.to_string(), simple_schema(n)))
243 .collect()
244 }
245
246 #[test]
247 fn compensation_tool_is_resolved_against_the_registry() {
248 let state = StateStore::new();
252 let tools = tools_map(&["echo"]);
253 let mut action = make_tool_call("echo");
254 action.reversibility = car_ir::Reversibility::Compensable;
255 action.compensation = Some(car_ir::Compensation::Tool {
256 tool: "unecho".to_string(),
257 parameters: HashMap::new(),
258 });
259 let result = validate_action(&action, &state, &tools);
260 assert!(!result.valid());
261 assert!(result.errors.iter().any(|e| e
262 .reason
263 .contains("compensation tool 'unecho' is not registered")));
264
265 let tools = tools_map(&["echo", "unecho"]);
267 assert!(validate_action(&action, &state, &tools).valid());
268
269 let mut action = make_tool_call("echo");
272 action.compensation = Some(car_ir::Compensation::ActionRef {
273 action_id: "whatever".to_string(),
274 });
275 assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
276 }
277
278 #[test]
279 fn unknown_tool_rejected() {
280 let state = StateStore::new();
281 let tools = tools_map(&["echo"]);
282 let action = make_tool_call("nonexistent");
283 let result = validate_action(&action, &state, &tools);
284 assert!(!result.valid());
285 assert!(result.errors[0].reason.contains("not registered"));
286 }
287
288 #[test]
289 fn known_tool_passes() {
290 let state = StateStore::new();
291 let tools = tools_map(&["echo"]);
292 let action = make_tool_call("echo");
293 let result = validate_action(&action, &state, &tools);
294 assert!(result.valid());
295 }
296
297 #[test]
298 fn precondition_eq_fails() {
299 let state = StateStore::new();
300 let pre = Precondition {
301 key: "auth".to_string(),
302 operator: "eq".to_string(),
303 value: Value::Bool(true),
304 description: String::new(),
305 };
306 assert!(check_precondition(&pre, &state).is_some());
307 }
308
309 #[test]
310 fn precondition_eq_passes() {
311 let state = StateStore::new();
312 state.set("auth", Value::Bool(true), "setup");
313 let pre = Precondition {
314 key: "auth".to_string(),
315 operator: "eq".to_string(),
316 value: Value::Bool(true),
317 description: String::new(),
318 };
319 assert!(check_precondition(&pre, &state).is_none());
320 }
321
322 #[test]
323 fn precondition_exists() {
324 let state = StateStore::new();
325 let pre = Precondition {
326 key: "x".to_string(),
327 operator: "exists".to_string(),
328 value: Value::Null,
329 description: String::new(),
330 };
331 assert!(check_precondition(&pre, &state).is_some());
332
333 state.set("x", Value::from(1), "setup");
334 assert!(check_precondition(&pre, &state).is_none());
335 }
336
337 #[test]
338 fn state_dependency_missing() {
339 let state = StateStore::new();
340 let tools: HashMap<String, ToolSchema> = HashMap::new();
341 let mut action = make_tool_call("echo");
342 action.tool = None;
343 action.action_type = ActionType::StateRead;
344 action.state_dependencies = vec!["missing".to_string()];
345 let result = validate_action(&action, &state, &tools);
346 assert!(!result.valid());
347 assert!(result.errors[0].reason.contains("not found"));
348 }
349
350 #[test]
351 fn precondition_gt() {
352 let state = StateStore::new();
353 state.set("count", Value::from(10), "setup");
354 let pre = Precondition {
355 key: "count".to_string(),
356 operator: "gt".to_string(),
357 value: Value::from(5),
358 description: String::new(),
359 };
360 assert!(check_precondition(&pre, &state).is_none());
361
362 let pre_fail = Precondition {
363 key: "count".to_string(),
364 operator: "gt".to_string(),
365 value: Value::from(20),
366 description: String::new(),
367 };
368 assert!(check_precondition(&pre_fail, &state).is_some());
369 }
370
371 #[test]
372 fn missing_required_parameter_rejected() {
373 let state = StateStore::new();
374 let mut schema = simple_schema("add");
375 schema.parameters = serde_json::json!({
376 "type": "object",
377 "properties": {
378 "a": {"type": "number"},
379 "b": {"type": "number"}
380 },
381 "required": ["a", "b"]
382 });
383 let tools: HashMap<String, ToolSchema> =
384 [("add".to_string(), schema)].into_iter().collect();
385
386 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
388 assert!(!result.valid());
389 assert!(result
390 .errors
391 .iter()
392 .any(|e| e.reason.contains("missing required parameter 'a'")));
393 assert!(result
394 .errors
395 .iter()
396 .any(|e| e.reason.contains("missing required parameter 'b'")));
397 }
398
399 #[test]
400 fn required_parameters_provided_passes() {
401 let state = StateStore::new();
402 let mut schema = simple_schema("add");
403 schema.parameters = serde_json::json!({
404 "type": "object",
405 "properties": {
406 "a": {"type": "number"},
407 "b": {"type": "number"}
408 },
409 "required": ["a", "b"]
410 });
411 let tools: HashMap<String, ToolSchema> =
412 [("add".to_string(), schema)].into_iter().collect();
413
414 let mut action = make_tool_call("add");
415 action.parameters = [
416 ("a".to_string(), Value::from(1)),
417 ("b".to_string(), Value::from(2)),
418 ]
419 .into();
420 let result = validate_action(&action, &state, &tools);
421 assert!(result.valid());
422 }
423
424 #[test]
425 fn type_mismatch_rejected_when_schema_registered() {
426 let state = StateStore::new();
427 let mut schema = simple_schema("read");
428 schema.parameters = serde_json::json!({
429 "type": "object",
430 "properties": {
431 "path": {"type": "string"}
432 },
433 "required": ["path"]
434 });
435 let tools: HashMap<String, ToolSchema> =
436 [("read".to_string(), schema)].into_iter().collect();
437
438 let mut action = make_tool_call("read");
439 action.parameters = [("path".to_string(), Value::from(42))].into();
440 let result = validate_action(&action, &state, &tools);
441 assert!(!result.valid(), "type mismatch should be rejected");
442 assert!(
443 result
444 .errors
445 .iter()
446 .any(|e| e.reason.contains("parameter validation")),
447 "expected jsonschema parameter validation failure, got: {:?}",
448 result.errors
449 );
450 }
451
452 #[test]
453 fn empty_object_schema_is_treated_as_legacy() {
454 assert!(schema_is_empty_object(&Value::Object(Default::default())));
458 }
459
460 #[test]
461 fn tool_output_uses_declared_return_schema() {
462 let schema = serde_json::json!({
463 "type":"object",
464 "properties":{"report_id":{"type":"string"}},
465 "required":["report_id"],
466 "additionalProperties":false
467 });
468 assert!(validate_tool_output(
469 "report_source",
470 &schema,
471 &serde_json::json!({"report_id":"r-1"})
472 )
473 .is_ok());
474 let error = validate_tool_output(
475 "report_source",
476 &schema,
477 &serde_json::json!({"report_id":42}),
478 )
479 .expect_err("wrong return type must fail");
480 assert!(error.contains("output validation"), "{error}");
481 }
482
483 #[test]
484 fn empty_return_schema_preserves_legacy_output() {
485 assert!(validate_tool_output(
486 "legacy",
487 &serde_json::json!({}),
488 &serde_json::json!({"anything":true})
489 )
490 .is_ok());
491 }
492
493 #[test]
494 fn legacy_schemaless_tool_accepts_any_parameters() {
495 let state = StateStore::new();
496 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
498 action.parameters = [
499 ("anything".to_string(), Value::from(42)),
500 ("else".to_string(), Value::from("string")),
501 ]
502 .into();
503 let result = validate_action(&action, &state, &tools);
504 assert!(
505 result.valid(),
506 "schemaless registration must accept anything"
507 );
508 }
509
510 #[test]
511 fn extra_parameters_allowed() {
512 let state = StateStore::new();
513 let mut schema = simple_schema("echo");
514 schema.parameters = serde_json::json!({
515 "type": "object",
516 "properties": {
517 "message": {"type": "string"}
518 },
519 "required": ["message"]
520 });
521 let tools: HashMap<String, ToolSchema> =
522 [("echo".to_string(), schema)].into_iter().collect();
523
524 let mut action = make_tool_call("echo");
525 action.parameters = [
526 ("message".to_string(), Value::from("hi")),
527 ("unexpected_extra".to_string(), Value::from(true)),
528 ]
529 .into();
530 let result = validate_action(&action, &state, &tools);
531 assert!(result.valid()); }
533}