Skip to main content

car_validator/
lib.rs

1//! Action validation — precondition checking and invariant enforcement.
2
3use car_ir::{Action, Precondition, ToolSchema};
4use car_state::StateStore;
5use serde_json::Value;
6use std::collections::HashMap;
7
8/// A single validation failure.
9#[derive(Debug, Clone)]
10pub struct ValidationError {
11    pub action_id: String,
12    pub reason: String,
13}
14
15/// Aggregate result of validating an action.
16#[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
28/// Check a single precondition against state.
29pub 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
126/// Validate a single action against state and registered tools.
127pub 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    // Check tool exists and validate parameters against schema
138    if let Some(ref tool) = action.tool {
139        if let Some(schema) = registered_tools.get(tool) {
140            // Validate required parameters
141            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            // Note: we intentionally do NOT reject unknown/extra parameters.
159            // LLMs may send unexpected ones and rejecting would be too strict.
160        } 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    // Check preconditions
169    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    // Check state dependencies
179    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"); // no parameters
342        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()); // extra params should NOT cause rejection
395    }
396}