par_term_config/automation.rs
1//! Configuration types for triggers, coprocesses, and trigger security.
2
3use serde::{Deserialize, Serialize};
4
5/// A trigger definition that matches terminal output and fires actions.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct TriggerConfig {
8 pub name: String,
9 pub pattern: String,
10 #[serde(default = "crate::defaults::bool_true")]
11 pub enabled: bool,
12 #[serde(default)]
13 pub actions: Vec<TriggerActionConfig>,
14 /// When true (default), dangerous actions show a confirmation dialog before executing.
15 /// When false, they execute automatically (with rate-limit + denylist guards still applied).
16 ///
17 /// Previously named `require_user_action`. The old name is accepted as an alias for
18 /// backward compatibility with existing config files.
19 ///
20 /// # SECURITY WARNING
21 ///
22 /// Setting `prompt_before_run: false` is a **high-risk configuration** that allows
23 /// terminal output — including output produced by commands you run in the terminal —
24 /// to trigger `RunCommand`, `SendText`, and `SplitPane` actions **without any user
25 /// confirmation**. This is effectively a terminal-output-to-command-execution path.
26 ///
27 /// Known risks with `prompt_before_run: false`:
28 ///
29 /// - **Prompt injection**: a web page, log file, or remote server response printed
30 /// to the terminal could contain text that matches your trigger pattern and causes
31 /// arbitrary commands to run.
32 /// - **Denylist bypass**: the command denylist is a best-effort heuristic, not a
33 /// security boundary. Encoded or obfuscated payloads can bypass it.
34 /// - **No audit trail**: commands execute silently; there is no confirmation step
35 /// that would give you a chance to notice unexpected execution.
36 ///
37 /// **This field MUST remain `true` (the default) unless you have a specific,
38 /// well-understood automation need and have set `i_accept_the_risk: true` to
39 /// explicitly acknowledge the above risks.**
40 #[serde(default = "crate::defaults::bool_true", alias = "require_user_action")]
41 pub prompt_before_run: bool,
42 /// Explicit opt-in required when `prompt_before_run: false` is set for any trigger
43 /// that contains dangerous actions (`RunCommand`, `SendText`, `SplitPane`).
44 ///
45 /// When `prompt_before_run: false`, the denylist is the only automated protection
46 /// against malicious terminal output triggering arbitrary command execution.
47 /// Setting this to `true` signals that you have reviewed and accepted this risk.
48 ///
49 /// If `prompt_before_run: false` AND this is `false` (default), the trigger will
50 /// not execute and a warning will be logged.
51 #[serde(default)]
52 pub i_accept_the_risk: bool,
53 /// SEC-002: Optional allowlist of commands that this trigger is permitted to
54 /// execute via `RunCommand` actions.
55 ///
56 /// When `allowed_commands` is set (non-empty), **only** commands whose binary
57 /// name or canonical path matches an entry in this list are allowed. This is
58 /// more secure than the denylist because it defaults to deny-everything and
59 /// only allows explicitly approved commands.
60 ///
61 /// When `allowed_commands` is empty or absent (the default), the existing
62 /// denylist behavior continues — all commands are allowed unless they match
63 /// a denylist pattern.
64 ///
65 /// # Matching rules
66 ///
67 /// - Entries are matched against the command binary name (the first token)
68 /// using case-insensitive substring matching.
69 /// - For exact matching, include the full command name (e.g., `"git"`,
70 /// `"docker"`, `"npm"`).
71 /// - Path-based matching is also supported (e.g., `"/usr/bin/git"`).
72 ///
73 /// # Example
74 ///
75 /// ```yaml
76 /// triggers:
77 /// - name: deploy
78 /// pattern: "deploy:\\s*(\\S+)"
79 /// prompt_before_run: false
80 /// i_accept_the_risk: true
81 /// allowed_commands:
82 /// - git
83 /// - docker
84 /// - kubectl
85 /// actions:
86 /// - type: run_command
87 /// command: "/usr/local/bin/deploy"
88 /// ```
89 #[serde(default)]
90 pub allowed_commands: Vec<String>,
91}
92
93/// An action fired when a trigger pattern matches terminal output.
94///
95/// Each variant represents a different type of response to matched output,
96/// from visual highlighting to command execution. Actions marked as "dangerous"
97/// require explicit user confirmation unless `prompt_before_run: false` is set.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99#[serde(tag = "type", rename_all = "snake_case")]
100pub enum TriggerActionConfig {
101 /// Highlight matched text with a temporary color overlay.
102 Highlight {
103 #[serde(default)]
104 fg: Option<[u8; 3]>,
105 #[serde(default)]
106 bg: Option<[u8; 3]>,
107 #[serde(default = "default_highlight_duration")]
108 duration_ms: u64,
109 },
110 /// Show a desktop notification with a title and message.
111 Notify { title: String, message: String },
112 /// Mark the line containing the match with a colored indicator in the scrollback.
113 MarkLine {
114 #[serde(default)]
115 label: Option<String>,
116 #[serde(default)]
117 color: Option<[u8; 3]>,
118 },
119 /// Set an internal variable that can be referenced by other triggers or snippets.
120 SetVariable { name: String, value: String },
121 /// Run an external command (dangerous when `prompt_before_run: false`).
122 RunCommand {
123 command: String,
124 #[serde(default)]
125 args: Vec<String>,
126 },
127 /// Play an alert sound.
128 PlaySound {
129 #[serde(default)]
130 sound_id: String,
131 #[serde(default = "default_volume")]
132 volume: u8,
133 },
134 /// Send text to the terminal as if typed by the user (dangerous when `prompt_before_run: false`).
135 SendText {
136 text: String,
137 #[serde(default)]
138 delay_ms: u64,
139 },
140 /// Open a new pane (horizontal or vertical split) and optionally run a command in it.
141 SplitPane {
142 direction: TriggerSplitDirection,
143 #[serde(default)]
144 command: Option<SplitPaneCommand>,
145 #[serde(default = "crate::defaults::bool_true")]
146 focus_new_pane: bool,
147 #[serde(default)]
148 target: TriggerSplitTarget,
149 /// Percent of the current pane that the existing pane retains after the split.
150 /// Range 10–90. Default: 66 (existing pane keeps 66%, new pane gets 34%).
151 #[serde(default = "default_split_percent")]
152 split_percent: u8,
153 },
154}
155
156/// Split orientation for a new pane created by a trigger action.
157///
158/// - `Horizontal` creates a new pane below the existing one (stacked vertically).
159/// - `Vertical` creates a new pane to the right (side by side).
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161#[serde(rename_all = "snake_case")]
162pub enum TriggerSplitDirection {
163 /// New pane below (panes stacked vertically).
164 Horizontal,
165 /// New pane to the right (side by side).
166 Vertical,
167}
168
169/// Which pane to split when a `SplitPane` trigger fires.
170#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
171#[serde(rename_all = "snake_case")]
172pub enum TriggerSplitTarget {
173 /// Split the currently focused pane (default).
174 #[default]
175 Active,
176 /// Split the pane whose PTY output matched the trigger pattern.
177 Source,
178}
179
180/// How to run a command in a newly created split pane.
181///
182/// Two modes: send text to the shell (best-effort, shell must be running) or
183/// launch the pane with a specific command instead of the login shell.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[serde(tag = "type", rename_all = "snake_case")]
186pub enum SplitPaneCommand {
187 /// Send text to the shell with a trailing newline. Best-effort; shell must be running.
188 SendText {
189 text: String,
190 #[serde(default = "default_split_send_delay")]
191 delay_ms: u64,
192 },
193 /// Launch the pane with this command instead of the login shell.
194 InitialCommand {
195 command: String,
196 #[serde(default)]
197 args: Vec<String>,
198 },
199}
200
201fn default_split_send_delay() -> u64 {
202 200
203}
204
205fn default_split_percent() -> u8 {
206 66
207}
208
209/// Policy for restarting a coprocess when it exits
210#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum RestartPolicy {
213 /// Never restart (default)
214 #[default]
215 Never,
216 /// Always restart regardless of exit code
217 Always,
218 /// Restart only on non-zero exit code
219 OnFailure,
220}
221
222impl RestartPolicy {
223 /// All available restart policies for UI dropdowns
224 pub fn all() -> &'static [RestartPolicy] {
225 &[Self::Never, Self::Always, Self::OnFailure]
226 }
227
228 /// Human-readable display name
229 pub fn display_name(self) -> &'static str {
230 match self {
231 Self::Never => "Never",
232 Self::Always => "Always",
233 Self::OnFailure => "On Failure",
234 }
235 }
236}
237
238/// Configuration for a coprocess that runs alongside a terminal session.
239///
240/// Coprocesses receive terminal output via stdin and can send input back
241/// to the terminal. They are managed by the `CoprocessManager` in the
242/// core library and are started/stopped with the PTY session.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct CoprocessDefConfig {
245 /// Human-readable name for this coprocess.
246 pub name: String,
247 /// Command to execute (binary name or full path).
248 pub command: String,
249 #[serde(default)]
250 pub args: Vec<String>,
251 #[serde(default)]
252 pub auto_start: bool,
253 #[serde(default = "crate::defaults::bool_true")]
254 pub copy_terminal_output: bool,
255 #[serde(default)]
256 pub restart_policy: RestartPolicy,
257 #[serde(default)]
258 pub restart_delay_ms: u64,
259}
260
261fn default_highlight_duration() -> u64 {
262 5000
263}
264
265fn default_volume() -> u8 {
266 50
267}
268
269impl TriggerActionConfig {
270 /// Returns true if this action is considered dangerous when triggered by
271 /// passive terminal output (i.e., without explicit user interaction).
272 ///
273 /// Dangerous actions: `RunCommand`, `SendText`, `SplitPane`
274 /// Safe actions: `Highlight`, `Notify`, `MarkLine`, `SetVariable`, `PlaySound`
275 pub fn is_dangerous(&self) -> bool {
276 matches!(
277 self,
278 Self::RunCommand { .. } | Self::SendText { .. } | Self::SplitPane { .. }
279 )
280 }
281}
282
283// ============================================================================
284// Trigger Security — command denylist and rate limiting
285// ============================================================================
286
287/// Denied command patterns for `RunCommand` trigger actions.
288///
289/// These patterns are checked against the command string (command + args joined).
290/// A match means the command is blocked from execution.
291/// Simple substring-matched denied patterns.
292///
293/// # Known Bypass Vectors
294///
295/// This denylist uses **substring matching only** and is **not a security boundary**.
296/// It is a best-effort heuristic to catch obviously dangerous patterns, not a
297/// comprehensive security solution. Known bypass techniques include (but are not
298/// limited to):
299///
300/// - Encoding/obfuscation: `$'\x72\x6d' -rf /`
301/// - Variable indirection: `CMD=rm; $CMD -rf /`
302/// - Path variations: `/usr/bin/rm -rf /` vs `rm -rf /`
303/// - Argument reordering: `rm / -rf`
304///
305/// **The recommended security setting is `prompt_before_run: true` (the default).**
306/// The denylist is a secondary defense layer for triggers that opt in to
307/// `prompt_before_run: false`, not a substitute for user confirmation.
308const DENIED_COMMAND_PATTERNS: &[&str] = &[
309 // Destructive file operations
310 "rm -rf /",
311 "rm -rf ~",
312 "rm -rf .",
313 "mkfs.",
314 "dd if=",
315 // Shell evaluation
316 "eval ",
317 "exec ",
318 // Credential/key exfiltration
319 "ssh-add",
320 ".ssh/id_",
321 ".ssh/authorized_keys",
322 ".gnupg/",
323 // System manipulation
324 "chmod 777",
325 "chown root",
326 "passwd",
327 "sudoers",
328];
329
330/// Command wrapper prefixes that are used to bypass simple command-name checks.
331///
332/// Patterns like `/usr/bin/env rm -rf /` or `sh -c "rm -rf /"` can bypass a
333/// check that only looks at the first word of the command. These prefixes are
334/// detected in [`check_command_denylist`] so that the full reconstructed command
335/// string (after stripping the wrapper) is re-checked against
336/// [`DENIED_COMMAND_PATTERNS`].
337///
338/// # Limitations
339///
340/// Detecting wrappers via substring matching is still bypassable (e.g. through
341/// quoting, encoding, or unusual shell invocations). This is a best-effort
342/// heuristic only. **Use `prompt_before_run: true` for real protection.**
343const BYPASS_WRAPPER_PATTERNS: &[&str] = &[
344 "env ",
345 "/usr/bin/env ",
346 "/bin/env ",
347 "sh -c ",
348 "bash -c ",
349 "zsh -c ",
350 "fish -c ",
351 "dash -c ",
352 "ksh -c ",
353 "csh -c ",
354 "tcsh -c ",
355];
356
357/// Pipe-to-shell patterns checked with word-boundary awareness.
358/// These match `| bash`, `|bash`, `| sh`, `|sh` only when `bash`/`sh`
359/// appear as whole words (not as part of longer words like "polish").
360const PIPE_SHELL_TARGETS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
361
362/// Check if a command string matches any denied pattern.
363///
364/// The check is case-insensitive and looks for substring matches.
365/// Checks both the full joined command and each individual argument
366/// (since shell evaluation like `bash -c "curl ... | bash"` puts the
367/// dangerous content in a single arg).
368///
369/// # SECURITY WARNING
370///
371/// This function implements a **best-effort heuristic denylist**, not a security
372/// boundary. The following bypass techniques are known to exist and are **not**
373/// fully mitigated by this check:
374///
375/// - **Shell wrapper bypass**: `sh -c "rm -rf /"`, `bash -c "..."`, `zsh -c "..."`
376/// (partially mitigated by `BYPASS_WRAPPER_PATTERNS`)
377/// - **env wrapper bypass**: `/usr/bin/env rm -rf /` (partially mitigated)
378/// - **Encoding/obfuscation**: `$'\x72\x6d' -rf /` — the raw bytes bypass substring matching
379/// - **Variable indirection**: `CMD=rm; $CMD -rf /` — shell variables are opaque
380/// - **Path variations**: `/bin/rm -rf /` vs `rm -rf /` (full absolute paths may bypass)
381/// - **Argument reordering**: `rm / -rf` — patterns that depend on argument order
382/// - **Commands not on the list**: Anything not explicitly enumerated is allowed
383///
384/// **The recommended and default setting is `prompt_before_run: true`.**
385/// When `prompt_before_run` is `false`, the denylist is the only automated
386/// protection against malicious terminal output triggering dangerous commands.
387/// For any trigger that uses `prompt_before_run: false`, users should
388/// carefully audit the command and args to ensure they cannot be exploited.
389///
390/// # Why Not Shell Parsing?
391///
392/// A truly robust solution would require **full shell parsing** of the command string:
393/// expanding variables, resolving aliases, decoding escape sequences, and evaluating
394/// subshells before checking against any policy. Implementing a complete POSIX shell
395/// parser is a significant undertaking and would itself introduce a large attack surface.
396/// This function intentionally does not attempt shell parsing and instead relies on
397/// `prompt_before_run: true` as the primary security control. The denylist exists
398/// only as a best-effort secondary guard.
399///
400/// Returns `Some(pattern)` if denied, `None` if allowed.
401pub fn check_command_denylist(command: &str, args: &[String]) -> Option<&'static str> {
402 // Build full command string for pattern matching
403 let full_command = if args.is_empty() {
404 command.to_lowercase()
405 } else {
406 format!("{} {}", command, args.join(" ")).to_lowercase()
407 };
408
409 // Collect all strings to check: the full command and each individual arg.
410 // Individual arg checking catches `bash -c "curl ... | bash"` where the
411 // pipe-to-shell pattern is within a single argument.
412 let mut check_strings = vec![full_command.clone()];
413 for arg in args {
414 let lowered = arg.to_lowercase();
415 if !lowered.is_empty() {
416 check_strings.push(lowered);
417 }
418 }
419
420 // Check for bypass wrapper patterns (env, sh -c, bash -c, etc.).
421 // When a wrapper is detected, also add the de-wrapped remainder to the
422 // check list so that patterns like `/usr/bin/env rm -rf /` are caught
423 // by the standard DENIED_COMMAND_PATTERNS check below.
424 //
425 // NOTE: This is a best-effort mitigation only. Sufficiently obfuscated
426 // wrappers (e.g. using variable indirection, encoding, or unusual quoting)
427 // will still bypass this check. See the SECURITY WARNING on this function.
428 for wrapper in BYPASS_WRAPPER_PATTERNS {
429 let normalized_wrapper = wrapper.to_lowercase();
430 if full_command.starts_with(&normalized_wrapper) {
431 let remainder = full_command[normalized_wrapper.len()..].trim().to_string();
432 if !remainder.is_empty() {
433 check_strings.push(remainder);
434 }
435 // Also flag any `sh -c`, `bash -c`, etc. usage directly as denied,
436 // since shell invocation with `-c` allows arbitrary code execution
437 // and cannot be safely filtered by substring matching alone.
438 if normalized_wrapper.contains(" -c ") || normalized_wrapper.ends_with(" -c") {
439 return Some("shell -c wrapper");
440 }
441 }
442 // Also check each individual arg for wrapper patterns, since the
443 // wrapper might appear in an argument (e.g., `sudo bash -c "..."`)
444 for arg in args {
445 let lowered_arg = arg.to_lowercase();
446 if lowered_arg.starts_with(&normalized_wrapper)
447 && (normalized_wrapper.contains(" -c ") || normalized_wrapper.ends_with(" -c"))
448 {
449 return Some("shell -c wrapper");
450 }
451 }
452 }
453
454 // Check simple substring patterns
455 for pattern in DENIED_COMMAND_PATTERNS {
456 let normalized_pattern = pattern.to_lowercase();
457 for check_str in &check_strings {
458 if check_str.contains(&normalized_pattern) {
459 return Some(pattern);
460 }
461 }
462 }
463
464 // Check pipe-to-shell patterns with word boundary awareness.
465 // We look for `|<shell>` or `| <shell>` where <shell> is followed by
466 // end-of-string or a non-alphanumeric character (word boundary).
467 for check_str in &check_strings {
468 for &shell in PIPE_SHELL_TARGETS {
469 if check_pipe_to_shell(check_str, shell) {
470 // Return a static description (we can't construct dynamic strings here)
471 return match shell {
472 "bash" => Some("| bash"),
473 "sh" => Some("| sh"),
474 "zsh" => Some("| zsh"),
475 "fish" => Some("| fish"),
476 "dash" => Some("| dash"),
477 "ksh" => Some("| ksh"),
478 _ => Some("| <shell>"),
479 };
480 }
481 }
482 }
483
484 None
485}
486
487/// SEC-002: Check if a command is allowed by the optional allowlist.
488///
489/// When `allowed_commands` is non-empty, the command's binary name (first token)
490/// must match at least one entry in the allowlist. Matching is case-insensitive.
491///
492/// Returns `Ok(())` if the command is allowed. Returns `Err(allowlist_violation)`
493/// if the command is not on the allowlist.
494///
495/// When `allowed_commands` is empty, returns `Ok(())` (allowlist mode disabled;
496/// falls back to denylist-only checking).
497///
498/// # Matching behavior
499///
500/// Each entry in `allowed_commands` is compared against:
501/// 1. The `command` string directly (case-insensitive contains match on the
502/// command binary).
503/// 2. The basename of the command (for path-style commands like
504/// `/usr/bin/git`, the basename `git` is extracted and compared).
505///
506/// This means an allowlist entry of `"git"` will match both `git` and
507/// `/usr/bin/git`.
508///
509/// # Errors
510///
511/// Returns an error naming the command and the allowlist when `allowed_commands`
512/// is non-empty and no entry matches. An empty allowlist disables the check and
513/// always returns `Ok(())`.
514pub fn check_command_allowlist(command: &str, allowed_commands: &[String]) -> Result<(), String> {
515 if allowed_commands.is_empty() {
516 return Ok(());
517 }
518
519 let command_lower = command.to_lowercase();
520 let command_basename = std::path::Path::new(&command_lower)
521 .file_name()
522 .and_then(|n| n.to_str())
523 .unwrap_or(&command_lower)
524 .to_string();
525
526 for allowed in allowed_commands {
527 let allowed_lower = allowed.to_lowercase();
528 // Match if the allowed entry appears in the command basename or the full command
529 if command_basename == allowed_lower
530 || command_lower == allowed_lower
531 || command_lower.starts_with(&format!("{allowed_lower}/"))
532 || command_lower.starts_with(&format!("{allowed_lower} "))
533 {
534 return Ok(());
535 }
536 }
537
538 Err(format!(
539 "command '{}' not in allowed list: [{}]",
540 command,
541 allowed_commands.join(", ")
542 ))
543}
544
545/// Check if a string contains a pipe-to-shell pattern like `|bash` or `| sh`
546/// with word boundary awareness to avoid false positives (e.g., "polish").
547fn check_pipe_to_shell(s: &str, shell: &str) -> bool {
548 // Check both `|<shell>` and `| <shell>` patterns
549 for sep in &["|", "| "] {
550 let pattern = format!("{}{}", sep, shell);
551 if let Some(pos) = s.find(&pattern) {
552 let end_pos = pos + pattern.len();
553 // Check that `shell` is at a word boundary (end of string or followed by non-alphanumeric)
554 if end_pos >= s.len() || !s.as_bytes()[end_pos].is_ascii_alphanumeric() {
555 return true;
556 }
557 }
558 }
559 false
560}
561
562/// Emit a security warning when a trigger is configured with `prompt_before_run: false`.
563///
564/// Called during config load for any trigger with `prompt_before_run: false` that contains
565/// dangerous actions. With `prompt_before_run: false`, dangerous actions execute automatically
566/// without user confirmation; only the rate-limiter and denylist provide protection.
567///
568/// When `i_accept_the_risk` is `false`, the warning additionally notes that the trigger
569/// will be blocked until the explicit opt-in is added to the config.
570pub fn warn_prompt_before_run_false(trigger_name: &str, i_accept_the_risk: bool) {
571 if i_accept_the_risk {
572 eprintln!(
573 "[par-term SECURITY WARNING] Trigger '{trigger_name}' has `prompt_before_run: false`.\n\
574 This allows terminal output to directly trigger RunCommand/SendText/SplitPane actions\n\
575 without confirmation. The command denylist provides only limited protection.\n\
576 Only use this setting if you fully trust the configured commands and environment.\n\
577 Recommendation: set `prompt_before_run: true` (the default) to require confirmation."
578 );
579 } else {
580 eprintln!(
581 "[par-term SECURITY BLOCK] Trigger '{trigger_name}' has `prompt_before_run: false` \
582 but is missing `i_accept_the_risk: true`.\n\
583 Dangerous actions (RunCommand/SendText/SplitPane) will NOT execute for this trigger \
584 until you add `i_accept_the_risk: true` to acknowledge the risk.\n\
585 This opt-in is required when bypassing the confirmation dialog for dangerous actions.\n\
586 Recommendation: set `prompt_before_run: true` (the default) instead."
587 );
588 }
589}
590
591/// Rate limiter for output-triggered actions.
592///
593/// Tracks when actions last fired per trigger_id and enforces a minimum
594/// interval between firings to prevent malicious output flooding.
595pub struct TriggerRateLimiter {
596 /// Map of trigger_id -> last fire time
597 last_fire: std::collections::HashMap<u64, std::time::Instant>,
598 /// Minimum interval between trigger firings (in milliseconds)
599 min_interval_ms: u64,
600}
601
602/// Default minimum interval between output trigger firings (1 second).
603const DEFAULT_TRIGGER_RATE_LIMIT_MS: u64 = 1000;
604
605impl Default for TriggerRateLimiter {
606 fn default() -> Self {
607 Self {
608 last_fire: std::collections::HashMap::new(),
609 min_interval_ms: DEFAULT_TRIGGER_RATE_LIMIT_MS,
610 }
611 }
612}
613
614impl TriggerRateLimiter {
615 /// Create a new rate limiter with a custom minimum interval.
616 pub fn new(min_interval_ms: u64) -> Self {
617 Self {
618 last_fire: std::collections::HashMap::new(),
619 min_interval_ms,
620 }
621 }
622
623 /// Check if a trigger is allowed to fire. Returns true if allowed,
624 /// false if rate-limited. Updates the last fire time on success.
625 pub fn check_and_update(&mut self, trigger_id: u64) -> bool {
626 let now = std::time::Instant::now();
627 if let Some(last) = self.last_fire.get(&trigger_id) {
628 let elapsed = now.duration_since(*last).as_millis() as u64;
629 if elapsed < self.min_interval_ms {
630 return false;
631 }
632 }
633 self.last_fire.insert(trigger_id, now);
634 true
635 }
636
637 /// Remove stale entries for triggers that haven't fired recently.
638 /// Call periodically to prevent unbounded growth.
639 pub fn cleanup(&mut self, max_age_secs: u64) {
640 let now = std::time::Instant::now();
641 let max_age = std::time::Duration::from_secs(max_age_secs);
642 self.last_fire
643 .retain(|_, last| now.duration_since(*last) < max_age);
644 }
645}
646
647#[cfg(test)]
648mod split_pane_tests {
649 use super::*;
650
651 #[test]
652 fn test_split_pane_config_deserialize_send_text() {
653 let yaml = r#"
654type: split_pane
655direction: horizontal
656command:
657 type: send_text
658 text: "tail -f build.log"
659 delay_ms: 300
660focus_new_pane: true
661target: active
662"#;
663 let action: TriggerActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
664 assert!(matches!(action, TriggerActionConfig::SplitPane { .. }));
665 assert!(action.is_dangerous());
666 }
667
668 #[test]
669 fn test_split_pane_config_deserialize_initial_command() {
670 let yaml = r#"
671type: split_pane
672direction: vertical
673command:
674 type: initial_command
675 command: htop
676 args: []
677focus_new_pane: false
678target: source
679"#;
680 let action: TriggerActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
681 assert!(matches!(
682 action,
683 TriggerActionConfig::SplitPane {
684 direction: TriggerSplitDirection::Vertical,
685 focus_new_pane: false,
686 target: TriggerSplitTarget::Source,
687 ..
688 }
689 ));
690 }
691
692 #[test]
693 fn test_split_pane_defaults() {
694 let yaml = r#"
695type: split_pane
696direction: horizontal
697"#;
698 let action: TriggerActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
699 if let TriggerActionConfig::SplitPane {
700 command,
701 focus_new_pane,
702 target,
703 split_percent,
704 ..
705 } = action
706 {
707 assert!(command.is_none());
708 assert!(focus_new_pane); // defaults true
709 assert_eq!(target, TriggerSplitTarget::Active); // defaults Active
710 assert_eq!(split_percent, 66); // defaults 66%
711 } else {
712 panic!("wrong variant");
713 }
714 }
715
716 #[test]
717 fn test_send_text_default_delay() {
718 let yaml = r#"type: send_text
719text: "hello"
720"#;
721 let cmd: SplitPaneCommand = serde_yaml_ng::from_str(yaml).unwrap();
722 if let SplitPaneCommand::SendText { delay_ms, .. } = cmd {
723 assert_eq!(delay_ms, 200);
724 }
725 }
726}