Skip to main content

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}