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 source: car_ir::ToolSourceKind::UserDefined,
231 description: String::new(),
232 parameters: Value::Object(Default::default()),
233 returns: None,
234 idempotent: false,
235 cache_ttl_secs: None,
236 rate_limit: None,
237 }
238 }
239
240 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
241 names
242 .iter()
243 .map(|n| (n.to_string(), simple_schema(n)))
244 .collect()
245 }
246
247 #[test]
248 fn compensation_tool_is_resolved_against_the_registry() {
249 let state = StateStore::new();
253 let tools = tools_map(&["echo"]);
254 let mut action = make_tool_call("echo");
255 action.reversibility = car_ir::Reversibility::Compensable;
256 action.compensation = Some(car_ir::Compensation::Tool {
257 tool: "unecho".to_string(),
258 parameters: HashMap::new(),
259 });
260 let result = validate_action(&action, &state, &tools);
261 assert!(!result.valid());
262 assert!(result.errors.iter().any(|e| e
263 .reason
264 .contains("compensation tool 'unecho' is not registered")));
265
266 let tools = tools_map(&["echo", "unecho"]);
268 assert!(validate_action(&action, &state, &tools).valid());
269
270 let mut action = make_tool_call("echo");
273 action.compensation = Some(car_ir::Compensation::ActionRef {
274 action_id: "whatever".to_string(),
275 });
276 assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
277 }
278
279 #[test]
280 fn unknown_tool_rejected() {
281 let state = StateStore::new();
282 let tools = tools_map(&["echo"]);
283 let action = make_tool_call("nonexistent");
284 let result = validate_action(&action, &state, &tools);
285 assert!(!result.valid());
286 assert!(result.errors[0].reason.contains("not registered"));
287 }
288
289 #[test]
290 fn known_tool_passes() {
291 let state = StateStore::new();
292 let tools = tools_map(&["echo"]);
293 let action = make_tool_call("echo");
294 let result = validate_action(&action, &state, &tools);
295 assert!(result.valid());
296 }
297
298 #[test]
299 fn precondition_eq_fails() {
300 let state = StateStore::new();
301 let pre = Precondition {
302 key: "auth".to_string(),
303 operator: "eq".to_string(),
304 value: Value::Bool(true),
305 description: String::new(),
306 };
307 assert!(check_precondition(&pre, &state).is_some());
308 }
309
310 #[test]
311 fn precondition_eq_passes() {
312 let state = StateStore::new();
313 state.set("auth", Value::Bool(true), "setup");
314 let pre = Precondition {
315 key: "auth".to_string(),
316 operator: "eq".to_string(),
317 value: Value::Bool(true),
318 description: String::new(),
319 };
320 assert!(check_precondition(&pre, &state).is_none());
321 }
322
323 #[test]
324 fn precondition_exists() {
325 let state = StateStore::new();
326 let pre = Precondition {
327 key: "x".to_string(),
328 operator: "exists".to_string(),
329 value: Value::Null,
330 description: String::new(),
331 };
332 assert!(check_precondition(&pre, &state).is_some());
333
334 state.set("x", Value::from(1), "setup");
335 assert!(check_precondition(&pre, &state).is_none());
336 }
337
338 #[test]
339 fn state_dependency_missing() {
340 let state = StateStore::new();
341 let tools: HashMap<String, ToolSchema> = HashMap::new();
342 let mut action = make_tool_call("echo");
343 action.tool = None;
344 action.action_type = ActionType::StateRead;
345 action.state_dependencies = vec!["missing".to_string()];
346 let result = validate_action(&action, &state, &tools);
347 assert!(!result.valid());
348 assert!(result.errors[0].reason.contains("not found"));
349 }
350
351 #[test]
352 fn precondition_gt() {
353 let state = StateStore::new();
354 state.set("count", Value::from(10), "setup");
355 let pre = Precondition {
356 key: "count".to_string(),
357 operator: "gt".to_string(),
358 value: Value::from(5),
359 description: String::new(),
360 };
361 assert!(check_precondition(&pre, &state).is_none());
362
363 let pre_fail = Precondition {
364 key: "count".to_string(),
365 operator: "gt".to_string(),
366 value: Value::from(20),
367 description: String::new(),
368 };
369 assert!(check_precondition(&pre_fail, &state).is_some());
370 }
371
372 #[test]
373 fn missing_required_parameter_rejected() {
374 let state = StateStore::new();
375 let mut schema = simple_schema("add");
376 schema.parameters = serde_json::json!({
377 "type": "object",
378 "properties": {
379 "a": {"type": "number"},
380 "b": {"type": "number"}
381 },
382 "required": ["a", "b"]
383 });
384 let tools: HashMap<String, ToolSchema> =
385 [("add".to_string(), schema)].into_iter().collect();
386
387 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
389 assert!(!result.valid());
390 assert!(result
391 .errors
392 .iter()
393 .any(|e| e.reason.contains("missing required parameter 'a'")));
394 assert!(result
395 .errors
396 .iter()
397 .any(|e| e.reason.contains("missing required parameter 'b'")));
398 }
399
400 #[test]
401 fn required_parameters_provided_passes() {
402 let state = StateStore::new();
403 let mut schema = simple_schema("add");
404 schema.parameters = serde_json::json!({
405 "type": "object",
406 "properties": {
407 "a": {"type": "number"},
408 "b": {"type": "number"}
409 },
410 "required": ["a", "b"]
411 });
412 let tools: HashMap<String, ToolSchema> =
413 [("add".to_string(), schema)].into_iter().collect();
414
415 let mut action = make_tool_call("add");
416 action.parameters = [
417 ("a".to_string(), Value::from(1)),
418 ("b".to_string(), Value::from(2)),
419 ]
420 .into();
421 let result = validate_action(&action, &state, &tools);
422 assert!(result.valid());
423 }
424
425 #[test]
426 fn type_mismatch_rejected_when_schema_registered() {
427 let state = StateStore::new();
428 let mut schema = simple_schema("read");
429 schema.parameters = serde_json::json!({
430 "type": "object",
431 "properties": {
432 "path": {"type": "string"}
433 },
434 "required": ["path"]
435 });
436 let tools: HashMap<String, ToolSchema> =
437 [("read".to_string(), schema)].into_iter().collect();
438
439 let mut action = make_tool_call("read");
440 action.parameters = [("path".to_string(), Value::from(42))].into();
441 let result = validate_action(&action, &state, &tools);
442 assert!(!result.valid(), "type mismatch should be rejected");
443 assert!(
444 result
445 .errors
446 .iter()
447 .any(|e| e.reason.contains("parameter validation")),
448 "expected jsonschema parameter validation failure, got: {:?}",
449 result.errors
450 );
451 }
452
453 #[test]
454 fn empty_object_schema_is_treated_as_legacy() {
455 assert!(schema_is_empty_object(&Value::Object(Default::default())));
459 }
460
461 #[test]
462 fn tool_output_uses_declared_return_schema() {
463 let schema = serde_json::json!({
464 "type":"object",
465 "properties":{"report_id":{"type":"string"}},
466 "required":["report_id"],
467 "additionalProperties":false
468 });
469 assert!(validate_tool_output(
470 "report_source",
471 &schema,
472 &serde_json::json!({"report_id":"r-1"})
473 )
474 .is_ok());
475 let error = validate_tool_output(
476 "report_source",
477 &schema,
478 &serde_json::json!({"report_id":42}),
479 )
480 .expect_err("wrong return type must fail");
481 assert!(error.contains("output validation"), "{error}");
482 }
483
484 #[test]
485 fn empty_return_schema_preserves_legacy_output() {
486 assert!(validate_tool_output(
487 "legacy",
488 &serde_json::json!({}),
489 &serde_json::json!({"anything":true})
490 )
491 .is_ok());
492 }
493
494 #[test]
495 fn legacy_schemaless_tool_accepts_any_parameters() {
496 let state = StateStore::new();
497 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
499 action.parameters = [
500 ("anything".to_string(), Value::from(42)),
501 ("else".to_string(), Value::from("string")),
502 ]
503 .into();
504 let result = validate_action(&action, &state, &tools);
505 assert!(
506 result.valid(),
507 "schemaless registration must accept anything"
508 );
509 }
510
511 #[test]
512 fn extra_parameters_allowed() {
513 let state = StateStore::new();
514 let mut schema = simple_schema("echo");
515 schema.parameters = serde_json::json!({
516 "type": "object",
517 "properties": {
518 "message": {"type": "string"}
519 },
520 "required": ["message"]
521 });
522 let tools: HashMap<String, ToolSchema> =
523 [("echo".to_string(), schema)].into_iter().collect();
524
525 let mut action = make_tool_call("echo");
526 action.parameters = [
527 ("message".to_string(), Value::from("hi")),
528 ("unexpected_extra".to_string(), Value::from(true)),
529 ]
530 .into();
531 let result = validate_action(&action, &state, &tools);
532 assert!(result.valid()); }
534}