1use crate::rules::{RuleCategory, RulesStore};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Enforcement {
18 Block(String),
20 Warn(String),
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct HookContext {
27 pub hook_event: String,
29 pub tool_name: String,
31 pub files_touched: Vec<String>,
33 pub cwd: String,
35 pub command: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct EvaluationResult {
42 pub rules_checked: usize,
43 pub rules_matched: usize,
44 pub matched_rule_ids: Vec<String>,
45 pub enforcements: Vec<EnforcementRecord>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct EnforcementRecord {
51 pub rule_id: String,
52 pub trigger: String,
53 pub action: String,
54 pub category: String,
55}
56
57fn relevant_categories(hook_event: &str) -> Vec<RuleCategory> {
59 match hook_event {
60 "PreToolUse" => vec![
61 RuleCategory::PreCommit,
62 RuleCategory::CodePattern,
63 RuleCategory::Workflow,
64 ],
65 "PostToolUse" => vec![RuleCategory::CodePattern, RuleCategory::Workflow],
66 _ => vec![RuleCategory::Workflow],
67 }
68}
69
70pub fn evaluate_rules(store: &RulesStore, ctx: &HookContext) -> EvaluationResult {
75 let active = store.active_rules();
76 let categories = relevant_categories(&ctx.hook_event);
77 let mut matched_ids = Vec::new();
78 let mut enforcements = Vec::new();
79
80 for rule in &active {
81 if !categories.contains(&rule.category) {
83 continue;
84 }
85
86 if matches_trigger(&rule.trigger, ctx) {
88 matched_ids.push(rule.id.clone());
89 enforcements.push(EnforcementRecord {
90 rule_id: rule.id.clone(),
91 trigger: rule.trigger.clone(),
92 action: rule.action.clone(),
93 category: rule.category.to_string(),
94 });
95 }
96 }
97
98 EvaluationResult {
99 rules_checked: active.len(),
100 rules_matched: matched_ids.len(),
101 matched_rule_ids: matched_ids,
102 enforcements,
103 }
104}
105
106pub fn record_matched_hits(store: &mut RulesStore, matched_ids: &[String]) {
108 for id in matched_ids {
109 if let Some(rule) = store.get_mut(id) {
110 rule.record_hit();
111 }
112 }
113}
114
115pub fn format_warnings(result: &EvaluationResult) -> Option<String> {
117 if result.enforcements.is_empty() {
118 return None;
119 }
120
121 let mut lines = vec!["[edda L3] Learned rules triggered:".to_string()];
122 for e in &result.enforcements {
123 lines.push(format!(" - {} -> {}", e.trigger, e.action));
124 }
125 Some(lines.join("\n"))
126}
127
128fn matches_trigger(trigger: &str, ctx: &HookContext) -> bool {
138 if let Some(path) = trigger.strip_prefix("file_churn:") {
139 return ctx.files_touched.iter().any(|f| f.contains(path));
140 }
141
142 if let Some(cmd) = trigger.strip_prefix("command_failure:") {
143 if ctx.tool_name != "Bash" {
149 return false;
150 }
151 let cmd = cmd.trim();
152 if cmd.is_empty() {
153 return false;
154 }
155 let is_simple_token = cmd
159 .chars()
160 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.');
161 return ctx.command.as_deref().is_some_and(|current| {
162 if is_simple_token {
163 current
164 .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == '.'))
165 .any(|word| word == cmd)
166 } else {
167 current.contains(cmd)
168 }
169 });
170 }
171
172 if trigger == "multi_agent_start" {
173 return ctx.hook_event == "SessionStart";
174 }
175
176 if ctx.tool_name.contains(trigger) {
178 return true;
179 }
180 ctx.files_touched.iter().any(|f| f.contains(trigger))
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::rules::{Rule, RuleCategory, RuleStatus, RulesStore};
187
188 fn active_rule(trigger: &str, action: &str, category: RuleCategory) -> Rule {
189 Rule {
190 id: format!("rule_test_{}", trigger.replace(':', "_")),
191 trigger: trigger.to_string(),
192 action: action.to_string(),
193 anchor_file: None,
194 anchor_hash: None,
195 created: "2026-01-01T00:00:00Z".to_string(),
196 last_hit: "2026-01-01T00:00:00Z".to_string(),
197 hits: 2,
198 ttl_days: 30,
199 superseded_by: None,
200 status: RuleStatus::Active,
201 source_session: "test".to_string(),
202 source_event: None,
203 category,
204 }
205 }
206
207 fn make_store(rules: Vec<Rule>) -> RulesStore {
208 RulesStore {
209 rules,
210 last_decay_run: None,
211 }
212 }
213
214 #[test]
215 fn file_churn_trigger_matches_touched_files() {
216 let store = make_store(vec![active_rule(
217 "file_churn:src/main.rs",
218 "Review carefully",
219 RuleCategory::PreCommit,
220 )]);
221
222 let ctx = HookContext {
223 hook_event: "PreToolUse".to_string(),
224 tool_name: "Write".to_string(),
225 files_touched: vec!["src/main.rs".to_string()],
226 cwd: "/project".to_string(),
227 command: None,
228 };
229
230 let result = evaluate_rules(&store, &ctx);
231 assert_eq!(result.rules_matched, 1);
232 }
233
234 #[test]
235 fn no_match_when_file_not_touched() {
236 let store = make_store(vec![active_rule(
237 "file_churn:src/main.rs",
238 "Review carefully",
239 RuleCategory::PreCommit,
240 )]);
241
242 let ctx = HookContext {
243 hook_event: "PreToolUse".to_string(),
244 tool_name: "Write".to_string(),
245 files_touched: vec!["src/lib.rs".to_string()],
246 cwd: "/project".to_string(),
247 command: None,
248 };
249
250 let result = evaluate_rules(&store, &ctx);
251 assert_eq!(result.rules_matched, 0);
252 }
253
254 #[test]
255 fn dormant_rules_not_evaluated() {
256 let mut rule = active_rule(
257 "file_churn:src/main.rs",
258 "Review carefully",
259 RuleCategory::PreCommit,
260 );
261 rule.status = RuleStatus::Dormant;
262 let store = make_store(vec![rule]);
263
264 let ctx = HookContext {
265 hook_event: "PreToolUse".to_string(),
266 tool_name: "Write".to_string(),
267 files_touched: vec!["src/main.rs".to_string()],
268 cwd: "/project".to_string(),
269 command: None,
270 };
271
272 let result = evaluate_rules(&store, &ctx);
273 assert_eq!(result.rules_matched, 0);
274 }
275
276 #[test]
277 fn command_failure_matches_only_same_command() {
278 let store = make_store(vec![active_rule(
279 "command_failure:python",
280 "Verify python is available",
281 RuleCategory::Workflow,
282 )]);
283
284 let hit_ctx = HookContext {
286 hook_event: "PreToolUse".to_string(),
287 tool_name: "Bash".to_string(),
288 files_touched: vec![],
289 cwd: "/project".to_string(),
290 command: Some("python scripts/run.py".to_string()),
291 };
292 assert_eq!(evaluate_rules(&store, &hit_ctx).rules_matched, 1);
293
294 let miss_ctx = HookContext {
296 command: Some("git status".to_string()),
297 ..hit_ctx.clone()
298 };
299 assert_eq!(evaluate_rules(&store, &miss_ctx).rules_matched, 0);
300
301 let substr_ctx = HookContext {
303 command: Some("pythonic-helper --run".to_string()),
304 ..hit_ctx.clone()
305 };
306 assert_eq!(evaluate_rules(&store, &substr_ctx).rules_matched, 0);
307
308 let none_ctx = HookContext {
310 command: None,
311 ..hit_ctx.clone()
312 };
313 assert_eq!(evaluate_rules(&store, &none_ctx).rules_matched, 0);
314
315 let write_ctx = HookContext {
317 tool_name: "Write".to_string(),
318 ..hit_ctx
319 };
320 assert_eq!(evaluate_rules(&store, &write_ctx).rules_matched, 0);
321 }
322
323 #[test]
324 fn format_warnings_empty_when_no_matches() {
325 let result = EvaluationResult {
326 rules_checked: 5,
327 rules_matched: 0,
328 matched_rule_ids: vec![],
329 enforcements: vec![],
330 };
331 assert!(format_warnings(&result).is_none());
332 }
333
334 #[test]
335 fn format_warnings_produces_output() {
336 let result = EvaluationResult {
337 rules_checked: 5,
338 rules_matched: 1,
339 matched_rule_ids: vec!["rule_1".to_string()],
340 enforcements: vec![EnforcementRecord {
341 rule_id: "rule_1".to_string(),
342 trigger: "file_churn:main.rs".to_string(),
343 action: "Review carefully".to_string(),
344 category: "pre_commit".to_string(),
345 }],
346 };
347 let warning = format_warnings(&result).unwrap();
348 assert!(warning.contains("Learned rules triggered"));
349 assert!(warning.contains("file_churn:main.rs"));
350 }
351}