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 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 read_set: vec![],
183 write_set: vec![],
184 assumptions: vec![],
185 idempotent: false,
186 max_retries: 3,
187 failure_behavior: FailureBehavior::Abort,
188 timeout_ms: None,
189 metadata: HashMap::new(),
190 }
191 }
192
193 fn simple_schema(name: &str) -> ToolSchema {
194 ToolSchema {
195 name: name.to_string(),
196 description: String::new(),
197 parameters: Value::Object(Default::default()),
198 returns: None,
199 idempotent: false,
200 cache_ttl_secs: None,
201 rate_limit: None,
202 }
203 }
204
205 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
206 names
207 .iter()
208 .map(|n| (n.to_string(), simple_schema(n)))
209 .collect()
210 }
211
212 #[test]
213 fn unknown_tool_rejected() {
214 let state = StateStore::new();
215 let tools = tools_map(&["echo"]);
216 let action = make_tool_call("nonexistent");
217 let result = validate_action(&action, &state, &tools);
218 assert!(!result.valid());
219 assert!(result.errors[0].reason.contains("not registered"));
220 }
221
222 #[test]
223 fn known_tool_passes() {
224 let state = StateStore::new();
225 let tools = tools_map(&["echo"]);
226 let action = make_tool_call("echo");
227 let result = validate_action(&action, &state, &tools);
228 assert!(result.valid());
229 }
230
231 #[test]
232 fn precondition_eq_fails() {
233 let state = StateStore::new();
234 let pre = Precondition {
235 key: "auth".to_string(),
236 operator: "eq".to_string(),
237 value: Value::Bool(true),
238 description: String::new(),
239 };
240 assert!(check_precondition(&pre, &state).is_some());
241 }
242
243 #[test]
244 fn precondition_eq_passes() {
245 let state = StateStore::new();
246 state.set("auth", Value::Bool(true), "setup");
247 let pre = Precondition {
248 key: "auth".to_string(),
249 operator: "eq".to_string(),
250 value: Value::Bool(true),
251 description: String::new(),
252 };
253 assert!(check_precondition(&pre, &state).is_none());
254 }
255
256 #[test]
257 fn precondition_exists() {
258 let state = StateStore::new();
259 let pre = Precondition {
260 key: "x".to_string(),
261 operator: "exists".to_string(),
262 value: Value::Null,
263 description: String::new(),
264 };
265 assert!(check_precondition(&pre, &state).is_some());
266
267 state.set("x", Value::from(1), "setup");
268 assert!(check_precondition(&pre, &state).is_none());
269 }
270
271 #[test]
272 fn state_dependency_missing() {
273 let state = StateStore::new();
274 let tools: HashMap<String, ToolSchema> = HashMap::new();
275 let mut action = make_tool_call("echo");
276 action.tool = None;
277 action.action_type = ActionType::StateRead;
278 action.state_dependencies = vec!["missing".to_string()];
279 let result = validate_action(&action, &state, &tools);
280 assert!(!result.valid());
281 assert!(result.errors[0].reason.contains("not found"));
282 }
283
284 #[test]
285 fn precondition_gt() {
286 let state = StateStore::new();
287 state.set("count", Value::from(10), "setup");
288 let pre = Precondition {
289 key: "count".to_string(),
290 operator: "gt".to_string(),
291 value: Value::from(5),
292 description: String::new(),
293 };
294 assert!(check_precondition(&pre, &state).is_none());
295
296 let pre_fail = Precondition {
297 key: "count".to_string(),
298 operator: "gt".to_string(),
299 value: Value::from(20),
300 description: String::new(),
301 };
302 assert!(check_precondition(&pre_fail, &state).is_some());
303 }
304
305 #[test]
306 fn missing_required_parameter_rejected() {
307 let state = StateStore::new();
308 let mut schema = simple_schema("add");
309 schema.parameters = serde_json::json!({
310 "type": "object",
311 "properties": {
312 "a": {"type": "number"},
313 "b": {"type": "number"}
314 },
315 "required": ["a", "b"]
316 });
317 let tools: HashMap<String, ToolSchema> =
318 [("add".to_string(), schema)].into_iter().collect();
319
320 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
322 assert!(!result.valid());
323 assert!(result
324 .errors
325 .iter()
326 .any(|e| e.reason.contains("missing required parameter 'a'")));
327 assert!(result
328 .errors
329 .iter()
330 .any(|e| e.reason.contains("missing required parameter 'b'")));
331 }
332
333 #[test]
334 fn required_parameters_provided_passes() {
335 let state = StateStore::new();
336 let mut schema = simple_schema("add");
337 schema.parameters = serde_json::json!({
338 "type": "object",
339 "properties": {
340 "a": {"type": "number"},
341 "b": {"type": "number"}
342 },
343 "required": ["a", "b"]
344 });
345 let tools: HashMap<String, ToolSchema> =
346 [("add".to_string(), schema)].into_iter().collect();
347
348 let mut action = make_tool_call("add");
349 action.parameters = [
350 ("a".to_string(), Value::from(1)),
351 ("b".to_string(), Value::from(2)),
352 ]
353 .into();
354 let result = validate_action(&action, &state, &tools);
355 assert!(result.valid());
356 }
357
358 #[test]
359 fn type_mismatch_rejected_when_schema_registered() {
360 let state = StateStore::new();
361 let mut schema = simple_schema("read");
362 schema.parameters = serde_json::json!({
363 "type": "object",
364 "properties": {
365 "path": {"type": "string"}
366 },
367 "required": ["path"]
368 });
369 let tools: HashMap<String, ToolSchema> =
370 [("read".to_string(), schema)].into_iter().collect();
371
372 let mut action = make_tool_call("read");
373 action.parameters = [("path".to_string(), Value::from(42))].into();
374 let result = validate_action(&action, &state, &tools);
375 assert!(!result.valid(), "type mismatch should be rejected");
376 assert!(
377 result
378 .errors
379 .iter()
380 .any(|e| e.reason.contains("parameter validation")),
381 "expected jsonschema parameter validation failure, got: {:?}",
382 result.errors
383 );
384 }
385
386 #[test]
387 fn empty_object_schema_is_treated_as_legacy() {
388 assert!(schema_is_empty_object(&Value::Object(Default::default())));
392 }
393
394 #[test]
395 fn legacy_schemaless_tool_accepts_any_parameters() {
396 let state = StateStore::new();
397 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
399 action.parameters = [
400 ("anything".to_string(), Value::from(42)),
401 ("else".to_string(), Value::from("string")),
402 ]
403 .into();
404 let result = validate_action(&action, &state, &tools);
405 assert!(
406 result.valid(),
407 "schemaless registration must accept anything"
408 );
409 }
410
411 #[test]
412 fn extra_parameters_allowed() {
413 let state = StateStore::new();
414 let mut schema = simple_schema("echo");
415 schema.parameters = serde_json::json!({
416 "type": "object",
417 "properties": {
418 "message": {"type": "string"}
419 },
420 "required": ["message"]
421 });
422 let tools: HashMap<String, ToolSchema> =
423 [("echo".to_string(), schema)].into_iter().collect();
424
425 let mut action = make_tool_call("echo");
426 action.parameters = [
427 ("message".to_string(), Value::from("hi")),
428 ("unexpected_extra".to_string(), Value::from(true)),
429 ]
430 .into();
431 let result = validate_action(&action, &state, &tools);
432 assert!(result.valid()); }
434}