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 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
321 .errors
322 .iter()
323 .any(|e| e.reason.contains("missing required parameter 'a'")));
324 assert!(result
325 .errors
326 .iter()
327 .any(|e| e.reason.contains("missing required parameter 'b'")));
328 }
329
330 #[test]
331 fn required_parameters_provided_passes() {
332 let state = StateStore::new();
333 let mut schema = simple_schema("add");
334 schema.parameters = serde_json::json!({
335 "type": "object",
336 "properties": {
337 "a": {"type": "number"},
338 "b": {"type": "number"}
339 },
340 "required": ["a", "b"]
341 });
342 let tools: HashMap<String, ToolSchema> =
343 [("add".to_string(), schema)].into_iter().collect();
344
345 let mut action = make_tool_call("add");
346 action.parameters = [
347 ("a".to_string(), Value::from(1)),
348 ("b".to_string(), Value::from(2)),
349 ]
350 .into();
351 let result = validate_action(&action, &state, &tools);
352 assert!(result.valid());
353 }
354
355 #[test]
356 fn type_mismatch_rejected_when_schema_registered() {
357 let state = StateStore::new();
358 let mut schema = simple_schema("read");
359 schema.parameters = serde_json::json!({
360 "type": "object",
361 "properties": {
362 "path": {"type": "string"}
363 },
364 "required": ["path"]
365 });
366 let tools: HashMap<String, ToolSchema> =
367 [("read".to_string(), schema)].into_iter().collect();
368
369 let mut action = make_tool_call("read");
370 action.parameters = [("path".to_string(), Value::from(42))].into();
371 let result = validate_action(&action, &state, &tools);
372 assert!(!result.valid(), "type mismatch should be rejected");
373 assert!(
374 result
375 .errors
376 .iter()
377 .any(|e| e.reason.contains("parameter validation")),
378 "expected jsonschema parameter validation failure, got: {:?}",
379 result.errors
380 );
381 }
382
383 #[test]
384 fn empty_object_schema_is_treated_as_legacy() {
385 assert!(schema_is_empty_object(&Value::Object(Default::default())));
389 }
390
391 #[test]
392 fn legacy_schemaless_tool_accepts_any_parameters() {
393 let state = StateStore::new();
394 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
396 action.parameters = [
397 ("anything".to_string(), Value::from(42)),
398 ("else".to_string(), Value::from("string")),
399 ]
400 .into();
401 let result = validate_action(&action, &state, &tools);
402 assert!(
403 result.valid(),
404 "schemaless registration must accept anything"
405 );
406 }
407
408 #[test]
409 fn extra_parameters_allowed() {
410 let state = StateStore::new();
411 let mut schema = simple_schema("echo");
412 schema.parameters = serde_json::json!({
413 "type": "object",
414 "properties": {
415 "message": {"type": "string"}
416 },
417 "required": ["message"]
418 });
419 let tools: HashMap<String, ToolSchema> =
420 [("echo".to_string(), schema)].into_iter().collect();
421
422 let mut action = make_tool_call("echo");
423 action.parameters = [
424 ("message".to_string(), Value::from("hi")),
425 ("unexpected_extra".to_string(), Value::from(true)),
426 ]
427 .into();
428 let result = validate_action(&action, &state, &tools);
429 assert!(result.valid()); }
431}