1use car_ir::{Action, Precondition, ToolSchema};
4use car_state::StateStore;
5use serde_json::Value;
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 let op = pre.operator.as_str();
31
32 if op == "exists" {
33 return if !state.exists(&pre.key) {
34 Some(format!("state key '{}' does not exist", pre.key))
35 } else {
36 None
37 };
38 }
39
40 if op == "not_exists" {
41 return if state.exists(&pre.key) {
42 Some(format!("state key '{}' exists but should not", pre.key))
43 } else {
44 None
45 };
46 }
47
48 if op == "contains" {
49 let current = state.get(&pre.key);
50 match current {
51 None => return Some(format!("state key '{}' is None, cannot check contains", pre.key)),
52 Some(val) => {
53 let val_str = match &val {
54 Value::String(s) => s.clone(),
55 other => other.to_string(),
56 };
57 let needle = match &pre.value {
58 Value::String(s) => s.clone(),
59 other => other.to_string(),
60 };
61 if !val_str.contains(&needle) {
62 return Some(format!(
63 "state key '{}' does not contain {:?}",
64 pre.key, pre.value
65 ));
66 }
67 return None;
68 }
69 }
70 }
71
72 let current = state.get(&pre.key);
73
74 match op {
75 "eq" => {
76 if current.as_ref() != Some(&pre.value) {
77 Some(format!(
78 "precondition failed: state['{}'] {} {:?} (actual: {:?})",
79 pre.key, op, pre.value, current
80 ))
81 } else {
82 None
83 }
84 }
85 "neq" => {
86 if current.as_ref() == Some(&pre.value) {
87 Some(format!(
88 "precondition failed: state['{}'] {} {:?} (actual: {:?})",
89 pre.key, op, pre.value, current
90 ))
91 } else {
92 None
93 }
94 }
95 "gt" | "lt" | "gte" | "lte" => {
96 let cur = current.as_ref().and_then(|v| v.as_f64());
97 let expected = pre.value.as_f64();
98 match (cur, expected) {
99 (Some(c), Some(e)) => {
100 let pass = match op {
101 "gt" => c > e,
102 "lt" => c < e,
103 "gte" => c >= e,
104 "lte" => c <= e,
105 _ => unreachable!(),
106 };
107 if !pass {
108 Some(format!(
109 "precondition failed: state['{}'] {} {:?} (actual: {:?})",
110 pre.key, op, pre.value, current
111 ))
112 } else {
113 None
114 }
115 }
116 _ => Some(format!(
117 "type error checking precondition on '{}'",
118 pre.key
119 )),
120 }
121 }
122 _ => Some(format!("unknown operator '{}'", op)),
123 }
124}
125
126pub fn validate_action(
128 action: &Action,
129 state: &StateStore,
130 registered_tools: &HashMap<String, ToolSchema>,
131) -> ValidationResult {
132 let mut result = ValidationResult {
133 action_id: action.id.clone(),
134 errors: Vec::new(),
135 };
136
137 if let Some(ref tool) = action.tool {
139 if let Some(schema) = registered_tools.get(tool) {
140 if let Some(required) = schema.parameters.get("required") {
142 if let Some(required_arr) = required.as_array() {
143 for req in required_arr {
144 if let Some(param_name) = req.as_str() {
145 if !action.parameters.contains_key(param_name) {
146 result.errors.push(ValidationError {
147 action_id: action.id.clone(),
148 reason: format!(
149 "missing required parameter '{}' for tool '{}'",
150 param_name, tool
151 ),
152 });
153 }
154 }
155 }
156 }
157 }
158 } else {
161 result.errors.push(ValidationError {
162 action_id: action.id.clone(),
163 reason: format!("tool '{}' is not registered", tool),
164 });
165 }
166 }
167
168 for pre in &action.preconditions {
170 if let Some(error) = check_precondition(pre, state) {
171 result.errors.push(ValidationError {
172 action_id: action.id.clone(),
173 reason: error,
174 });
175 }
176 }
177
178 for dep in &action.state_dependencies {
180 if !state.exists(dep) {
181 result.errors.push(ValidationError {
182 action_id: action.id.clone(),
183 reason: format!("state dependency '{}' not found", dep),
184 });
185 }
186 }
187
188 result
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use car_ir::{ActionType, FailureBehavior, Precondition, ToolSchema};
195 use std::collections::HashMap;
196
197 fn make_tool_call(tool: &str) -> Action {
198 Action {
199 id: "test".to_string(),
200 action_type: ActionType::ToolCall,
201 tool: Some(tool.to_string()),
202 parameters: HashMap::new(),
203 preconditions: vec![],
204 expected_effects: HashMap::new(),
205 state_dependencies: vec![],
206 idempotent: false,
207 max_retries: 3,
208 failure_behavior: FailureBehavior::Abort,
209 timeout_ms: None,
210 metadata: HashMap::new(),
211 }
212 }
213
214 fn simple_schema(name: &str) -> ToolSchema {
215 ToolSchema {
216 name: name.to_string(),
217 description: String::new(),
218 parameters: Value::Object(Default::default()),
219 returns: None,
220 idempotent: false,
221 cache_ttl_secs: None,
222 rate_limit: None,
223 }
224 }
225
226 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
227 names
228 .iter()
229 .map(|n| (n.to_string(), simple_schema(n)))
230 .collect()
231 }
232
233 #[test]
234 fn unknown_tool_rejected() {
235 let state = StateStore::new();
236 let tools = tools_map(&["echo"]);
237 let action = make_tool_call("nonexistent");
238 let result = validate_action(&action, &state, &tools);
239 assert!(!result.valid());
240 assert!(result.errors[0].reason.contains("not registered"));
241 }
242
243 #[test]
244 fn known_tool_passes() {
245 let state = StateStore::new();
246 let tools = tools_map(&["echo"]);
247 let action = make_tool_call("echo");
248 let result = validate_action(&action, &state, &tools);
249 assert!(result.valid());
250 }
251
252 #[test]
253 fn precondition_eq_fails() {
254 let state = StateStore::new();
255 let pre = Precondition {
256 key: "auth".to_string(),
257 operator: "eq".to_string(),
258 value: Value::Bool(true),
259 description: String::new(),
260 };
261 assert!(check_precondition(&pre, &state).is_some());
262 }
263
264 #[test]
265 fn precondition_eq_passes() {
266 let state = StateStore::new();
267 state.set("auth", Value::Bool(true), "setup");
268 let pre = Precondition {
269 key: "auth".to_string(),
270 operator: "eq".to_string(),
271 value: Value::Bool(true),
272 description: String::new(),
273 };
274 assert!(check_precondition(&pre, &state).is_none());
275 }
276
277 #[test]
278 fn precondition_exists() {
279 let state = StateStore::new();
280 let pre = Precondition {
281 key: "x".to_string(),
282 operator: "exists".to_string(),
283 value: Value::Null,
284 description: String::new(),
285 };
286 assert!(check_precondition(&pre, &state).is_some());
287
288 state.set("x", Value::from(1), "setup");
289 assert!(check_precondition(&pre, &state).is_none());
290 }
291
292 #[test]
293 fn state_dependency_missing() {
294 let state = StateStore::new();
295 let tools: HashMap<String, ToolSchema> = HashMap::new();
296 let mut action = make_tool_call("echo");
297 action.tool = None;
298 action.action_type = ActionType::StateRead;
299 action.state_dependencies = vec!["missing".to_string()];
300 let result = validate_action(&action, &state, &tools);
301 assert!(!result.valid());
302 assert!(result.errors[0].reason.contains("not found"));
303 }
304
305 #[test]
306 fn precondition_gt() {
307 let state = StateStore::new();
308 state.set("count", Value::from(10), "setup");
309 let pre = Precondition {
310 key: "count".to_string(),
311 operator: "gt".to_string(),
312 value: Value::from(5),
313 description: String::new(),
314 };
315 assert!(check_precondition(&pre, &state).is_none());
316
317 let pre_fail = Precondition {
318 key: "count".to_string(),
319 operator: "gt".to_string(),
320 value: Value::from(20),
321 description: String::new(),
322 };
323 assert!(check_precondition(&pre_fail, &state).is_some());
324 }
325
326 #[test]
327 fn missing_required_parameter_rejected() {
328 let state = StateStore::new();
329 let mut schema = simple_schema("add");
330 schema.parameters = serde_json::json!({
331 "type": "object",
332 "properties": {
333 "a": {"type": "number"},
334 "b": {"type": "number"}
335 },
336 "required": ["a", "b"]
337 });
338 let tools: HashMap<String, ToolSchema> =
339 [("add".to_string(), schema)].into_iter().collect();
340
341 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
343 assert!(!result.valid());
344 assert!(result.errors.iter().any(|e| e.reason.contains("missing required parameter 'a'")));
345 assert!(result.errors.iter().any(|e| e.reason.contains("missing required parameter 'b'")));
346 }
347
348 #[test]
349 fn required_parameters_provided_passes() {
350 let state = StateStore::new();
351 let mut schema = simple_schema("add");
352 schema.parameters = serde_json::json!({
353 "type": "object",
354 "properties": {
355 "a": {"type": "number"},
356 "b": {"type": "number"}
357 },
358 "required": ["a", "b"]
359 });
360 let tools: HashMap<String, ToolSchema> =
361 [("add".to_string(), schema)].into_iter().collect();
362
363 let mut action = make_tool_call("add");
364 action.parameters = [
365 ("a".to_string(), Value::from(1)),
366 ("b".to_string(), Value::from(2)),
367 ]
368 .into();
369 let result = validate_action(&action, &state, &tools);
370 assert!(result.valid());
371 }
372
373 #[test]
374 fn extra_parameters_allowed() {
375 let state = StateStore::new();
376 let mut schema = simple_schema("echo");
377 schema.parameters = serde_json::json!({
378 "type": "object",
379 "properties": {
380 "message": {"type": "string"}
381 },
382 "required": ["message"]
383 });
384 let tools: HashMap<String, ToolSchema> =
385 [("echo".to_string(), schema)].into_iter().collect();
386
387 let mut action = make_tool_call("echo");
388 action.parameters = [
389 ("message".to_string(), Value::from("hi")),
390 ("unexpected_extra".to_string(), Value::from(true)),
391 ]
392 .into();
393 let result = validate_action(&action, &state, &tools);
394 assert!(result.valid()); }
396}