use anyhow::{Context, Result};
use glob::Pattern;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use crate::storage::MetadataStore;
use crate::types::FileCoupling;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReactionConfig {
#[serde(default)]
pub reactions: Vec<ReactionRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionRule {
pub name: String,
pub tool: String,
#[serde(default, rename = "match")]
pub match_conditions: HashMap<String, String>,
#[serde(default)]
pub guidance: String,
#[serde(default)]
pub search_query: String,
#[serde(default)]
pub search_group: String,
#[serde(default)]
pub search_tags: Vec<String>,
#[serde(default = "default_max_context_lines")]
pub max_context_lines: usize,
#[serde(default)]
pub use_coupling: bool,
#[serde(default = "default_coupling_threshold")]
pub coupling_threshold: f32,
#[serde(default)]
pub roles: Vec<String>,
}
fn default_max_context_lines() -> usize {
50
}
fn default_coupling_threshold() -> f32 {
0.3
}
impl ReactionConfig {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read reactions file: {}", path.display()))?;
Self::parse(&content)
}
pub fn parse(toml_str: &str) -> Result<Self> {
toml::from_str(toml_str).context("Failed to parse reactions TOML")
}
pub fn load_for_repo(repo_root: &Path) -> Self {
let mut config = crate::config::Config::global_config_dir()
.map(|dir| dir.join("reactions.toml"))
.filter(|p| p.exists())
.and_then(|p| Self::load(&p).ok())
.unwrap_or_default();
let local_path = repo_root.join(".bobbin").join("reactions.toml");
if local_path.exists() {
if let Ok(local) = Self::load(&local_path) {
let local_names: std::collections::HashSet<String> =
local.reactions.iter().map(|r| r.name.clone()).collect();
config.reactions.retain(|r| !local_names.contains(&r.name));
config.reactions.extend(local.reactions);
}
}
config
}
}
pub struct CompiledRule {
pub rule: ReactionRule,
tool_pattern: Pattern,
match_regexes: Vec<(String, Regex)>,
}
impl CompiledRule {
pub fn compile(rule: ReactionRule) -> Result<Self> {
let tool_pattern = Pattern::new(&rule.tool).with_context(|| {
format!(
"Invalid tool glob pattern '{}' in rule '{}'",
rule.tool, rule.name
)
})?;
let mut match_regexes = Vec::new();
for (param, pattern) in &rule.match_conditions {
let regex = Regex::new(pattern).with_context(|| {
format!(
"Invalid regex '{}' for param '{}' in rule '{}'",
pattern, param, rule.name
)
})?;
match_regexes.push((param.clone(), regex));
}
Ok(Self {
rule,
tool_pattern,
match_regexes,
})
}
}
fn role_matches(patterns: &[String], role: &str) -> bool {
if patterns.is_empty() {
return true;
}
patterns.iter().any(|pattern| {
Pattern::new(pattern)
.map(|p| p.matches(role))
.unwrap_or(false)
})
}
#[derive(Debug, Clone)]
pub struct MatchResult {
pub rule_name: String,
pub captures: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ToolEvent {
pub tool_name: String,
pub tool_input: serde_json::Value,
}
impl ToolEvent {
pub fn arg(&self, name: &str) -> Option<&str> {
self.tool_input.get(name).and_then(|v| v.as_str())
}
}
pub fn match_rules<'a>(
event: &ToolEvent,
rules: &'a [CompiledRule],
) -> Vec<(&'a CompiledRule, MatchResult)> {
let mut matches = Vec::new();
for compiled in rules {
if !compiled.tool_pattern.matches(&event.tool_name) {
continue;
}
let mut all_match = true;
let mut captures = HashMap::new();
for (param, regex) in &compiled.match_regexes {
let value = event.arg(param).unwrap_or("");
if let Some(caps) = regex.captures(value) {
captures.insert(
param.clone(),
caps.get(0).map_or("", |m| m.as_str()).to_string(),
);
for name in regex.capture_names().flatten() {
if let Some(m) = caps.name(name) {
captures.insert(format!("matched.{}", name), m.as_str().to_string());
}
}
} else {
all_match = false;
break;
}
}
if all_match {
matches.push((
compiled,
MatchResult {
rule_name: compiled.rule.name.clone(),
captures,
},
));
}
}
matches
}
pub fn render_template(
template: &str,
event: &ToolEvent,
captures: &HashMap<String, String>,
) -> String {
let mut result = template.to_string();
let args_re = Regex::new(r"\{args\.([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap();
result = args_re
.replace_all(&result, |caps: ®ex::Captures| {
let param_name = &caps[1];
event.arg(param_name).unwrap_or("").to_string()
})
.into_owned();
let matched_re = Regex::new(r"\{matched\.([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap();
result = matched_re
.replace_all(&result, |caps: ®ex::Captures| {
let capture_name = &caps[1];
captures
.get(&format!("matched.{}", capture_name))
.map_or("", |v| v.as_str())
.to_string()
})
.into_owned();
if result.contains("{file_stem}") {
let file_stem = event
.arg("file_path")
.and_then(|p| Path::new(p).file_stem())
.and_then(|s| s.to_str())
.unwrap_or("");
result = result.replace("{file_stem}", file_stem);
}
result
}
#[derive(Debug, Clone)]
pub struct CouplingResult {
pub coupled_files: Vec<CoupledFile>,
}
#[derive(Debug, Clone)]
pub struct CoupledFile {
pub path: String,
pub score: f32,
pub co_changes: u32,
}
pub fn query_coupling(
store: &MetadataStore,
file_path: &str,
threshold: f32,
limit: usize,
) -> Result<CouplingResult> {
let couplings = store.get_coupling(file_path, limit)?;
let coupled_files: Vec<CoupledFile> = couplings
.into_iter()
.filter(|c| c.score >= threshold)
.map(|c: FileCoupling| {
let path = if c.file_a == file_path {
c.file_b
} else {
c.file_a
};
CoupledFile {
path,
score: c.score,
co_changes: c.co_changes,
}
})
.collect();
Ok(CouplingResult { coupled_files })
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct DedupKey {
pub rule_name: String,
pub args_hash: String,
}
pub struct DedupTracker {
fired: std::collections::HashSet<DedupKey>,
path: Option<std::path::PathBuf>,
}
impl DedupTracker {
pub fn load(repo_root: &Path, session_id: &str) -> Self {
if session_id.is_empty() {
return Self {
fired: std::collections::HashSet::new(),
path: None,
};
}
let dir = repo_root.join(".bobbin").join("session").join(session_id);
let path = dir.join("reactions.jsonl");
let mut fired = std::collections::HashSet::new();
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
if let Ok(key) = serde_json::from_str::<DedupKey>(line) {
fired.insert(key);
}
}
}
}
Self {
fired,
path: Some(path),
}
}
pub fn has_fired(&self, key: &DedupKey) -> bool {
self.fired.contains(key)
}
pub fn record(&mut self, key: DedupKey) {
if self.fired.insert(key.clone()) {
if let Some(ref path) = self.path {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(line) = serde_json::to_string(&key) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{}", line);
}
}
}
}
}
pub fn make_key(rule: &ReactionRule, event: &ToolEvent) -> DedupKey {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(event.tool_name.as_bytes());
if rule.match_conditions.is_empty() {
for key in &["file_path", "command", "container", "service", "pattern"] {
if let Some(val) = event.arg(key) {
hasher.update(key.as_bytes());
hasher.update(val.as_bytes());
}
}
} else {
for param in rule.match_conditions.keys() {
if let Some(val) = event.arg(param) {
hasher.update(param.as_bytes());
hasher.update(val.as_bytes());
}
}
}
let hash = hex::encode(hasher.finalize());
DedupKey {
rule_name: rule.name.clone(),
args_hash: hash[..16].to_string(), }
}
}
pub struct EvaluationResult {
pub output: String,
pub reactions_fired: usize,
pub rules_fired: Vec<String>,
pub injection_ids: Vec<String>,
pub rules_deduped: usize,
}
pub fn evaluate_reactions(
event: &ToolEvent,
rules: &[CompiledRule],
dedup: &mut DedupTracker,
metadata_store: Option<&MetadataStore>,
global_budget: usize,
role: &str,
) -> EvaluationResult {
let matches = match_rules(event, rules);
let mut output = String::new();
let mut lines_used = 0usize;
let mut reactions_fired = 0;
let mut rules_fired = Vec::new();
let mut injection_ids = Vec::new();
let mut rules_deduped = 0;
for (compiled, match_result) in &matches {
if lines_used >= global_budget {
break;
}
if !role_matches(&compiled.rule.roles, role) {
continue;
}
let dedup_key = DedupTracker::make_key(&compiled.rule, event);
if dedup.has_fired(&dedup_key) {
rules_deduped += 1;
continue;
}
let guidance = render_template(&compiled.rule.guidance, event, &match_result.captures);
let coupled_files = if compiled.rule.use_coupling {
if let Some(store) = metadata_store {
let file_path = event.arg("file_path").unwrap_or("");
match query_coupling(store, file_path, compiled.rule.coupling_threshold, 10) {
Ok(result) if !result.coupled_files.is_empty() => Some(result.coupled_files),
_ => {
rules_deduped += 1;
continue;
}
}
} else {
rules_deduped += 1;
continue;
}
} else {
None };
let (reaction_text, injection_id) =
format_reaction(&compiled.rule, &guidance, coupled_files.as_deref());
let reaction_lines: Vec<&str> = reaction_text.lines().collect();
let max_lines = compiled
.rule
.max_context_lines
.min(global_budget - lines_used);
let lines_to_add = reaction_lines.len().min(max_lines);
if !output.is_empty() {
output.push('\n');
lines_used += 1;
}
for line in &reaction_lines[..lines_to_add] {
output.push_str(line);
output.push('\n');
}
lines_used += lines_to_add;
if lines_to_add < reaction_lines.len() {
output.push_str("... (truncated by budget)\n");
lines_used += 1;
}
dedup.record(dedup_key);
reactions_fired += 1;
rules_fired.push(compiled.rule.name.clone());
injection_ids.push(injection_id);
}
EvaluationResult {
output,
reactions_fired,
rules_fired,
injection_ids,
rules_deduped,
}
}
pub fn pending_searches(
event: &ToolEvent,
rules: &[CompiledRule],
dedup: &DedupTracker,
role: &str,
) -> Vec<PendingSearch> {
let matches = match_rules(event, rules);
let mut searches = Vec::new();
for (compiled, match_result) in &matches {
if !role_matches(&compiled.rule.roles, role) {
continue;
}
if compiled.rule.use_coupling || compiled.rule.search_query.is_empty() {
continue;
}
let dedup_key = DedupTracker::make_key(&compiled.rule, event);
if dedup.has_fired(&dedup_key) {
continue;
}
let query = render_template(&compiled.rule.search_query, event, &match_result.captures);
if query.trim().is_empty() {
continue;
}
searches.push(PendingSearch {
rule_name: compiled.rule.name.clone(),
query,
group: if compiled.rule.search_group.is_empty() {
None
} else {
Some(compiled.rule.search_group.clone())
},
tags: compiled.rule.search_tags.clone(),
max_lines: compiled.rule.max_context_lines,
});
}
searches
}
#[derive(Debug, Clone)]
pub struct PendingSearch {
pub rule_name: String,
pub query: String,
pub group: Option<String>,
pub tags: Vec<String>,
pub max_lines: usize,
}
#[derive(Debug, Clone)]
pub struct SearchHit {
pub path: String,
pub score: f32,
}
pub fn format_search_results(search: &PendingSearch, hits: &[SearchHit]) -> String {
let mut out = String::new();
if hits.is_empty() {
out.push_str(&format!(
"\n--- No indexed files found for `{}` ---\n\n",
search.query
));
out.push_str(
" WARNING: this reaction's search returned nothing. Either the index does not\n",
);
out.push_str(" cover these files, or the rule's scope excludes everything indexed.\n");
if let Some(ref group) = search.group {
out.push_str(&format!(" scope: group `{group}`\n"));
}
if !search.tags.is_empty() {
out.push_str(&format!(" scope: tags {}\n", search.tags.join(", ")));
}
return out;
}
out.push_str(&format!(
"\n--- Indexed files ({} results) ---\n\n",
hits.len()
));
for h in hits {
out.push_str(&format!(" {} (score: {:.2})\n", h.path, h.score));
}
out
}
pub fn builtin_rules() -> Vec<ReactionRule> {
vec![
ReactionRule {
name: "iac-drift-check".into(),
tool: "mcp__homelab__batch_probe".into(),
match_conditions: HashMap::new(),
guidance: concat!(
"You just modified container {args.container} via batch_probe.\n",
"Ensure this change is reflected in goldblum IaC (Terraform/Ansible)\n",
"so it persists across reprovisioning.",
)
.into(),
search_query: "terraform container {args.container}".into(),
search_group: "goldblum".into(),
search_tags: vec!["auto:config".into()],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
},
ReactionRule {
name: "service-known-issues".into(),
tool: "mcp__homelab__service_restart".into(),
match_conditions: HashMap::new(),
guidance: concat!(
"Service {args.service} restarted on {args.container}.\n",
"Check for known issues or recent changes that could affect this service.",
)
.into(),
search_query: "{args.service} {args.container} configuration".into(),
search_group: String::new(),
search_tags: vec!["auto:config".into()],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
},
ReactionRule {
name: "package-iac-declaration".into(),
tool: "Bash".into(),
match_conditions: {
let mut m = HashMap::new();
m.insert("command".into(), r"apt install (?P<package>\S+)".into());
m
},
guidance: concat!(
"You installed package {matched.package} directly.\n",
"If this container is managed by Ansible/Terraform, add the package\n",
"to the relevant IaC declaration so it persists across reprovisioning.",
)
.into(),
search_query: "ansible package {matched.package}".into(),
search_group: "goldblum".into(),
search_tags: vec![],
max_context_lines: 30,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
},
ReactionRule {
name: "terraform-plan-check".into(),
tool: "Edit".into(),
match_conditions: {
let mut m = HashMap::new();
m.insert("file_path".into(), r".*\.tf$".into());
m
},
guidance: concat!(
"Terraform file modified. Run `terraform plan` to verify\n",
"the change before applying. Check for dependent resources.",
)
.into(),
search_query: "terraform {file_stem} resource".into(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 40,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
},
ReactionRule {
name: "coupled-files".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: concat!(
"This file has historically changed alongside other files.\n",
"Review these coupled files for necessary updates.",
)
.into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 30,
use_coupling: true,
coupling_threshold: 0.3,
roles: vec![],
},
]
}
impl ReactionConfig {
pub fn with_builtins(mut self) -> Self {
let user_names: std::collections::HashSet<String> =
self.reactions.iter().map(|r| r.name.clone()).collect();
let mut builtins: Vec<ReactionRule> = builtin_rules()
.into_iter()
.filter(|r| !user_names.contains(&r.name))
.collect();
builtins.append(&mut self.reactions);
self.reactions = builtins;
self
}
}
pub fn generate_injection_id(rule_name: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(rule_name.as_bytes());
hasher.update(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
let hash = hex::encode(hasher.finalize());
format!("inj-react-{}", &hash[..8])
}
pub fn format_reaction(
rule: &ReactionRule,
guidance: &str,
coupled_files: Option<&[CoupledFile]>,
) -> (String, String) {
let injection_id = generate_injection_id(&rule.name);
let mut out = String::new();
out.push_str(&format!(
"=== Reaction: {} [injection_id: {}] ===\n\n",
rule.name, injection_id
));
out.push_str(guidance.trim());
out.push('\n');
if let Some(files) = coupled_files {
if files.is_empty() {
} else {
out.push_str(&format!(
"\n--- Coupled files ({} results) ---\n\n",
files.len()
));
for f in files {
out.push_str(&format!(
" {} (coupling: {:.2}, co-changes: {})\n",
f.path, f.score, f.co_changes
));
}
}
}
out.push_str("\n=== End Reaction ===");
(out, injection_id)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_empty_config() {
let config = ReactionConfig::parse("").unwrap();
assert!(config.reactions.is_empty());
}
#[test]
fn test_parse_single_rule() {
let toml = r#"
[[reactions]]
name = "iac-drift-check"
tool = "mcp__homelab__batch_probe"
guidance = "Check IaC for drift"
search_query = "terraform container"
max_context_lines = 30
"#;
let config = ReactionConfig::parse(toml).unwrap();
assert_eq!(config.reactions.len(), 1);
let rule = &config.reactions[0];
assert_eq!(rule.name, "iac-drift-check");
assert_eq!(rule.tool, "mcp__homelab__batch_probe");
assert_eq!(rule.guidance, "Check IaC for drift");
assert_eq!(rule.search_query, "terraform container");
assert_eq!(rule.max_context_lines, 30);
assert!(!rule.use_coupling);
}
#[test]
fn test_parse_rule_with_match_conditions() {
let toml = r#"
[[reactions]]
name = "apt-iac"
tool = "Bash"
guidance = "Package installed directly"
search_query = "ansible package"
[reactions.match]
command = "apt install .*"
"#;
let config = ReactionConfig::parse(toml).unwrap();
assert_eq!(config.reactions.len(), 1);
let rule = &config.reactions[0];
assert_eq!(
rule.match_conditions.get("command").unwrap(),
"apt install .*"
);
}
#[test]
fn test_parse_coupling_rule() {
let toml = r#"
[[reactions]]
name = "coupled-files"
tool = "Edit"
use_coupling = true
coupling_threshold = 0.4
guidance = "These files historically change together"
"#;
let config = ReactionConfig::parse(toml).unwrap();
let rule = &config.reactions[0];
assert!(rule.use_coupling);
assert!((rule.coupling_threshold - 0.4).abs() < f32::EPSILON);
}
#[test]
fn test_parse_multiple_rules() {
let toml = r#"
[[reactions]]
name = "rule1"
tool = "Edit"
guidance = "First rule"
[[reactions]]
name = "rule2"
tool = "Bash"
guidance = "Second rule"
search_tags = ["auto:config", "user:ops"]
"#;
let config = ReactionConfig::parse(toml).unwrap();
assert_eq!(config.reactions.len(), 2);
assert_eq!(
config.reactions[1].search_tags,
vec!["auto:config", "user:ops"]
);
}
#[test]
fn test_parse_default_values() {
let toml = r#"
[[reactions]]
name = "minimal"
tool = "Edit"
"#;
let config = ReactionConfig::parse(toml).unwrap();
let rule = &config.reactions[0];
assert_eq!(rule.max_context_lines, 50); assert!((rule.coupling_threshold - 0.3).abs() < f32::EPSILON); assert!(rule.guidance.is_empty());
assert!(rule.search_query.is_empty());
assert!(rule.search_group.is_empty());
assert!(rule.search_tags.is_empty());
assert!(!rule.use_coupling);
}
#[test]
fn test_parse_invalid_toml() {
let result = ReactionConfig::parse("not valid toml [[[");
assert!(result.is_err());
}
#[test]
fn test_compile_valid_rule() {
let rule = ReactionRule {
name: "test".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "test".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
assert!(CompiledRule::compile(rule).is_ok());
}
#[test]
fn test_compile_glob_pattern() {
let rule = ReactionRule {
name: "test".into(),
tool: "mcp__homelab__*".into(),
match_conditions: HashMap::new(),
guidance: String::new(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
let compiled = CompiledRule::compile(rule).unwrap();
assert!(compiled.tool_pattern.matches("mcp__homelab__batch_probe"));
assert!(compiled
.tool_pattern
.matches("mcp__homelab__service_restart"));
assert!(!compiled.tool_pattern.matches("Edit"));
}
#[test]
fn test_compile_invalid_glob() {
let rule = ReactionRule {
name: "test".into(),
tool: "[invalid".into(),
match_conditions: HashMap::new(),
guidance: String::new(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
assert!(CompiledRule::compile(rule).is_err());
}
#[test]
fn test_compile_invalid_regex() {
let mut conditions = HashMap::new();
conditions.insert("command".into(), "[invalid regex".into());
let rule = ReactionRule {
name: "test".into(),
tool: "Bash".into(),
match_conditions: conditions,
guidance: String::new(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
assert!(CompiledRule::compile(rule).is_err());
}
fn make_rule(name: &str, tool: &str, conditions: HashMap<String, String>) -> CompiledRule {
CompiledRule::compile(ReactionRule {
name: name.into(),
tool: tool.into(),
match_conditions: conditions,
guidance: format!("Guidance for {}", name),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
})
.unwrap()
}
#[test]
fn test_match_exact_tool() {
let rules = vec![make_rule("r1", "Edit", HashMap::new())];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].1.rule_name, "r1");
}
#[test]
fn test_match_glob_tool() {
let rules = vec![make_rule("r1", "mcp__homelab__*", HashMap::new())];
let event = ToolEvent {
tool_name: "mcp__homelab__batch_probe".into(),
tool_input: json!({"command": "uptime", "container": "monitoring"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1);
}
#[test]
fn test_no_match_wrong_tool() {
let rules = vec![make_rule("r1", "Edit", HashMap::new())];
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "ls"}),
};
let matches = match_rules(&event, &rules);
assert!(matches.is_empty());
}
#[test]
fn test_match_with_param_regex() {
let mut conditions = HashMap::new();
conditions.insert("command".into(), "apt install .*".into());
let rules = vec![make_rule("apt", "Bash", conditions)];
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "apt install nginx"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1);
}
#[test]
fn test_no_match_param_regex_fails() {
let mut conditions = HashMap::new();
conditions.insert("command".into(), "^apt install .*".into());
let rules = vec![make_rule("apt", "Bash", conditions)];
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "cargo build"}),
};
let matches = match_rules(&event, &rules);
assert!(matches.is_empty());
}
#[test]
fn test_match_file_path_glob() {
let mut conditions = HashMap::new();
conditions.insert("file_path".into(), r".*\.tf$".into());
let rules = vec![make_rule("tf", "Edit", conditions)];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/infra/main.tf"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1);
}
#[test]
fn test_multiple_rules_match() {
let rules = vec![
make_rule("r1", "Edit", HashMap::new()),
make_rule("r2", "Edit", HashMap::new()),
make_rule("r3", "Bash", HashMap::new()),
];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 2); }
#[test]
fn test_match_missing_param_treated_as_empty() {
let mut conditions = HashMap::new();
conditions.insert("nonexistent".into(), "^$".into()); let rules = vec![make_rule("r1", "Bash", conditions)];
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "ls"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1); }
#[test]
fn test_match_named_captures() {
let mut conditions = HashMap::new();
conditions.insert("command".into(), r"apt install (?P<package>\S+)".into());
let rules = vec![make_rule("apt", "Bash", conditions)];
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "apt install nginx"}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1);
assert_eq!(
matches[0].1.captures.get("matched.package").unwrap(),
"nginx"
);
}
#[test]
fn test_render_args_substitution() {
let event = ToolEvent {
tool_name: "mcp__homelab__batch_probe".into(),
tool_input: json!({"container": "monitoring", "command": "uptime"}),
};
let result = render_template(
"Container {args.container} ran {args.command}",
&event,
&HashMap::new(),
);
assert_eq!(result, "Container monitoring ran uptime");
}
#[test]
fn test_render_missing_arg() {
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "ls"}),
};
let result = render_template("Value: {args.missing}", &event, &HashMap::new());
assert_eq!(result, "Value: ");
}
#[test]
fn test_render_file_stem() {
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/infra/main.tf"}),
};
let result = render_template("Resource: {file_stem}", &event, &HashMap::new());
assert_eq!(result, "Resource: main");
}
#[test]
fn test_render_file_stem_no_path() {
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "ls"}),
};
let result = render_template("Resource: {file_stem}", &event, &HashMap::new());
assert_eq!(result, "Resource: ");
}
#[test]
fn test_render_matched_captures() {
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "apt install nginx"}),
};
let mut captures = HashMap::new();
captures.insert("matched.package".into(), "nginx".into());
let result = render_template("Package: {matched.package}", &event, &captures);
assert_eq!(result, "Package: nginx");
}
#[test]
fn test_render_mixed_templates() {
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/terraform/monitoring.tf", "old_string": "x", "new_string": "y"}),
};
let result = render_template(
"terraform {file_stem} resource {args.file_path}",
&event,
&HashMap::new(),
);
assert_eq!(
result,
"terraform monitoring resource /home/user/terraform/monitoring.tf"
);
}
#[test]
fn test_render_no_templates() {
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({}),
};
let result = render_template("No templates here", &event, &HashMap::new());
assert_eq!(result, "No templates here");
}
#[test]
fn test_coupled_file_filtering() {
let couplings = vec![
FileCoupling {
file_a: "src/main.rs".into(),
file_b: "src/lib.rs".into(),
score: 0.8,
co_changes: 15,
last_co_change: 1000,
},
FileCoupling {
file_a: "src/main.rs".into(),
file_b: "src/test.rs".into(),
score: 0.2, co_changes: 2,
last_co_change: 500,
},
FileCoupling {
file_a: "src/utils.rs".into(),
file_b: "src/main.rs".into(),
score: 0.5,
co_changes: 8,
last_co_change: 900,
},
];
let file_path = "src/main.rs";
let threshold = 0.3;
let coupled: Vec<CoupledFile> = couplings
.into_iter()
.filter(|c| c.score >= threshold)
.map(|c| {
let path = if c.file_a == file_path {
c.file_b
} else {
c.file_a
};
CoupledFile {
path,
score: c.score,
co_changes: c.co_changes,
}
})
.collect();
assert_eq!(coupled.len(), 2);
assert_eq!(coupled[0].path, "src/lib.rs");
assert_eq!(coupled[0].co_changes, 15);
assert_eq!(coupled[1].path, "src/utils.rs");
}
#[test]
fn test_format_reaction_with_coupled_files() {
let rule = ReactionRule {
name: "coupled-files".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "These files change together.".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: true,
coupling_threshold: 0.3,
roles: vec![],
};
let files = vec![
CoupledFile {
path: "src/lib.rs".into(),
score: 0.85,
co_changes: 12,
},
CoupledFile {
path: "src/config.rs".into(),
score: 0.42,
co_changes: 5,
},
];
let (output, injection_id) =
format_reaction(&rule, "These files change together.", Some(&files));
assert!(output.contains("=== Reaction: coupled-files"));
assert!(output.contains(&injection_id));
assert!(output.contains("These files change together."));
assert!(output.contains("src/lib.rs (coupling: 0.85, co-changes: 12)"));
assert!(output.contains("src/config.rs (coupling: 0.42, co-changes: 5)"));
assert!(output.contains("=== End Reaction ==="));
assert!(injection_id.starts_with("inj-react-"));
}
#[test]
fn test_format_reaction_guidance_only() {
let rule = ReactionRule {
name: "simple".into(),
tool: "Bash".into(),
match_conditions: HashMap::new(),
guidance: "Remember to check IaC".into(),
search_query: "terraform".into(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
let (output, injection_id) = format_reaction(&rule, "Remember to check IaC", None);
assert!(output.contains("=== Reaction: simple"));
assert!(output.contains(&injection_id));
assert!(output.contains("Remember to check IaC"));
assert!(!output.contains("Coupled files"));
assert!(output.contains("=== End Reaction ==="));
}
#[test]
fn test_format_reaction_empty_coupled_files() {
let rule = ReactionRule {
name: "empty".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Checking coupling...".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: true,
coupling_threshold: 0.3,
roles: vec![],
};
let (output, _injection_id) = format_reaction(&rule, "Checking coupling...", Some(&[]));
assert!(output.contains("Checking coupling..."));
assert!(!output.contains("Coupled files"));
}
#[test]
fn test_end_to_end_match_and_render() {
let toml = r#"
[[reactions]]
name = "iac-drift-check"
tool = "mcp__homelab__batch_probe"
guidance = """
You modified container {args.container} via batch_probe.
Ensure changes are reflected in IaC.
"""
search_query = "terraform container {args.container}"
search_group = "goldblum"
search_tags = ["auto:config"]
max_context_lines = 50
[[reactions]]
name = "apt-iac"
tool = "Bash"
guidance = "Package {matched.package} installed. Add to IaC."
search_query = "ansible package {matched.package}"
[reactions.match]
command = "apt install (?P<package>\\S+)"
[[reactions]]
name = "coupled-files"
tool = "Edit"
use_coupling = true
coupling_threshold = 0.3
guidance = "Review coupled files for {file_stem}"
"#;
let config = ReactionConfig::parse(toml).unwrap();
assert_eq!(config.reactions.len(), 3);
let compiled: Vec<CompiledRule> = config
.reactions
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event1 = ToolEvent {
tool_name: "mcp__homelab__batch_probe".into(),
tool_input: json!({"command": "uptime", "container": "monitoring"}),
};
let matches1 = match_rules(&event1, &compiled);
assert_eq!(matches1.len(), 1);
assert_eq!(matches1[0].1.rule_name, "iac-drift-check");
let guidance = render_template(
&matches1[0].0.rule.guidance,
&event1,
&matches1[0].1.captures,
);
assert!(guidance.contains("container monitoring"));
let query = render_template(
&matches1[0].0.rule.search_query,
&event1,
&matches1[0].1.captures,
);
assert_eq!(query, "terraform container monitoring");
let event2 = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "apt install nginx"}),
};
let matches2 = match_rules(&event2, &compiled);
assert_eq!(matches2.len(), 1);
assert_eq!(matches2[0].1.rule_name, "apt-iac");
let guidance2 = render_template(
&matches2[0].0.rule.guidance,
&event2,
&matches2[0].1.captures,
);
assert_eq!(guidance2, "Package nginx installed. Add to IaC.");
let event3 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/src/handler.go", "old_string": "x", "new_string": "y"}),
};
let matches3 = match_rules(&event3, &compiled);
assert_eq!(matches3.len(), 1);
assert_eq!(matches3[0].1.rule_name, "coupled-files");
let guidance3 = render_template(
&matches3[0].0.rule.guidance,
&event3,
&matches3[0].1.captures,
);
assert_eq!(guidance3, "Review coupled files for handler");
let event4 = ToolEvent {
tool_name: "Read".into(),
tool_input: json!({"file_path": "/tmp/test"}),
};
let matches4 = match_rules(&event4, &compiled);
assert!(matches4.is_empty());
}
#[test]
fn test_wildcard_all_tools() {
let rules = vec![make_rule("catch-all", "*", HashMap::new())];
let event = ToolEvent {
tool_name: "AnythingAtAll".into(),
tool_input: json!({}),
};
let matches = match_rules(&event, &rules);
assert_eq!(matches.len(), 1);
}
#[test]
fn test_serialize_roundtrip() {
let config = ReactionConfig {
reactions: vec![ReactionRule {
name: "test".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Test guidance".into(),
search_query: "test query".into(),
search_group: "default".into(),
search_tags: vec!["auto:config".into()],
max_context_lines: 30,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
}],
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: ReactionConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.reactions.len(), 1);
assert_eq!(deserialized.reactions[0].name, "test");
assert_eq!(deserialized.reactions[0].max_context_lines, 30);
}
#[test]
fn test_dedup_key_same_rule_same_args() {
let rule = ReactionRule {
name: "test".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: String::new(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
let event1 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/a.rs"}),
};
let event2 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/a.rs"}),
};
let key1 = DedupTracker::make_key(&rule, &event1);
let key2 = DedupTracker::make_key(&rule, &event2);
assert_eq!(key1, key2);
}
#[test]
fn test_dedup_key_same_rule_different_args() {
let rule = ReactionRule {
name: "test".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: String::new(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
let event1 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/a.rs"}),
};
let event2 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/b.rs"}),
};
let key1 = DedupTracker::make_key(&rule, &event1);
let key2 = DedupTracker::make_key(&rule, &event2);
assert_ne!(key1, key2); }
#[test]
fn test_dedup_tracker_in_memory() {
let mut tracker = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let key = DedupKey {
rule_name: "test".into(),
args_hash: "abc123".into(),
};
assert!(!tracker.has_fired(&key));
tracker.record(key.clone());
assert!(tracker.has_fired(&key));
}
#[test]
fn test_dedup_tracker_persistence() {
let tmp = tempfile::tempdir().unwrap();
let session_dir = tmp
.path()
.join(".bobbin")
.join("session")
.join("test-session");
std::fs::create_dir_all(&session_dir).unwrap();
{
let mut tracker = DedupTracker::load(tmp.path(), "test-session");
let key = DedupKey {
rule_name: "rule1".into(),
args_hash: "hash1".into(),
};
assert!(!tracker.has_fired(&key));
tracker.record(key);
}
{
let tracker = DedupTracker::load(tmp.path(), "test-session");
let key = DedupKey {
rule_name: "rule1".into(),
args_hash: "hash1".into(),
};
assert!(tracker.has_fired(&key));
let key2 = DedupKey {
rule_name: "rule2".into(),
args_hash: "hash2".into(),
};
assert!(!tracker.has_fired(&key2));
}
}
#[test]
fn test_evaluate_reactions_basic() {
let rules = vec![make_rule("r1", "Edit", HashMap::new())];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result = evaluate_reactions(&event, &rules, &mut dedup, None, 100, "default");
assert_eq!(result.reactions_fired, 1);
assert_eq!(result.rules_fired, vec!["r1"]);
assert!(result
.output
.contains("=== Reaction: r1 [injection_id: inj-react-"));
assert!(result.output.contains("Guidance for r1"));
assert_eq!(result.injection_ids.len(), 1);
assert!(result.injection_ids[0].starts_with("inj-react-"));
}
#[test]
fn test_evaluate_reactions_dedup() {
let rules = vec![make_rule("r1", "Edit", HashMap::new())];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result1 = evaluate_reactions(&event, &rules, &mut dedup, None, 100, "default");
assert_eq!(result1.reactions_fired, 1);
let result2 = evaluate_reactions(&event, &rules, &mut dedup, None, 100, "default");
assert_eq!(result2.reactions_fired, 0);
assert_eq!(result2.rules_deduped, 1);
assert!(result2.output.is_empty());
}
#[test]
fn test_evaluate_reactions_dedup_different_args() {
let rules = vec![make_rule("r1", "Edit", HashMap::new())];
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let event1 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/a.rs"}),
};
let result1 = evaluate_reactions(&event1, &rules, &mut dedup, None, 100, "default");
assert_eq!(result1.reactions_fired, 1);
let event2 = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/b.rs"}),
};
let result2 = evaluate_reactions(&event2, &rules, &mut dedup, None, 100, "default");
assert_eq!(result2.reactions_fired, 1);
}
#[test]
fn test_evaluate_reactions_budget_limit() {
let rules: Vec<CompiledRule> = (0..5)
.map(|i| {
CompiledRule::compile(ReactionRule {
name: format!("rule{}", i),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Line1\nLine2\nLine3\nLine4\nLine5".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
})
.unwrap()
})
.collect();
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result = evaluate_reactions(&event, &rules, &mut dedup, None, 20, "default");
assert!(result.reactions_fired > 0);
assert!(result.reactions_fired < 5);
}
#[test]
fn test_evaluate_no_match() {
let rules = vec![make_rule("r1", "Edit", HashMap::new())];
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "ls"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result = evaluate_reactions(&event, &rules, &mut dedup, None, 100, "default");
assert_eq!(result.reactions_fired, 0);
assert!(result.output.is_empty());
}
#[test]
fn test_pending_searches() {
let rules: Vec<CompiledRule> = vec![
CompiledRule::compile(ReactionRule {
name: "search-rule".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Check related".into(),
search_query: "related to {file_stem}".into(),
search_group: "infra".into(),
search_tags: vec!["auto:config".into()],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
})
.unwrap(),
CompiledRule::compile(ReactionRule {
name: "coupling-rule".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Coupling check".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: true,
coupling_threshold: 0.3,
roles: vec![],
})
.unwrap(),
];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/main.tf"}),
};
let dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let searches = pending_searches(&event, &rules, &dedup, "default");
assert_eq!(searches.len(), 1); assert_eq!(searches[0].rule_name, "search-rule");
assert_eq!(searches[0].query, "related to main");
assert_eq!(searches[0].group, Some("infra".into()));
assert_eq!(searches[0].tags, vec!["auto:config"]);
}
#[test]
fn test_pending_searches_must_be_collected_before_evaluate() {
let rules: Vec<CompiledRule> = vec![CompiledRule::compile(ReactionRule {
name: "search-rule".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Check related".into(),
search_query: "related to {file_stem}".into(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
})
.unwrap()];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/main.tf"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let before = pending_searches(&event, &rules, &dedup, "default");
let eval = evaluate_reactions(&event, &rules, &mut dedup, None, 100, "default");
assert_eq!(eval.reactions_fired, 1);
assert_eq!(before.len(), 1, "search must be visible before evaluation");
let after = pending_searches(&event, &rules, &dedup, "default");
assert!(
after.is_empty(),
"evaluate_reactions marks the rule fired, so a later collection is empty"
);
}
#[test]
fn test_format_search_results_lists_hits() {
let search = PendingSearch {
rule_name: "r".into(),
query: "terraform container web".into(),
group: None,
tags: vec![],
max_lines: 50,
};
let out = format_search_results(
&search,
&[
SearchHit {
path: "infra/web.tf".into(),
score: 0.91,
},
SearchHit {
path: "infra/net.tf".into(),
score: 0.42,
},
],
);
assert!(out.contains("Indexed files (2 results)"), "{out}");
assert!(out.contains("infra/web.tf (score: 0.91)"), "{out}");
assert!(!out.contains("WARNING"), "{out}");
}
#[test]
fn test_format_search_results_warns_on_no_hits() {
let search = PendingSearch {
rule_name: "r".into(),
query: "terraform container web".into(),
group: Some("goldblum".into()),
tags: vec!["auto:config".into()],
max_lines: 50,
};
let out = format_search_results(&search, &[]);
assert!(out.contains("No indexed files found"), "{out}");
assert!(out.contains("WARNING"), "{out}");
assert!(out.contains("group `goldblum`"), "{out}");
assert!(out.contains("auto:config"), "{out}");
}
#[test]
fn test_builtin_rules_compile() {
let builtins = builtin_rules();
assert!(builtins.len() >= 5);
for rule in builtins {
assert!(
CompiledRule::compile(rule.clone()).is_ok(),
"Built-in rule '{}' failed to compile",
rule.name,
);
}
}
#[test]
fn test_builtin_iac_drift_matches_batch_probe() {
let builtins: Vec<CompiledRule> = builtin_rules()
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event = ToolEvent {
tool_name: "mcp__homelab__batch_probe".into(),
tool_input: json!({"container": "monitoring", "command": "uptime"}),
};
let matches = match_rules(&event, &builtins);
let names: Vec<&str> = matches.iter().map(|(_, m)| m.rule_name.as_str()).collect();
assert!(names.contains(&"iac-drift-check"));
}
#[test]
fn test_builtin_service_restart_matches() {
let builtins: Vec<CompiledRule> = builtin_rules()
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event = ToolEvent {
tool_name: "mcp__homelab__service_restart".into(),
tool_input: json!({"container": "automation", "service": "forgejo-runner"}),
};
let matches = match_rules(&event, &builtins);
let names: Vec<&str> = matches.iter().map(|(_, m)| m.rule_name.as_str()).collect();
assert!(names.contains(&"service-known-issues"));
}
#[test]
fn test_builtin_apt_install_matches() {
let builtins: Vec<CompiledRule> = builtin_rules()
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event = ToolEvent {
tool_name: "Bash".into(),
tool_input: json!({"command": "apt install nginx"}),
};
let matches = match_rules(&event, &builtins);
let names: Vec<&str> = matches.iter().map(|(_, m)| m.rule_name.as_str()).collect();
assert!(names.contains(&"package-iac-declaration"));
let apt_match = matches
.iter()
.find(|(_, m)| m.rule_name == "package-iac-declaration")
.unwrap();
assert_eq!(
apt_match.1.captures.get("matched.package").unwrap(),
"nginx"
);
}
#[test]
fn test_builtin_terraform_edit_matches() {
let builtins: Vec<CompiledRule> = builtin_rules()
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/goldblum/main.tf", "old_string": "x", "new_string": "y"}),
};
let matches = match_rules(&event, &builtins);
let names: Vec<&str> = matches.iter().map(|(_, m)| m.rule_name.as_str()).collect();
assert!(names.contains(&"terraform-plan-check"));
assert!(names.contains(&"coupled-files")); }
#[test]
fn test_builtin_coupled_files_matches_any_edit() {
let builtins: Vec<CompiledRule> = builtin_rules()
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/home/user/src/main.rs", "old_string": "x", "new_string": "y"}),
};
let matches = match_rules(&event, &builtins);
let names: Vec<&str> = matches.iter().map(|(_, m)| m.rule_name.as_str()).collect();
assert!(names.contains(&"coupled-files"));
assert!(!names.contains(&"terraform-plan-check"));
}
#[test]
fn test_builtin_no_match_for_read() {
let builtins: Vec<CompiledRule> = builtin_rules()
.into_iter()
.map(|r| CompiledRule::compile(r).unwrap())
.collect();
let event = ToolEvent {
tool_name: "Read".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let matches = match_rules(&event, &builtins);
assert!(matches.is_empty()); }
#[test]
fn test_with_builtins_merges() {
let config = ReactionConfig {
reactions: vec![ReactionRule {
name: "custom-rule".into(),
tool: "Write".into(),
match_conditions: HashMap::new(),
guidance: "Custom guidance".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
}],
};
let merged = config.with_builtins();
assert!(merged.reactions.len() > 5);
let names: Vec<&str> = merged.reactions.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"iac-drift-check"));
assert!(names.contains(&"custom-rule"));
}
#[test]
fn test_with_builtins_user_overrides() {
let config = ReactionConfig {
reactions: vec![ReactionRule {
name: "coupled-files".into(), tool: "Write".into(), match_conditions: HashMap::new(),
guidance: "User override".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
}],
};
let merged = config.with_builtins();
let cf = merged
.reactions
.iter()
.find(|r| r.name == "coupled-files")
.unwrap();
assert_eq!(cf.tool, "Write"); assert_eq!(cf.guidance, "User override");
assert_eq!(
merged
.reactions
.iter()
.filter(|r| r.name == "coupled-files")
.count(),
1
);
}
#[test]
fn test_injection_id_format() {
let id = generate_injection_id("test-rule");
assert!(id.starts_with("inj-react-"));
assert_eq!(id.len(), "inj-react-".len() + 8); }
#[test]
fn test_injection_ids_unique() {
let id1 = generate_injection_id("rule1");
let id2 = generate_injection_id("rule1");
assert!(id1.starts_with("inj-react-"));
assert!(id2.starts_with("inj-react-"));
}
#[test]
fn test_evaluation_returns_injection_ids() {
let rules = vec![
make_rule("r1", "Edit", HashMap::new()),
make_rule("r2", "Edit", HashMap::new()),
];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result = evaluate_reactions(&event, &rules, &mut dedup, None, 200, "default");
assert_eq!(result.reactions_fired, 2);
assert_eq!(result.injection_ids.len(), 2);
for id in &result.injection_ids {
assert!(id.starts_with("inj-react-"));
}
for id in &result.injection_ids {
assert!(
result.output.contains(id),
"Output should contain injection_id {}",
id
);
}
}
#[test]
fn test_format_reaction_includes_injection_id() {
let rule = ReactionRule {
name: "test-feedback".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Test guidance for feedback".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
};
let (output, injection_id) = format_reaction(&rule, "Test guidance for feedback", None);
assert!(output.contains(&format!("[injection_id: {}]", injection_id)));
assert!(output.contains("inj-react-"));
}
#[test]
fn test_role_matches_empty_allows_all() {
assert!(role_matches(&[], "anything"));
assert!(role_matches(&[], "aegis/crew/malcolm"));
assert!(role_matches(&[], "default"));
}
#[test]
fn test_role_matches_exact() {
let patterns = vec!["aegis/crew/malcolm".into()];
assert!(role_matches(&patterns, "aegis/crew/malcolm"));
assert!(!role_matches(&patterns, "aegis/crew/ellie"));
}
#[test]
fn test_role_matches_glob() {
let patterns = vec!["aegis/crew/*".into()];
assert!(role_matches(&patterns, "aegis/crew/malcolm"));
assert!(role_matches(&patterns, "aegis/crew/ellie"));
assert!(!role_matches(&patterns, "aegis/polecats/rust"));
assert!(!role_matches(&patterns, "goldblum/crew/planner"));
}
#[test]
fn test_role_matches_multiple_patterns() {
let patterns = vec!["aegis/crew/*".into(), "aegis/polecats/*".into()];
assert!(role_matches(&patterns, "aegis/crew/malcolm"));
assert!(role_matches(&patterns, "aegis/polecats/rust"));
assert!(!role_matches(&patterns, "goldblum/crew/planner"));
}
#[test]
fn test_evaluate_reactions_role_filtering() {
let rules = vec![CompiledRule::compile(ReactionRule {
name: "ops-only".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "Ops guidance".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec!["aegis/crew/*".into()],
})
.unwrap()];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result =
evaluate_reactions(&event, &rules, &mut dedup, None, 100, "aegis/crew/malcolm");
assert_eq!(result.reactions_fired, 1);
let mut dedup2 = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result2 = evaluate_reactions(
&event,
&rules,
&mut dedup2,
None,
100,
"goldblum/crew/planner",
);
assert_eq!(result2.reactions_fired, 0);
}
#[test]
fn test_evaluate_reactions_empty_roles_matches_all() {
let rules = vec![CompiledRule::compile(ReactionRule {
name: "r1".into(),
tool: "Edit".into(),
match_conditions: HashMap::new(),
guidance: "All agents".into(),
search_query: String::new(),
search_group: String::new(),
search_tags: vec![],
max_context_lines: 50,
use_coupling: false,
coupling_threshold: 0.3,
roles: vec![],
})
.unwrap()];
let event = ToolEvent {
tool_name: "Edit".into(),
tool_input: json!({"file_path": "/tmp/test.rs"}),
};
let mut dedup = DedupTracker {
fired: std::collections::HashSet::new(),
path: None,
};
let result = evaluate_reactions(&event, &rules, &mut dedup, None, 100, "any/role/at/all");
assert_eq!(result.reactions_fired, 1);
}
#[test]
fn test_parse_rule_with_roles() {
let toml = r#"
[[reactions]]
name = "ops-guard"
tool = "mcp__homelab__*"
guidance = "Ops guidance"
roles = ["aegis/crew/*", "aegis/polecats/*"]
"#;
let config = ReactionConfig::parse(toml).unwrap();
assert_eq!(
config.reactions[0].roles,
vec!["aegis/crew/*", "aegis/polecats/*"]
);
}
#[test]
fn test_parse_rule_without_roles_defaults_empty() {
let toml = r#"
[[reactions]]
name = "no-roles"
tool = "Edit"
guidance = "All agents"
"#;
let config = ReactionConfig::parse(toml).unwrap();
assert!(config.reactions[0].roles.is_empty());
}
#[test]
fn test_global_reactions_loading() {
let tmp = tempfile::tempdir().unwrap();
let bobbin_dir = tmp.path().join(".bobbin");
std::fs::create_dir_all(&bobbin_dir).unwrap();
let fake_config = tmp.path().join("fake_xdg");
std::fs::create_dir_all(&fake_config).unwrap();
std::env::set_var("XDG_CONFIG_HOME", &fake_config);
std::fs::write(
bobbin_dir.join("reactions.toml"),
r#"
[[reactions]]
name = "local-rule"
tool = "Bash"
guidance = "Local guidance"
"#,
)
.unwrap();
let config = ReactionConfig::load_for_repo(tmp.path());
std::env::remove_var("XDG_CONFIG_HOME");
assert_eq!(config.reactions.len(), 1);
assert_eq!(config.reactions[0].name, "local-rule");
}
}