1use std::collections::HashMap;
6
7pub struct EvalOutcome {
8 pub passed: bool,
9 pub details: String,
10}
11
12#[derive(Debug, Default, Clone)]
14pub struct ToolCallStats {
15 pub total: i64,
17 pub successful: i64,
20}
21
22pub fn evaluate_condition(
27 condition: &serde_json::Value,
28 tool_calls: &HashMap<String, ToolCallStats>,
29 files_modified: &[String],
30) -> EvalOutcome {
31 let cond_type = condition.get("type").and_then(|v| v.as_str()).unwrap_or("");
32
33 match cond_type {
34 "RequiredToolCall" => eval_required(condition, tool_calls),
35 "ConditionalToolCall" => eval_conditional(condition, tool_calls, files_modified),
36 "AiPercentageThreshold" => EvalOutcome {
37 passed: true,
38 details: "AI percentage not evaluated in check (requires attribution data)".into(),
39 },
40 "TokenBudget" => EvalOutcome {
41 passed: true,
42 details: "Token budget not evaluated in check (requires token data)".into(),
43 },
44 _ => EvalOutcome {
45 passed: true,
46 details: format!("Unknown condition type '{cond_type}', skipped"),
47 },
48 }
49}
50
51fn eval_required(
52 condition: &serde_json::Value,
53 tool_calls: &HashMap<String, ToolCallStats>,
54) -> EvalOutcome {
55 let tool_names: Vec<String> = condition
56 .get("tool_names")
57 .and_then(|v| v.as_array())
58 .map(|arr| {
59 arr.iter()
60 .filter_map(|v| v.as_str().map(String::from))
61 .collect()
62 })
63 .unwrap_or_default();
64
65 let must_succeed = condition
66 .get("must_succeed")
67 .and_then(|v| v.as_bool())
68 .unwrap_or(false);
69
70 let missing: Vec<&String> = tool_names
71 .iter()
72 .filter(|name| {
73 let stats = tool_calls.get(name.as_str());
74 if must_succeed {
75 stats.map(|s| s.successful).unwrap_or(0) == 0
76 } else {
77 stats.map(|s| s.total).unwrap_or(0) == 0
78 }
79 })
80 .collect();
81
82 if missing.is_empty() {
83 EvalOutcome {
84 passed: true,
85 details: if must_succeed {
86 "All required tools were used and succeeded".into()
87 } else {
88 "All required tools were used".into()
89 },
90 }
91 } else {
92 EvalOutcome {
93 passed: false,
94 details: format!(
95 "{}: {}",
96 if must_succeed {
97 "Required tools not called or failed"
98 } else {
99 "Missing required tools"
100 },
101 missing
102 .iter()
103 .map(|s| s.as_str())
104 .collect::<Vec<_>>()
105 .join(", ")
106 ),
107 }
108 }
109}
110
111fn eval_conditional(
112 condition: &serde_json::Value,
113 tool_calls: &HashMap<String, ToolCallStats>,
114 files_modified: &[String],
115) -> EvalOutcome {
116 let tool_name = condition
117 .get("tool_name")
118 .and_then(|v| v.as_str())
119 .unwrap_or("");
120 let min_count = condition
121 .get("min_count")
122 .and_then(|v| v.as_u64())
123 .unwrap_or(1) as i64;
124 let file_patterns = condition
125 .get("when_files_match")
126 .and_then(|v| v.as_array())
127 .map(|arr| {
128 arr.iter()
129 .filter_map(|v| v.as_str().map(String::from))
130 .collect::<Vec<_>>()
131 });
132
133 let patterns_match = match &file_patterns {
134 None => true,
135 Some(patterns) if patterns.is_empty() => true,
136 Some(patterns) => files_modified.iter().any(|file| {
137 patterns
138 .iter()
139 .any(|pattern| glob_match::glob_match(pattern, file))
140 }),
141 };
142
143 if !patterns_match {
144 return EvalOutcome {
145 passed: true,
146 details: "Rule skipped: no modified files match patterns".into(),
147 };
148 }
149
150 let must_succeed = condition
151 .get("must_succeed")
152 .and_then(|v| v.as_bool())
153 .unwrap_or(false);
154
155 let stats = tool_calls.get(tool_name);
156 let actual_count = if must_succeed {
157 stats.map(|s| s.successful).unwrap_or(0)
158 } else {
159 stats.map(|s| s.total).unwrap_or(0)
160 };
161 let total_count = stats.map(|s| s.total).unwrap_or(0);
162
163 let passed = actual_count >= min_count;
164 EvalOutcome {
165 passed,
166 details: if must_succeed {
167 format!(
168 "Tool '{tool_name}' called {total_count} time(s), {actual_count} succeeded (required >= {min_count} successful)"
169 )
170 } else {
171 format!("Tool '{tool_name}' called {actual_count} time(s) (required >= {min_count})")
172 },
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use serde_json::json;
180
181 fn tools(pairs: &[(&str, i64)]) -> HashMap<String, ToolCallStats> {
182 pairs
183 .iter()
184 .map(|(k, v)| {
185 (
186 k.to_string(),
187 ToolCallStats {
188 total: *v,
189 successful: *v, },
191 )
192 })
193 .collect()
194 }
195
196 fn tools_with_errors(pairs: &[(&str, i64, i64)]) -> HashMap<String, ToolCallStats> {
197 pairs
198 .iter()
199 .map(|(k, total, successful)| {
200 (
201 k.to_string(),
202 ToolCallStats {
203 total: *total,
204 successful: *successful,
205 },
206 )
207 })
208 .collect()
209 }
210
211 #[test]
212 fn required_all_present_exact_match() {
213 let cond = json!({"type": "RequiredToolCall", "tool_names": ["Read", "Write"]});
214 let t = tools(&[("Read", 1), ("Write", 1)]);
215 assert!(evaluate_condition(&cond, &t, &[]).passed);
216 }
217
218 #[test]
219 fn required_substring_does_not_match() {
220 let cond = json!({"type": "RequiredToolCall", "tool_names": ["Read"]});
222 let t = tools(&[("read_file", 5)]);
223 assert!(!evaluate_condition(&cond, &t, &[]).passed);
224 }
225
226 #[test]
227 fn required_missing_reports_names() {
228 let cond = json!({"type": "RequiredToolCall", "tool_names": ["Lint"]});
229 let out = evaluate_condition(&cond, &HashMap::new(), &[]);
230 assert!(!out.passed);
231 assert!(out.details.contains("Lint"));
232 }
233
234 #[test]
235 fn conditional_glob_file_match_count_met() {
236 let cond = json!({
237 "type": "ConditionalToolCall",
238 "tool_name": "security_scan",
239 "min_count": 1,
240 "when_files_match": ["**/*.rs"]
241 });
242 let t = tools(&[("security_scan", 2)]);
243 let files = vec!["src/main.rs".to_string()];
244 assert!(evaluate_condition(&cond, &t, &files).passed);
245 }
246
247 #[test]
248 fn conditional_count_not_met_fails() {
249 let cond = json!({
250 "type": "ConditionalToolCall",
251 "tool_name": "security_scan",
252 "min_count": 5,
253 "when_files_match": ["**/*.rs"]
254 });
255 let t = tools(&[("security_scan", 1)]);
256 let files = vec!["src/main.rs".to_string()];
257 assert!(!evaluate_condition(&cond, &t, &files).passed);
258 }
259
260 #[test]
261 fn conditional_no_file_match_skips() {
262 let cond = json!({
263 "type": "ConditionalToolCall",
264 "tool_name": "security_scan",
265 "min_count": 1,
266 "when_files_match": ["*.py"]
267 });
268 let files = vec!["src/main.rs".to_string()];
269 let out = evaluate_condition(&cond, &HashMap::new(), &files);
270 assert!(out.passed);
271 assert!(out.details.contains("skipped"));
272 }
273
274 #[test]
275 fn conditional_no_patterns_always_applies() {
276 let cond = json!({
277 "type": "ConditionalToolCall",
278 "tool_name": "test",
279 "min_count": 1
280 });
281 let out = evaluate_condition(&cond, &HashMap::new(), &[]);
282 assert!(!out.passed);
283 }
284
285 #[test]
286 fn conditional_tool_name_exact_only() {
287 let cond = json!({
289 "type": "ConditionalToolCall",
290 "tool_name": "security_scan",
291 "min_count": 1
292 });
293 let t = tools(&[("my_security_scan_v2", 10)]);
294 assert!(!evaluate_condition(&cond, &t, &[]).passed);
295 }
296
297 #[test]
298 fn unknown_condition_passes() {
299 let cond = json!({"type": "FutureCondition"});
300 assert!(evaluate_condition(&cond, &HashMap::new(), &[]).passed);
301 }
302
303 #[test]
304 fn required_must_succeed_all_failed_fails() {
305 let cond =
306 json!({"type": "RequiredToolCall", "tool_names": ["Bash"], "must_succeed": true});
307 let t = tools_with_errors(&[("Bash", 3, 0)]);
309 let out = evaluate_condition(&cond, &t, &[]);
310 assert!(!out.passed);
311 assert!(out.details.contains("failed"));
312 }
313
314 #[test]
315 fn required_must_succeed_one_success_passes() {
316 let cond =
317 json!({"type": "RequiredToolCall", "tool_names": ["Bash"], "must_succeed": true});
318 let t = tools_with_errors(&[("Bash", 3, 1)]);
319 assert!(evaluate_condition(&cond, &t, &[]).passed);
320 }
321
322 #[test]
323 fn required_must_succeed_false_counts_all_calls() {
324 let cond =
326 json!({"type": "RequiredToolCall", "tool_names": ["Bash"], "must_succeed": false});
327 let t = tools_with_errors(&[("Bash", 3, 0)]);
328 assert!(evaluate_condition(&cond, &t, &[]).passed);
329 }
330
331 #[test]
332 fn conditional_must_succeed_counts_only_successes() {
333 let cond = json!({
334 "type": "ConditionalToolCall",
335 "tool_name": "security_scan",
336 "min_count": 2,
337 "must_succeed": true
338 });
339 let t = tools_with_errors(&[("security_scan", 5, 1)]);
341 assert!(!evaluate_condition(&cond, &t, &[]).passed);
342 }
343
344 #[test]
345 fn conditional_must_succeed_passes_when_enough_successes() {
346 let cond = json!({
347 "type": "ConditionalToolCall",
348 "tool_name": "security_scan",
349 "min_count": 2,
350 "must_succeed": true
351 });
352 let t = tools_with_errors(&[("security_scan", 5, 3)]);
353 assert!(evaluate_condition(&cond, &t, &[]).passed);
354 }
355}