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