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_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 if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
150 if !registered_tools.contains_key(tool) {
151 result.errors.push(ValidationError {
152 action_id: action.id.clone(),
153 reason: format!("compensation tool '{}' is not registered", tool),
154 });
155 }
156 }
157
158 for pre in &action.preconditions {
160 if let Some(error) = check_precondition(pre, state) {
161 result.errors.push(ValidationError {
162 action_id: action.id.clone(),
163 reason: error,
164 });
165 }
166 }
167
168 for dep in &action.state_dependencies {
170 if !state.exists(dep) {
171 result.errors.push(ValidationError {
172 action_id: action.id.clone(),
173 reason: format!("state dependency '{}' not found", dep),
174 });
175 }
176 }
177
178 result
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use car_ir::{ActionType, Precondition, ToolSchema};
185 use serde_json::Value;
186 use std::collections::HashMap;
187
188 fn make_tool_call(tool: &str) -> Action {
189 {
190 let mut a = Action::new(ActionType::ToolCall);
191 a.id = "test".to_string();
192 a.tool = Some(tool.to_string());
193 a
194 }
195 }
196
197 fn simple_schema(name: &str) -> ToolSchema {
198 ToolSchema {
199 name: name.to_string(),
200 description: String::new(),
201 parameters: Value::Object(Default::default()),
202 returns: None,
203 idempotent: false,
204 cache_ttl_secs: None,
205 rate_limit: None,
206 }
207 }
208
209 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
210 names
211 .iter()
212 .map(|n| (n.to_string(), simple_schema(n)))
213 .collect()
214 }
215
216 #[test]
217 fn compensation_tool_is_resolved_against_the_registry() {
218 let state = StateStore::new();
222 let tools = tools_map(&["echo"]);
223 let mut action = make_tool_call("echo");
224 action.reversibility = car_ir::Reversibility::Compensable;
225 action.compensation = Some(car_ir::Compensation::Tool {
226 tool: "unecho".to_string(),
227 parameters: HashMap::new(),
228 });
229 let result = validate_action(&action, &state, &tools);
230 assert!(!result.valid());
231 assert!(result.errors.iter().any(|e| e
232 .reason
233 .contains("compensation tool 'unecho' is not registered")));
234
235 let tools = tools_map(&["echo", "unecho"]);
237 assert!(validate_action(&action, &state, &tools).valid());
238
239 let mut action = make_tool_call("echo");
242 action.compensation = Some(car_ir::Compensation::ActionRef {
243 action_id: "whatever".to_string(),
244 });
245 assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
246 }
247
248 #[test]
249 fn unknown_tool_rejected() {
250 let state = StateStore::new();
251 let tools = tools_map(&["echo"]);
252 let action = make_tool_call("nonexistent");
253 let result = validate_action(&action, &state, &tools);
254 assert!(!result.valid());
255 assert!(result.errors[0].reason.contains("not registered"));
256 }
257
258 #[test]
259 fn known_tool_passes() {
260 let state = StateStore::new();
261 let tools = tools_map(&["echo"]);
262 let action = make_tool_call("echo");
263 let result = validate_action(&action, &state, &tools);
264 assert!(result.valid());
265 }
266
267 #[test]
268 fn precondition_eq_fails() {
269 let state = StateStore::new();
270 let pre = Precondition {
271 key: "auth".to_string(),
272 operator: "eq".to_string(),
273 value: Value::Bool(true),
274 description: String::new(),
275 };
276 assert!(check_precondition(&pre, &state).is_some());
277 }
278
279 #[test]
280 fn precondition_eq_passes() {
281 let state = StateStore::new();
282 state.set("auth", Value::Bool(true), "setup");
283 let pre = Precondition {
284 key: "auth".to_string(),
285 operator: "eq".to_string(),
286 value: Value::Bool(true),
287 description: String::new(),
288 };
289 assert!(check_precondition(&pre, &state).is_none());
290 }
291
292 #[test]
293 fn precondition_exists() {
294 let state = StateStore::new();
295 let pre = Precondition {
296 key: "x".to_string(),
297 operator: "exists".to_string(),
298 value: Value::Null,
299 description: String::new(),
300 };
301 assert!(check_precondition(&pre, &state).is_some());
302
303 state.set("x", Value::from(1), "setup");
304 assert!(check_precondition(&pre, &state).is_none());
305 }
306
307 #[test]
308 fn state_dependency_missing() {
309 let state = StateStore::new();
310 let tools: HashMap<String, ToolSchema> = HashMap::new();
311 let mut action = make_tool_call("echo");
312 action.tool = None;
313 action.action_type = ActionType::StateRead;
314 action.state_dependencies = vec!["missing".to_string()];
315 let result = validate_action(&action, &state, &tools);
316 assert!(!result.valid());
317 assert!(result.errors[0].reason.contains("not found"));
318 }
319
320 #[test]
321 fn precondition_gt() {
322 let state = StateStore::new();
323 state.set("count", Value::from(10), "setup");
324 let pre = Precondition {
325 key: "count".to_string(),
326 operator: "gt".to_string(),
327 value: Value::from(5),
328 description: String::new(),
329 };
330 assert!(check_precondition(&pre, &state).is_none());
331
332 let pre_fail = Precondition {
333 key: "count".to_string(),
334 operator: "gt".to_string(),
335 value: Value::from(20),
336 description: String::new(),
337 };
338 assert!(check_precondition(&pre_fail, &state).is_some());
339 }
340
341 #[test]
342 fn missing_required_parameter_rejected() {
343 let state = StateStore::new();
344 let mut schema = simple_schema("add");
345 schema.parameters = serde_json::json!({
346 "type": "object",
347 "properties": {
348 "a": {"type": "number"},
349 "b": {"type": "number"}
350 },
351 "required": ["a", "b"]
352 });
353 let tools: HashMap<String, ToolSchema> =
354 [("add".to_string(), schema)].into_iter().collect();
355
356 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
358 assert!(!result.valid());
359 assert!(result
360 .errors
361 .iter()
362 .any(|e| e.reason.contains("missing required parameter 'a'")));
363 assert!(result
364 .errors
365 .iter()
366 .any(|e| e.reason.contains("missing required parameter 'b'")));
367 }
368
369 #[test]
370 fn required_parameters_provided_passes() {
371 let state = StateStore::new();
372 let mut schema = simple_schema("add");
373 schema.parameters = serde_json::json!({
374 "type": "object",
375 "properties": {
376 "a": {"type": "number"},
377 "b": {"type": "number"}
378 },
379 "required": ["a", "b"]
380 });
381 let tools: HashMap<String, ToolSchema> =
382 [("add".to_string(), schema)].into_iter().collect();
383
384 let mut action = make_tool_call("add");
385 action.parameters = [
386 ("a".to_string(), Value::from(1)),
387 ("b".to_string(), Value::from(2)),
388 ]
389 .into();
390 let result = validate_action(&action, &state, &tools);
391 assert!(result.valid());
392 }
393
394 #[test]
395 fn type_mismatch_rejected_when_schema_registered() {
396 let state = StateStore::new();
397 let mut schema = simple_schema("read");
398 schema.parameters = serde_json::json!({
399 "type": "object",
400 "properties": {
401 "path": {"type": "string"}
402 },
403 "required": ["path"]
404 });
405 let tools: HashMap<String, ToolSchema> =
406 [("read".to_string(), schema)].into_iter().collect();
407
408 let mut action = make_tool_call("read");
409 action.parameters = [("path".to_string(), Value::from(42))].into();
410 let result = validate_action(&action, &state, &tools);
411 assert!(!result.valid(), "type mismatch should be rejected");
412 assert!(
413 result
414 .errors
415 .iter()
416 .any(|e| e.reason.contains("parameter validation")),
417 "expected jsonschema parameter validation failure, got: {:?}",
418 result.errors
419 );
420 }
421
422 #[test]
423 fn empty_object_schema_is_treated_as_legacy() {
424 assert!(schema_is_empty_object(&Value::Object(Default::default())));
428 }
429
430 #[test]
431 fn legacy_schemaless_tool_accepts_any_parameters() {
432 let state = StateStore::new();
433 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
435 action.parameters = [
436 ("anything".to_string(), Value::from(42)),
437 ("else".to_string(), Value::from("string")),
438 ]
439 .into();
440 let result = validate_action(&action, &state, &tools);
441 assert!(
442 result.valid(),
443 "schemaless registration must accept anything"
444 );
445 }
446
447 #[test]
448 fn extra_parameters_allowed() {
449 let state = StateStore::new();
450 let mut schema = simple_schema("echo");
451 schema.parameters = serde_json::json!({
452 "type": "object",
453 "properties": {
454 "message": {"type": "string"}
455 },
456 "required": ["message"]
457 });
458 let tools: HashMap<String, ToolSchema> =
459 [("echo".to_string(), schema)].into_iter().collect();
460
461 let mut action = make_tool_call("echo");
462 action.parameters = [
463 ("message".to_string(), Value::from("hi")),
464 ("unexpected_extra".to_string(), Value::from(true)),
465 ]
466 .into();
467 let result = validate_action(&action, &state, &tools);
468 assert!(result.valid()); }
470}