1use crate::session::ClaudeSession;
2use crate::terminals;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum RuleAction {
6 Approve,
7 Deny,
8 Send,
9 Terminate,
10 Route {
12 target_pid: u32,
13 },
14 Spawn {
16 prompt: String,
17 cwd: String,
18 },
19 Delegate {
21 agent: String,
22 prompt: String,
23 },
24}
25
26impl RuleAction {
27 pub fn parse(s: &str) -> Option<Self> {
28 match s.to_lowercase().as_str() {
29 "approve" => Some(Self::Approve),
30 "deny" => Some(Self::Deny),
31 "send" => Some(Self::Send),
32 "terminate" | "kill" => Some(Self::Terminate),
33 _ => None,
34 }
35 }
36
37 #[allow(dead_code)]
38 pub fn label(&self) -> &'static str {
39 match self {
40 Self::Approve => "approve",
41 Self::Deny => "deny",
42 Self::Send => "send",
43 Self::Terminate => "terminate",
44 Self::Route { .. } => "route",
45 Self::Spawn { .. } => "spawn",
46 Self::Delegate { .. } => "delegate",
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct AutoRule {
53 pub name: String,
54 pub match_status: Vec<String>,
55 pub match_tool: Vec<String>,
56 pub match_command: Vec<String>,
57 pub match_project: Vec<String>,
58 pub match_cost_above: Option<f64>,
59 pub match_last_error: Option<bool>,
60 pub match_file_conflict: Option<bool>,
61 pub action: RuleAction,
62 pub message: Option<String>,
63}
64
65impl AutoRule {
66 pub fn new(name: String, action: RuleAction) -> Self {
67 Self {
68 name,
69 match_status: Vec::new(),
70 match_tool: Vec::new(),
71 match_command: Vec::new(),
72 match_project: Vec::new(),
73 match_cost_above: None,
74 match_last_error: None,
75 match_file_conflict: None,
76 action,
77 message: None,
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
84pub struct RuleMatch {
85 pub rule_name: String,
86 pub action: RuleAction,
87 pub message: Option<String>,
88}
89
90pub fn evaluate(rules: &[AutoRule], session: &ClaudeSession) -> Option<RuleMatch> {
93 let mut first_non_deny: Option<RuleMatch> = None;
94
95 for rule in rules {
96 if !matches_rule(rule, session) {
97 continue;
98 }
99
100 if rule.action == RuleAction::Deny {
101 return Some(RuleMatch {
102 rule_name: rule.name.clone(),
103 action: RuleAction::Deny,
104 message: rule.message.clone(),
105 });
106 }
107
108 if first_non_deny.is_none() {
109 first_non_deny = Some(RuleMatch {
110 rule_name: rule.name.clone(),
111 action: rule.action.clone(),
112 message: rule.message.clone(),
113 });
114 }
115 }
116
117 first_non_deny
118}
119
120fn matches_rule(rule: &AutoRule, session: &ClaudeSession) -> bool {
123 if !rule.match_status.is_empty() {
124 let status_str = session.status.to_string().to_lowercase();
125 let any_match = rule
126 .match_status
127 .iter()
128 .any(|s| status_str == s.to_lowercase());
129 if !any_match {
130 return false;
131 }
132 }
133
134 if !rule.match_tool.is_empty() {
135 let tool = match &session.pending_tool_name {
136 Some(t) => t.to_lowercase(),
137 None => return false,
138 };
139 let any_match = rule.match_tool.iter().any(|t| tool == t.to_lowercase());
140 if !any_match {
141 return false;
142 }
143 }
144
145 if !rule.match_command.is_empty() {
146 let cmd = match &session.pending_tool_input {
147 Some(c) => c.to_lowercase(),
148 None => return false,
149 };
150 let any_match = rule
151 .match_command
152 .iter()
153 .any(|pattern| cmd.contains(&pattern.to_lowercase()));
154 if !any_match {
155 return false;
156 }
157 }
158
159 if !rule.match_project.is_empty() {
160 let project = session.display_name().to_lowercase();
161 let any_match = rule
162 .match_project
163 .iter()
164 .any(|p| project.contains(&p.to_lowercase()));
165 if !any_match {
166 return false;
167 }
168 }
169
170 if let Some(threshold) = rule.match_cost_above {
171 if session.cost_usd <= threshold {
172 return false;
173 }
174 }
175
176 if let Some(expected) = rule.match_last_error {
177 if session.last_tool_error != expected {
178 return false;
179 }
180 }
181
182 if let Some(expected) = rule.match_file_conflict {
183 if session.has_file_conflict != expected {
184 return false;
185 }
186 }
187
188 true
189}
190
191pub fn execute(result: &RuleMatch, session: &ClaudeSession) -> Result<String, String> {
193 let name = session.display_name();
194 match result.action {
195 RuleAction::Approve => {
196 terminals::approve_session(session)?;
197 Ok(format!(
198 "Rule '{}': approved {} ({})",
199 result.rule_name,
200 name,
201 session.pending_tool_name.as_deref().unwrap_or("?")
202 ))
203 }
204 RuleAction::Deny => Ok(format!(
205 "Rule '{}': denied {} ({})",
206 result.rule_name,
207 name,
208 session.pending_tool_name.as_deref().unwrap_or("?")
209 )),
210 RuleAction::Send => {
211 let msg = result.message.as_deref().unwrap_or("continue");
212 terminals::send_input(session, msg)?;
213 Ok(format!(
214 "Rule '{}': sent \"{}\" to {}",
215 result.rule_name, msg, name
216 ))
217 }
218 RuleAction::Terminate => {
219 let pid = session.pid;
220 let output = std::process::Command::new("kill")
221 .arg(pid.to_string())
222 .output()
223 .map_err(|e| format!("kill failed: {e}"))?;
224 if output.status.success() {
225 Ok(format!("Rule '{}': terminated {}", result.rule_name, name))
226 } else {
227 Err(format!("Rule '{}': kill {} failed", result.rule_name, pid))
228 }
229 }
230 RuleAction::Route { .. } => {
231 Ok(format!(
233 "Rule '{}': route queued for {}",
234 result.rule_name, name
235 ))
236 }
237 RuleAction::Spawn {
238 ref prompt,
239 ref cwd,
240 } => match terminals::launch_session(cwd, Some(prompt), None) {
241 Ok(msg) => Ok(format!(
242 "Rule '{}': spawned new session for {} — {msg}",
243 result.rule_name, name
244 )),
245 Err(e) => Err(format!(
246 "Rule '{}': spawn failed for {}: {e}",
247 result.rule_name, name
248 )),
249 },
250 RuleAction::Delegate { ref agent, .. } => {
251 Ok(format!(
254 "Rule '{}': delegated to agent '{}' for {}",
255 result.rule_name, agent, name
256 ))
257 }
258 }
259}
260
261pub fn execute_route(
263 source: &ClaudeSession,
264 target: &ClaudeSession,
265 summary: &str,
266 rule_name: &str,
267) -> Result<String, String> {
268 let msg = format!("[From {}] {}", source.display_name(), summary);
269 terminals::send_input(target, &msg)?;
270 Ok(format!(
271 "Rule '{}': routed summary from {} → {}",
272 rule_name,
273 source.display_name(),
274 target.display_name(),
275 ))
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::session::{ClaudeSession, RawSession, SessionStatus, TelemetryStatus};
282
283 fn make_session() -> ClaudeSession {
284 let raw = RawSession {
285 pid: 100,
286 session_id: "test".into(),
287 cwd: "/tmp/my-project".into(),
288 started_at: 0,
289 };
290 let mut s = ClaudeSession::from_raw(raw);
291 s.status = SessionStatus::NeedsInput;
292 s.telemetry_status = TelemetryStatus::Available;
293 s.pending_tool_name = Some("Bash".into());
294 s.pending_tool_input = Some("cargo test".into());
295 s.cost_usd = 5.0;
296 s
297 }
298
299 fn approve_rule(name: &str) -> AutoRule {
300 AutoRule::new(name.into(), RuleAction::Approve)
301 }
302
303 fn deny_rule(name: &str) -> AutoRule {
304 AutoRule::new(name.into(), RuleAction::Deny)
305 }
306
307 #[test]
308 fn no_rules_returns_none() {
309 let s = make_session();
310 assert!(evaluate(&[], &s).is_none());
311 }
312
313 #[test]
314 fn wildcard_rule_matches_any_session() {
315 let s = make_session();
316 let rules = vec![approve_rule("catch_all")];
317 let m = evaluate(&rules, &s).unwrap();
318 assert_eq!(m.action, RuleAction::Approve);
319 }
320
321 #[test]
322 fn match_status_filters() {
323 let mut s = make_session();
324 s.status = SessionStatus::WaitingInput;
325
326 let mut rule = approve_rule("only_needs_input");
327 rule.match_status = vec!["Needs Input".into()];
328
329 assert!(evaluate(&[rule.clone()], &s).is_none());
330
331 s.status = SessionStatus::NeedsInput;
332 assert!(evaluate(&[rule], &s).is_some());
333 }
334
335 #[test]
336 fn match_tool_filters() {
337 let s = make_session(); let mut rule = approve_rule("only_read");
340 rule.match_tool = vec!["Read".into()];
341 assert!(evaluate(&[rule], &s).is_none());
342
343 let mut rule2 = approve_rule("bash_ok");
344 rule2.match_tool = vec!["Bash".into()];
345 assert!(evaluate(&[rule2], &s).is_some());
346 }
347
348 #[test]
349 fn match_tool_case_insensitive() {
350 let s = make_session();
351
352 let mut rule = approve_rule("bash_lower");
353 rule.match_tool = vec!["bash".into()];
354 assert!(evaluate(&[rule], &s).is_some());
355 }
356
357 #[test]
358 fn match_command_substring() {
359 let s = make_session(); let mut rule = deny_rule("deny_rm");
362 rule.match_command = vec!["rm -rf".into()];
363 assert!(evaluate(&[rule], &s).is_none());
364
365 let mut rule2 = approve_rule("approve_cargo");
366 rule2.match_command = vec!["cargo".into()];
367 assert!(evaluate(&[rule2], &s).is_some());
368 }
369
370 #[test]
371 fn match_project_substring() {
372 let s = make_session(); let mut rule = approve_rule("my_proj");
375 rule.match_project = vec!["my-project".into()];
376 assert!(evaluate(&[rule], &s).is_some());
377
378 let mut rule2 = approve_rule("other");
379 rule2.match_project = vec!["other-project".into()];
380 assert!(evaluate(&[rule2], &s).is_none());
381 }
382
383 #[test]
384 fn match_cost_above() {
385 let s = make_session(); let mut rule = approve_rule("cheap");
388 rule.match_cost_above = Some(10.0);
389 assert!(evaluate(&[rule], &s).is_none());
390
391 let mut rule2 = approve_rule("expensive");
392 rule2.match_cost_above = Some(3.0);
393 assert!(evaluate(&[rule2], &s).is_some());
394 }
395
396 #[test]
397 fn match_last_error() {
398 let mut s = make_session();
399 s.last_tool_error = true;
400
401 let mut rule = approve_rule("on_error");
402 rule.match_last_error = Some(true);
403 assert!(evaluate(&[rule], &s).is_some());
404
405 let mut rule2 = approve_rule("no_error");
406 rule2.match_last_error = Some(false);
407 assert!(evaluate(&[rule2], &s).is_none());
408 }
409
410 #[test]
411 fn match_file_conflict() {
412 let mut s = make_session();
413 s.has_file_conflict = true;
414
415 let mut rule = deny_rule("deny_conflict");
416 rule.match_file_conflict = Some(true);
417 assert!(evaluate(&[rule], &s).is_some());
418
419 let mut rule2 = approve_rule("no_conflict");
420 rule2.match_file_conflict = Some(false);
421 assert!(evaluate(&[rule2], &s).is_none());
422 }
423
424 #[test]
425 fn match_file_conflict_false_matches_clean() {
426 let s = make_session(); let mut rule = approve_rule("clean");
429 rule.match_file_conflict = Some(false);
430 assert!(evaluate(&[rule], &s).is_some());
431 }
432
433 #[test]
434 fn deny_takes_precedence() {
435 let s = make_session();
436
437 let approve = approve_rule("approve_all");
438 let deny = deny_rule("deny_all");
439
440 let rules = vec![approve, deny];
442 let m = evaluate(&rules, &s).unwrap();
443 assert_eq!(m.action, RuleAction::Deny);
444 }
445
446 #[test]
447 fn first_non_deny_wins() {
448 let s = make_session();
449
450 let mut r1 = AutoRule::new("send_continue".into(), RuleAction::Send);
451 r1.message = Some("keep going".into());
452
453 let r2 = approve_rule("approve_all");
454
455 let rules = vec![r1, r2];
456 let m = evaluate(&rules, &s).unwrap();
457 assert_eq!(m.action, RuleAction::Send);
458 assert_eq!(m.message.as_deref(), Some("keep going"));
459 }
460
461 #[test]
462 fn multiple_conditions_are_and() {
463 let s = make_session(); let mut rule = approve_rule("bash_cargo_cheap");
466 rule.match_tool = vec!["Bash".into()];
467 rule.match_command = vec!["cargo".into()];
468 rule.match_cost_above = Some(10.0); assert!(evaluate(&[rule], &s).is_none());
470 }
471
472 #[test]
473 fn no_pending_tool_fails_tool_match() {
474 let mut s = make_session();
475 s.pending_tool_name = None;
476
477 let mut rule = approve_rule("bash");
478 rule.match_tool = vec!["Bash".into()];
479 assert!(evaluate(&[rule], &s).is_none());
480 }
481}