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 record_matched_shows(store: &mut RulesStore, matched_ids: &[String]) {
120 store.record_matched_shows(matched_ids);
121}
122
123pub fn format_warnings(result: &EvaluationResult) -> Option<String> {
125 if result.enforcements.is_empty() {
126 return None;
127 }
128
129 let mut lines = vec!["[edda L3] Learned rules triggered:".to_string()];
130 for e in &result.enforcements {
131 lines.push(format!(" - {} -> {}", e.trigger, e.action));
132 }
133 Some(lines.join("\n"))
134}
135
136fn matches_trigger(trigger: &str, ctx: &HookContext) -> bool {
146 if let Some(path) = trigger.strip_prefix("file_churn:") {
147 return ctx.files_touched.iter().any(|f| f.contains(path));
148 }
149
150 if let Some(cmd) = trigger.strip_prefix("command_failure:") {
151 if ctx.tool_name != "Bash" {
159 return false;
160 }
161 let cmd = cmd.trim();
162 if !crate::rules::is_trackable_command(cmd) {
163 return false;
166 }
167 return ctx.command.as_deref().is_some_and(|current| {
168 crate::rules::split_command_segments(current)
169 .iter()
170 .any(|segment| crate::rules::command_word(segment).as_deref() == Some(cmd))
171 });
172 }
173
174 if trigger == "multi_agent_start" {
175 return ctx.hook_event == "SessionStart";
176 }
177
178 if ctx.tool_name.contains(trigger) {
180 return true;
181 }
182 ctx.files_touched.iter().any(|f| f.contains(trigger))
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::rules::{Rule, RuleCategory, RuleStatus, RulesStore};
189
190 fn active_rule(trigger: &str, action: &str, category: RuleCategory) -> Rule {
191 Rule {
192 id: format!("rule_test_{}", trigger.replace(':', "_")),
193 trigger: trigger.to_string(),
194 action: action.to_string(),
195 anchor_file: None,
196 anchor_hash: None,
197 created: "2026-01-01T00:00:00Z".to_string(),
198 last_hit: "2026-01-01T00:00:00Z".to_string(),
199 hits: 2,
200 ttl_days: 30,
201 superseded_by: None,
202 status: RuleStatus::Active,
203 source_session: "test".to_string(),
204 source_event: None,
205 shows: 0,
206 revoked_reason: None,
207 category,
208 }
209 }
210
211 fn make_store(rules: Vec<Rule>) -> RulesStore {
212 RulesStore {
213 rules,
214 last_decay_run: None,
215 }
216 }
217
218 #[test]
219 fn file_churn_trigger_matches_touched_files() {
220 let store = make_store(vec![active_rule(
221 "file_churn:src/main.rs",
222 "Review carefully",
223 RuleCategory::PreCommit,
224 )]);
225
226 let ctx = HookContext {
227 hook_event: "PreToolUse".to_string(),
228 tool_name: "Write".to_string(),
229 files_touched: vec!["src/main.rs".to_string()],
230 cwd: "/project".to_string(),
231 command: None,
232 };
233
234 let result = evaluate_rules(&store, &ctx);
235 assert_eq!(result.rules_matched, 1);
236 }
237
238 #[test]
239 fn no_match_when_file_not_touched() {
240 let store = make_store(vec![active_rule(
241 "file_churn:src/main.rs",
242 "Review carefully",
243 RuleCategory::PreCommit,
244 )]);
245
246 let ctx = HookContext {
247 hook_event: "PreToolUse".to_string(),
248 tool_name: "Write".to_string(),
249 files_touched: vec!["src/lib.rs".to_string()],
250 cwd: "/project".to_string(),
251 command: None,
252 };
253
254 let result = evaluate_rules(&store, &ctx);
255 assert_eq!(result.rules_matched, 0);
256 }
257
258 #[test]
259 fn dormant_rules_not_evaluated() {
260 let mut rule = active_rule(
261 "file_churn:src/main.rs",
262 "Review carefully",
263 RuleCategory::PreCommit,
264 );
265 rule.status = RuleStatus::Dormant;
266 let store = make_store(vec![rule]);
267
268 let ctx = HookContext {
269 hook_event: "PreToolUse".to_string(),
270 tool_name: "Write".to_string(),
271 files_touched: vec!["src/main.rs".to_string()],
272 cwd: "/project".to_string(),
273 command: None,
274 };
275
276 let result = evaluate_rules(&store, &ctx);
277 assert_eq!(result.rules_matched, 0);
278 }
279
280 #[test]
281 fn command_failure_matches_only_same_command() {
282 let store = make_store(vec![active_rule(
283 "command_failure:python",
284 "Verify python is available",
285 RuleCategory::Workflow,
286 )]);
287
288 let hit_ctx = HookContext {
290 hook_event: "PreToolUse".to_string(),
291 tool_name: "Bash".to_string(),
292 files_touched: vec![],
293 cwd: "/project".to_string(),
294 command: Some("python scripts/run.py".to_string()),
295 };
296 assert_eq!(evaluate_rules(&store, &hit_ctx).rules_matched, 1);
297
298 let miss_ctx = HookContext {
300 command: Some("git status".to_string()),
301 ..hit_ctx.clone()
302 };
303 assert_eq!(evaluate_rules(&store, &miss_ctx).rules_matched, 0);
304
305 let substr_ctx = HookContext {
307 command: Some("pythonic-helper --run".to_string()),
308 ..hit_ctx.clone()
309 };
310 assert_eq!(evaluate_rules(&store, &substr_ctx).rules_matched, 0);
311
312 let none_ctx = HookContext {
314 command: None,
315 ..hit_ctx.clone()
316 };
317 assert_eq!(evaluate_rules(&store, &none_ctx).rules_matched, 0);
318
319 let write_ctx = HookContext {
321 tool_name: "Write".to_string(),
322 ..hit_ctx
323 };
324 assert_eq!(evaluate_rules(&store, &write_ctx).rules_matched, 0);
325 }
326
327 #[test]
328 fn command_failure_keys_on_first_token_of_segments() {
329 let store = make_store(vec![active_rule(
330 "command_failure:python",
331 "Verify python is available",
332 RuleCategory::Workflow,
333 )]);
334
335 let ctx = HookContext {
336 hook_event: "PreToolUse".to_string(),
337 tool_name: "Bash".to_string(),
338 files_touched: vec![],
339 cwd: "/project".to_string(),
340 command: Some("python scripts/run.py".to_string()),
341 };
342 assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 1);
343
344 let echo_ctx = HookContext {
347 command: Some("echo python".to_string()),
348 ..ctx.clone()
349 };
350 assert_eq!(evaluate_rules(&store, &echo_ctx).rules_matched, 0);
351
352 let seg_ctx = HookContext {
355 command: Some("cd /tmp && python scripts/run.py".to_string()),
356 ..ctx.clone()
357 };
358 assert_eq!(evaluate_rules(&store, &seg_ctx).rules_matched, 1);
359
360 let pipe_ctx = HookContext {
361 command: Some("cat data.txt | python -\nprint('x')".to_string()),
362 ..ctx.clone()
363 };
364 assert_eq!(evaluate_rules(&store, &pipe_ctx).rules_matched, 1);
365
366 let env_ctx = HookContext {
369 command: Some("FOO=1 BAR=2 python scripts/run.py".to_string()),
370 ..ctx.clone()
371 };
372 assert_eq!(evaluate_rules(&store, &env_ctx).rules_matched, 1);
373 }
374
375 #[test]
376 fn command_failure_does_not_match_when_cmd_is_argument() {
377 let store = make_store(vec![active_rule(
378 "command_failure:python",
379 "Verify python is available",
380 RuleCategory::Workflow,
381 )]);
382
383 let ctx = HookContext {
386 hook_event: "PreToolUse".to_string(),
387 tool_name: "Bash".to_string(),
388 files_touched: vec![],
389 cwd: "/project".to_string(),
390 command: Some("echo python; grep python file.txt".to_string()),
391 };
392 assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 0);
393 }
394
395 #[test]
396 fn command_failure_does_not_match_quoted_segment_content() {
397 let store = make_store(vec![active_rule(
398 "command_failure:python",
399 "Verify python is available",
400 RuleCategory::Workflow,
401 )]);
402
403 let ctx = HookContext {
406 hook_event: "PreToolUse".to_string(),
407 tool_name: "Bash".to_string(),
408 files_touched: vec![],
409 cwd: "/project".to_string(),
410 command: Some("printf '%s' 'skip; python -V'".to_string()),
411 };
412 assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 0);
413
414 let quoted_ctx = HookContext {
416 command: Some("\"python\" x.py".to_string()),
417 ..ctx
418 };
419 assert_eq!(evaluate_rules(&store, "ed_ctx).rules_matched, 1);
420 }
421
422 #[test]
423 fn command_failure_builtin_rules_never_match() {
424 let store = make_store(vec![
427 active_rule("command_failure:cd", "no", RuleCategory::Workflow),
428 active_rule("command_failure:echo", "no", RuleCategory::Workflow),
429 active_rule("command_failure:ls", "no", RuleCategory::Workflow),
430 active_rule("command_failure:grep", "no", RuleCategory::Workflow),
431 ]);
432 let ctx = HookContext {
433 hook_event: "PreToolUse".to_string(),
434 tool_name: "Bash".to_string(),
435 files_touched: vec![],
436 cwd: "/project".to_string(),
437 command: Some("cd /tmp; echo hi; ls -la | grep foo".to_string()),
438 };
439 assert_eq!(evaluate_rules(&store, &ctx).rules_matched, 0);
440 }
441
442 #[test]
443 fn command_failure_exact_command_word_matches_segment() {
444 let store = make_store(vec![active_rule(
445 "command_failure:git",
446 "Check git config",
447 RuleCategory::Workflow,
448 )]);
449 let ctx = HookContext {
450 hook_event: "PreToolUse".to_string(),
451 tool_name: "Bash".to_string(),
452 files_touched: vec![],
453 cwd: "/project".to_string(),
454 command: Some("cd /tmp; echo hi; git status".to_string()),
455 };
456 let result = evaluate_rules(&store, &ctx);
457 assert_eq!(result.rules_matched, 1);
458 assert_eq!(result.matched_rule_ids[0], "rule_test_command_failure_git");
459 }
460
461 #[test]
462 fn record_matched_shows_does_not_reset_ttl_or_status() {
463 let mut store = make_store(vec![active_rule(
464 "command_failure:python",
465 "Verify python is available",
466 RuleCategory::Workflow,
467 )]);
468 let last_hit = store.rules[0].last_hit.clone();
469 let hits = store.rules[0].hits;
470 let id = store.rules[0].id.clone();
471
472 record_matched_shows(&mut store, &["rule_missing".to_string(), id]);
473
474 let rule = &store.rules[0];
475 assert_eq!(rule.shows, 1);
476 assert_eq!(rule.hits, hits);
477 assert_eq!(rule.last_hit, last_hit);
478 assert_eq!(rule.status, RuleStatus::Active);
479 }
480
481 #[test]
482 fn format_warnings_empty_when_no_matches() {
483 let result = EvaluationResult {
484 rules_checked: 5,
485 rules_matched: 0,
486 matched_rule_ids: vec![],
487 enforcements: vec![],
488 };
489 assert!(format_warnings(&result).is_none());
490 }
491
492 #[test]
493 fn format_warnings_produces_output() {
494 let result = EvaluationResult {
495 rules_checked: 5,
496 rules_matched: 1,
497 matched_rule_ids: vec!["rule_1".to_string()],
498 enforcements: vec![EnforcementRecord {
499 rule_id: "rule_1".to_string(),
500 trigger: "file_churn:main.rs".to_string(),
501 action: "Review carefully".to_string(),
502 category: "pre_commit".to_string(),
503 }],
504 };
505 let warning = format_warnings(&result).unwrap();
506 assert!(warning.contains("Learned rules triggered"));
507 assert!(warning.contains("file_churn:main.rs"));
508 }
509}