use crate::rules::{RuleCategory, RulesStore};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Enforcement {
Block(String),
Warn(String),
}
#[derive(Debug, Clone, Default)]
pub struct HookContext {
pub hook_event: String,
pub tool_name: String,
pub files_touched: Vec<String>,
pub cwd: String,
pub command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationResult {
pub rules_checked: usize,
pub rules_matched: usize,
pub matched_rule_ids: Vec<String>,
pub enforcements: Vec<EnforcementRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnforcementRecord {
pub rule_id: String,
pub trigger: String,
pub action: String,
pub category: String,
}
fn relevant_categories(hook_event: &str) -> Vec<RuleCategory> {
match hook_event {
"PreToolUse" => vec![
RuleCategory::PreCommit,
RuleCategory::CodePattern,
RuleCategory::Workflow,
],
"PostToolUse" => vec![RuleCategory::CodePattern, RuleCategory::Workflow],
_ => vec![RuleCategory::Workflow],
}
}
pub fn evaluate_rules(store: &RulesStore, ctx: &HookContext) -> EvaluationResult {
let active = store.active_rules();
let categories = relevant_categories(&ctx.hook_event);
let mut matched_ids = Vec::new();
let mut enforcements = Vec::new();
for rule in &active {
if !categories.contains(&rule.category) {
continue;
}
if matches_trigger(&rule.trigger, ctx) {
matched_ids.push(rule.id.clone());
enforcements.push(EnforcementRecord {
rule_id: rule.id.clone(),
trigger: rule.trigger.clone(),
action: rule.action.clone(),
category: rule.category.to_string(),
});
}
}
EvaluationResult {
rules_checked: active.len(),
rules_matched: matched_ids.len(),
matched_rule_ids: matched_ids,
enforcements,
}
}
pub fn record_matched_hits(store: &mut RulesStore, matched_ids: &[String]) {
for id in matched_ids {
if let Some(rule) = store.get_mut(id) {
rule.record_hit();
}
}
}
pub fn format_warnings(result: &EvaluationResult) -> Option<String> {
if result.enforcements.is_empty() {
return None;
}
let mut lines = vec!["[edda L3] Learned rules triggered:".to_string()];
for e in &result.enforcements {
lines.push(format!(" - {} -> {}", e.trigger, e.action));
}
Some(lines.join("\n"))
}
fn matches_trigger(trigger: &str, ctx: &HookContext) -> bool {
if let Some(path) = trigger.strip_prefix("file_churn:") {
return ctx.files_touched.iter().any(|f| f.contains(path));
}
if let Some(cmd) = trigger.strip_prefix("command_failure:") {
if ctx.tool_name != "Bash" {
return false;
}
let cmd = cmd.trim();
if cmd.is_empty() {
return false;
}
let is_simple_token = cmd
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.');
return ctx.command.as_deref().is_some_and(|current| {
if is_simple_token {
current
.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == '.'))
.any(|word| word == cmd)
} else {
current.contains(cmd)
}
});
}
if trigger == "multi_agent_start" {
return ctx.hook_event == "SessionStart";
}
if ctx.tool_name.contains(trigger) {
return true;
}
ctx.files_touched.iter().any(|f| f.contains(trigger))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Rule, RuleCategory, RuleStatus, RulesStore};
fn active_rule(trigger: &str, action: &str, category: RuleCategory) -> Rule {
Rule {
id: format!("rule_test_{}", trigger.replace(':', "_")),
trigger: trigger.to_string(),
action: action.to_string(),
anchor_file: None,
anchor_hash: None,
created: "2026-01-01T00:00:00Z".to_string(),
last_hit: "2026-01-01T00:00:00Z".to_string(),
hits: 2,
ttl_days: 30,
superseded_by: None,
status: RuleStatus::Active,
source_session: "test".to_string(),
source_event: None,
category,
}
}
fn make_store(rules: Vec<Rule>) -> RulesStore {
RulesStore {
rules,
last_decay_run: None,
}
}
#[test]
fn file_churn_trigger_matches_touched_files() {
let store = make_store(vec![active_rule(
"file_churn:src/main.rs",
"Review carefully",
RuleCategory::PreCommit,
)]);
let ctx = HookContext {
hook_event: "PreToolUse".to_string(),
tool_name: "Write".to_string(),
files_touched: vec!["src/main.rs".to_string()],
cwd: "/project".to_string(),
command: None,
};
let result = evaluate_rules(&store, &ctx);
assert_eq!(result.rules_matched, 1);
}
#[test]
fn no_match_when_file_not_touched() {
let store = make_store(vec![active_rule(
"file_churn:src/main.rs",
"Review carefully",
RuleCategory::PreCommit,
)]);
let ctx = HookContext {
hook_event: "PreToolUse".to_string(),
tool_name: "Write".to_string(),
files_touched: vec!["src/lib.rs".to_string()],
cwd: "/project".to_string(),
command: None,
};
let result = evaluate_rules(&store, &ctx);
assert_eq!(result.rules_matched, 0);
}
#[test]
fn dormant_rules_not_evaluated() {
let mut rule = active_rule(
"file_churn:src/main.rs",
"Review carefully",
RuleCategory::PreCommit,
);
rule.status = RuleStatus::Dormant;
let store = make_store(vec![rule]);
let ctx = HookContext {
hook_event: "PreToolUse".to_string(),
tool_name: "Write".to_string(),
files_touched: vec!["src/main.rs".to_string()],
cwd: "/project".to_string(),
command: None,
};
let result = evaluate_rules(&store, &ctx);
assert_eq!(result.rules_matched, 0);
}
#[test]
fn command_failure_matches_only_same_command() {
let store = make_store(vec![active_rule(
"command_failure:python",
"Verify python is available",
RuleCategory::Workflow,
)]);
let hit_ctx = HookContext {
hook_event: "PreToolUse".to_string(),
tool_name: "Bash".to_string(),
files_touched: vec![],
cwd: "/project".to_string(),
command: Some("python scripts/run.py".to_string()),
};
assert_eq!(evaluate_rules(&store, &hit_ctx).rules_matched, 1);
let miss_ctx = HookContext {
command: Some("git status".to_string()),
..hit_ctx.clone()
};
assert_eq!(evaluate_rules(&store, &miss_ctx).rules_matched, 0);
let substr_ctx = HookContext {
command: Some("pythonic-helper --run".to_string()),
..hit_ctx.clone()
};
assert_eq!(evaluate_rules(&store, &substr_ctx).rules_matched, 0);
let none_ctx = HookContext {
command: None,
..hit_ctx.clone()
};
assert_eq!(evaluate_rules(&store, &none_ctx).rules_matched, 0);
let write_ctx = HookContext {
tool_name: "Write".to_string(),
..hit_ctx
};
assert_eq!(evaluate_rules(&store, &write_ctx).rules_matched, 0);
}
#[test]
fn format_warnings_empty_when_no_matches() {
let result = EvaluationResult {
rules_checked: 5,
rules_matched: 0,
matched_rule_ids: vec![],
enforcements: vec![],
};
assert!(format_warnings(&result).is_none());
}
#[test]
fn format_warnings_produces_output() {
let result = EvaluationResult {
rules_checked: 5,
rules_matched: 1,
matched_rule_ids: vec!["rule_1".to_string()],
enforcements: vec![EnforcementRecord {
rule_id: "rule_1".to_string(),
trigger: "file_churn:main.rs".to_string(),
action: "Review carefully".to_string(),
category: "pre_commit".to_string(),
}],
};
let warning = format_warnings(&result).unwrap();
assert!(warning.contains("Learned rules triggered"));
assert!(warning.contains("file_churn:main.rs"));
}
}