Skip to main content

par_term_config/
snippets.rs

1//! Configuration types for snippets and custom actions.
2//!
3//! This module provides:
4//! - Snippet definitions with variable substitution
5//! - Custom action definitions (shell commands, text insertion, key sequences)
6//! - Built-in and custom variable support
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Default timeout for shell commands (30 seconds).
12const fn default_shell_command_timeout_secs() -> u64 {
13    30
14}
15
16/// Deadline for the `git` invocations backing the `GitBranch`/`GitCommit` variables.
17const GIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
18
19/// How often [`git_stdout`] re-checks whether `git` has exited.
20const GIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
21
22/// Run `git <args>` and return its trimmed stdout, killing it if it outlives
23/// [`GIT_TIMEOUT`].
24///
25/// Returns an empty string on spawn failure, non-zero exit, or timeout — matching
26/// how the caller already treats "not a repository". The deadline matters because
27/// `git rev-parse` is sub-millisecond against a healthy repository but unbounded
28/// against a network-mounted `.git` or a stale `index.lock`, and variable expansion
29/// runs on the caller's UI thread.
30fn git_stdout(args: &[&str]) -> String {
31    use std::io::Read;
32    use std::process::{Command, Stdio};
33    use std::time::Instant;
34
35    let mut child = match Command::new("git")
36        .args(args)
37        .stdin(Stdio::null())
38        .stdout(Stdio::piped())
39        .stderr(Stdio::null())
40        .spawn()
41    {
42        Ok(child) => child,
43        Err(e) => {
44            log::warn!("git {} failed to spawn: {e}", args.join(" "));
45            return String::new();
46        }
47    };
48
49    // Drain stdout on a helper thread: polling `try_wait` without reading would
50    // deadlock as soon as the child fills the pipe buffer.
51    let Some(mut stdout) = child.stdout.take() else {
52        return String::new();
53    };
54    let reader = std::thread::spawn(move || {
55        let mut buf = String::new();
56        let _ = stdout.read_to_string(&mut buf);
57        buf
58    });
59
60    let deadline = Instant::now() + GIT_TIMEOUT;
61    loop {
62        match child.try_wait() {
63            Ok(Some(status)) if status.success() => {
64                return reader.join().unwrap_or_default().trim().to_string();
65            }
66            Ok(Some(_)) => return String::new(),
67            Ok(None) => {
68                if Instant::now() >= deadline {
69                    log::warn!(
70                        "git {} exceeded {:.1}s, terminating",
71                        args.join(" "),
72                        GIT_TIMEOUT.as_secs_f64()
73                    );
74                    let _ = child.kill();
75                    let _ = child.wait();
76                    return String::new();
77                }
78                std::thread::sleep(GIT_POLL_INTERVAL);
79            }
80            Err(e) => {
81                log::warn!("git {} failed while waiting: {e}", args.join(" "));
82                return String::new();
83            }
84        }
85    }
86}
87
88/// A text snippet that can be inserted into the terminal.
89///
90/// Snippets support variable substitution using \(variable\) syntax.
91/// Example: "echo 'Today is \(date)'" will replace \(date) with the current date.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct SnippetConfig {
94    /// Unique identifier for the snippet
95    pub id: String,
96
97    /// Human-readable title for the snippet
98    pub title: String,
99
100    /// The text content to insert (may contain variables)
101    pub content: String,
102
103    /// Optional keyboard shortcut to trigger the snippet (e.g., "Ctrl+Shift+D")
104    #[serde(default)]
105    pub keybinding: Option<String>,
106
107    /// Whether the keybinding is enabled (default: true)
108    /// If false, the keybinding won't be registered even if keybinding is set
109    #[serde(default = "crate::defaults::bool_true")]
110    pub keybinding_enabled: bool,
111
112    /// Optional folder/collection for organization (e.g., "Git", "Docker")
113    #[serde(default)]
114    pub folder: Option<String>,
115
116    /// Whether this snippet is enabled
117    #[serde(default = "crate::defaults::bool_true")]
118    pub enabled: bool,
119
120    /// Optional description of what the snippet does
121    #[serde(default)]
122    pub description: Option<String>,
123
124    /// Whether to automatically send Enter after inserting the snippet (default: false)
125    /// If true, a newline character is appended to execute the command immediately
126    #[serde(default)]
127    pub auto_execute: bool,
128
129    /// Custom variables defined for this snippet
130    #[serde(default)]
131    pub variables: HashMap<String, String>,
132}
133
134impl SnippetConfig {
135    /// Create a new snippet with the given ID and title.
136    pub fn new(id: String, title: String, content: String) -> Self {
137        Self {
138            id,
139            title,
140            content,
141            keybinding: None,
142            keybinding_enabled: true,
143            folder: None,
144            enabled: true,
145            description: None,
146            auto_execute: false,
147            variables: HashMap::new(),
148        }
149    }
150
151    /// Add a keybinding to the snippet.
152    pub fn with_keybinding(mut self, keybinding: String) -> Self {
153        self.keybinding = Some(keybinding);
154        self
155    }
156
157    /// Disable the keybinding for this snippet.
158    pub fn with_keybinding_disabled(mut self) -> Self {
159        self.keybinding_enabled = false;
160        self
161    }
162
163    /// Add a folder to the snippet.
164    pub fn with_folder(mut self, folder: String) -> Self {
165        self.folder = Some(folder);
166        self
167    }
168
169    /// Add a custom variable to the snippet.
170    pub fn with_variable(mut self, name: String, value: String) -> Self {
171        self.variables.insert(name, value);
172        self
173    }
174
175    /// Enable auto-execute (send Enter after inserting the snippet).
176    pub fn with_auto_execute(mut self) -> Self {
177        self.auto_execute = true;
178        self
179    }
180}
181
182/// A portable snippet library for import/export.
183///
184/// Wraps a list of snippets for serialization to/from YAML files.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SnippetLibrary {
187    /// The snippets in this library
188    pub snippets: Vec<SnippetConfig>,
189}
190
191/// Default delay in ms before sending text to a newly split pane.
192const fn default_split_pane_delay_ms() -> u64 {
193    200
194}
195
196/// Normalize an action prefix character for matching and conflict detection.
197///
198/// ASCII letters are matched case-insensitively; all other characters remain exact.
199pub fn normalize_action_prefix_char(ch: char) -> char {
200    if ch.is_ascii_alphabetic() {
201        ch.to_ascii_lowercase()
202    } else {
203        ch
204    }
205}
206
207/// Default split percent: existing pane keeps 66% of the space.
208const fn default_split_percent() -> u8 {
209    66
210}
211
212/// The six fields that are identical across every [`CustomActionConfig`] variant.
213///
214/// # Note on `#[serde(flatten)]`
215///
216/// Serde does not support combining `#[serde(tag = "type")]` (internally tagged enum)
217/// with `#[serde(flatten)]` on a variant field — the combination silently produces
218/// incorrect output or a runtime error depending on the format. Therefore `ActionBase`
219/// is **not** used as a flattened serde field inside the enum variants; the six fields
220/// remain individually declared in each variant to preserve existing YAML compatibility.
221///
222/// `ActionBase` is used purely as a value-transfer helper in [`CustomActionConfig::base`]
223/// and [`CustomActionConfig::apply_base`], which together eliminate the eight-arm match
224/// repetition in every mutator method.
225#[derive(Debug, Clone, PartialEq, Default)]
226pub struct ActionBase {
227    /// Action identifier (for keybinding reference).
228    pub id: String,
229    /// Human-readable title.
230    pub title: String,
231    /// Optional keyboard shortcut.
232    pub keybinding: Option<String>,
233    /// Optional single character triggered after the global prefix key.
234    pub prefix_char: Option<char>,
235    /// Whether the keybinding is active (default: `true`).
236    pub keybinding_enabled: bool,
237    /// Optional human-readable description.
238    pub description: Option<String>,
239}
240
241impl ActionBase {
242    /// Create a minimal base with just an id and title.
243    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
244        Self {
245            id: id.into(),
246            title: title.into(),
247            keybinding: None,
248            prefix_char: None,
249            keybinding_enabled: true,
250            description: None,
251        }
252    }
253}
254
255/// Split direction for a custom action pane split.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
257#[serde(rename_all = "snake_case")]
258pub enum ActionSplitDirection {
259    /// New pane below (panes stacked top/bottom)
260    #[default]
261    Horizontal,
262    /// New pane to the right (side by side)
263    Vertical,
264}
265
266impl ActionSplitDirection {
267    /// All directions for UI dropdowns.
268    pub fn all() -> &'static [ActionSplitDirection] {
269        &[Self::Horizontal, Self::Vertical]
270    }
271
272    /// Human-readable label.
273    pub fn label(self) -> &'static str {
274        match self {
275            Self::Horizontal => "Horizontal (below)",
276            Self::Vertical => "Vertical (right)",
277        }
278    }
279}
280
281/// What to do when a sequence step "fails".
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
283#[serde(rename_all = "snake_case")]
284pub enum SequenceStepBehavior {
285    /// Halt sequence and show error toast (default).
286    #[default]
287    Abort,
288    /// Halt sequence silently.
289    Stop,
290    /// Ignore failure and continue to the next step.
291    Continue,
292}
293
294/// A single step in a Sequence action.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct SequenceStep {
297    /// ID of the action to execute.
298    pub action_id: String,
299    /// Delay in milliseconds before this step runs (default: 0).
300    #[serde(default)]
301    pub delay_ms: u64,
302    /// What to do if this step fails (default: Abort).
303    #[serde(default)]
304    pub on_failure: SequenceStepBehavior,
305}
306
307/// Condition to check for a Condition action.
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309#[serde(tag = "kind", rename_all = "snake_case")]
310pub enum ConditionCheck {
311    /// Check the exit code of the last captured ShellCommand.
312    ExitCode { value: i32 },
313    /// Check whether the last captured output contains a pattern.
314    OutputContains {
315        pattern: String,
316        #[serde(default)]
317        case_sensitive: bool,
318    },
319    /// Check an environment variable (None value = existence check only).
320    EnvVar {
321        name: String,
322        #[serde(default)]
323        value: Option<String>,
324    },
325    /// Glob match on the current terminal CWD.
326    DirMatches { pattern: String },
327    /// Glob match on the current git branch.
328    GitBranch { pattern: String },
329}
330
331/// A custom action that can be triggered via keybinding.
332///
333/// Actions can execute shell commands, open a new tab, insert text, simulate key
334/// sequences, or split the active pane.
335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
336#[serde(tag = "type", rename_all = "snake_case")]
337pub enum CustomActionConfig {
338    /// Execute a shell command
339    ShellCommand {
340        /// Action identifier (for keybinding reference)
341        id: String,
342
343        /// Human-readable title
344        title: String,
345
346        /// Command to execute (e.g., "git", "npm")
347        command: String,
348
349        /// Command arguments (e.g., ["status", "--short"])
350        #[serde(default)]
351        args: Vec<String>,
352
353        /// Whether to show command output in a notification
354        #[serde(default)]
355        notify_on_success: bool,
356
357        /// Timeout in seconds for the command (default: 30)
358        #[serde(default = "default_shell_command_timeout_secs")]
359        timeout_secs: u64,
360
361        /// Capture stdout+stderr into WorkflowContext for use by Sequence/Condition actions.
362        /// When true, output is capped at 64 KB. Default: false.
363        #[serde(default)]
364        capture_output: bool,
365
366        /// Optional keyboard shortcut to trigger the action (e.g., "Ctrl+Shift+R")
367        #[serde(default)]
368        keybinding: Option<String>,
369
370        /// Optional single character triggered after the global custom action prefix key.
371        #[serde(default)]
372        prefix_char: Option<char>,
373
374        /// Whether the keybinding is enabled (default: true)
375        #[serde(default = "crate::defaults::bool_true")]
376        keybinding_enabled: bool,
377
378        /// Optional description
379        #[serde(default)]
380        description: Option<String>,
381    },
382
383    /// Open a new tab and optionally run a command in its shell
384    NewTab {
385        /// Action identifier
386        id: String,
387
388        /// Human-readable title
389        title: String,
390
391        /// Optional command to send to the new tab's shell after it opens
392        #[serde(default)]
393        command: Option<String>,
394
395        /// Optional keyboard shortcut to trigger the action
396        #[serde(default)]
397        keybinding: Option<String>,
398
399        /// Optional single character triggered after the global custom action prefix key.
400        #[serde(default)]
401        prefix_char: Option<char>,
402
403        /// Whether the keybinding is enabled (default: true)
404        #[serde(default = "crate::defaults::bool_true")]
405        keybinding_enabled: bool,
406
407        /// Optional description
408        #[serde(default)]
409        description: Option<String>,
410    },
411
412    /// Insert text into the terminal (like a snippet but no editing UI)
413    InsertText {
414        /// Action identifier
415        id: String,
416
417        /// Human-readable title
418        title: String,
419
420        /// Text to insert (supports variable substitution)
421        text: String,
422
423        /// Custom variables for substitution
424        #[serde(default)]
425        variables: HashMap<String, String>,
426
427        /// Optional keyboard shortcut to trigger the action
428        #[serde(default)]
429        keybinding: Option<String>,
430
431        /// Optional single character triggered after the global custom action prefix key.
432        #[serde(default)]
433        prefix_char: Option<char>,
434
435        /// Whether the keybinding is enabled (default: true)
436        #[serde(default = "crate::defaults::bool_true")]
437        keybinding_enabled: bool,
438
439        /// Optional description
440        #[serde(default)]
441        description: Option<String>,
442    },
443
444    /// Simulate a key sequence
445    KeySequence {
446        /// Action identifier
447        id: String,
448
449        /// Human-readable title
450        title: String,
451
452        /// Key sequence to simulate (e.g., "Ctrl+C", "Up Up Down Down")
453        keys: String,
454
455        /// Optional keyboard shortcut to trigger the action
456        #[serde(default)]
457        keybinding: Option<String>,
458
459        /// Optional single character triggered after the global custom action prefix key.
460        #[serde(default)]
461        prefix_char: Option<char>,
462
463        /// Whether the keybinding is enabled (default: true)
464        #[serde(default = "crate::defaults::bool_true")]
465        keybinding_enabled: bool,
466
467        /// Optional description
468        #[serde(default)]
469        description: Option<String>,
470    },
471
472    /// Split the active pane and optionally send a command to the new pane
473    SplitPane {
474        /// Action identifier
475        id: String,
476
477        /// Human-readable title
478        title: String,
479
480        /// Split direction: horizontal (new pane below) or vertical (new pane right)
481        #[serde(default)]
482        direction: ActionSplitDirection,
483
484        /// Command for the new pane.
485        ///
486        /// Behaviour depends on `command_is_direct`:
487        /// - `false` (default): text is sent to the shell with a trailing newline after `delay_ms`.
488        /// - `true`: the string is split on whitespace and used as the pane's initial process
489        ///   (like running `htop` directly). The pane closes when the process exits.
490        #[serde(default)]
491        command: Option<String>,
492
493        /// When `true`, `command` is the pane's initial process (argv), not a shell command.
494        /// The pane closes when the process exits. `delay_ms` is ignored.
495        /// When `false` (default), `command` is sent as text to the shell.
496        #[serde(default)]
497        command_is_direct: bool,
498
499        /// Whether to focus the new pane after splitting (default: true)
500        #[serde(default = "crate::defaults::bool_true")]
501        focus_new_pane: bool,
502
503        /// Delay in ms before sending the command text to the new pane (default: 200).
504        /// Only used when `command_is_direct` is `false`.
505        #[serde(default = "default_split_pane_delay_ms")]
506        delay_ms: u64,
507
508        /// Percent of the current pane that the existing pane retains after the split.
509        /// Range 10–90. Default: 66 (existing pane keeps 66%, new pane gets 34%).
510        #[serde(default = "default_split_percent")]
511        split_percent: u8,
512
513        /// Optional keyboard shortcut to trigger the action
514        #[serde(default)]
515        keybinding: Option<String>,
516
517        /// Optional single character triggered after the global custom action prefix key.
518        #[serde(default)]
519        prefix_char: Option<char>,
520
521        /// Whether the keybinding is enabled (default: true)
522        #[serde(default = "crate::defaults::bool_true")]
523        keybinding_enabled: bool,
524
525        /// Optional description
526        #[serde(default)]
527        description: Option<String>,
528    },
529
530    /// Run an ordered list of actions (steps) in sequence.
531    Sequence {
532        /// Action identifier
533        id: String,
534        /// Human-readable title
535        title: String,
536        /// Optional keyboard shortcut
537        #[serde(default)]
538        keybinding: Option<String>,
539        /// Optional single character triggered after the global custom action prefix key.
540        #[serde(default)]
541        prefix_char: Option<char>,
542        /// Whether the keybinding is enabled (default: true)
543        #[serde(default = "crate::defaults::bool_true")]
544        keybinding_enabled: bool,
545        /// Optional description
546        #[serde(default)]
547        description: Option<String>,
548        /// Ordered list of steps to execute.
549        #[serde(default)]
550        steps: Vec<SequenceStep>,
551    },
552
553    /// Evaluate a condition and branch to different actions.
554    Condition {
555        /// Action identifier
556        id: String,
557        /// Human-readable title
558        title: String,
559        /// Optional keyboard shortcut
560        #[serde(default)]
561        keybinding: Option<String>,
562        /// Optional single character triggered after the global custom action prefix key.
563        #[serde(default)]
564        prefix_char: Option<char>,
565        /// Whether the keybinding is enabled (default: true)
566        #[serde(default = "crate::defaults::bool_true")]
567        keybinding_enabled: bool,
568        /// Optional description
569        #[serde(default)]
570        description: Option<String>,
571        /// The condition to evaluate.
572        check: ConditionCheck,
573        /// Action ID to execute when check is true (standalone use only; ignored in Sequence).
574        #[serde(default)]
575        on_true_id: Option<String>,
576        /// Action ID to execute when check is false (standalone use only; ignored in Sequence).
577        #[serde(default)]
578        on_false_id: Option<String>,
579    },
580
581    /// Execute an action repeatedly up to N times.
582    Repeat {
583        /// Action identifier
584        id: String,
585        /// Human-readable title
586        title: String,
587        /// Optional keyboard shortcut
588        #[serde(default)]
589        keybinding: Option<String>,
590        /// Optional single character triggered after the global custom action prefix key.
591        #[serde(default)]
592        prefix_char: Option<char>,
593        /// Whether the keybinding is enabled (default: true)
594        #[serde(default = "crate::defaults::bool_true")]
595        keybinding_enabled: bool,
596        /// Optional description
597        #[serde(default)]
598        description: Option<String>,
599        /// ID of the action to repeat.
600        action_id: String,
601        /// Maximum number of repetitions (1–100).
602        count: u32,
603        /// Delay in milliseconds between repetitions (default: 0).
604        #[serde(default)]
605        delay_ms: u64,
606        /// Stop early when the action succeeds (default: false).
607        #[serde(default)]
608        stop_on_success: bool,
609        /// Stop early when the action fails (default: false).
610        #[serde(default)]
611        stop_on_failure: bool,
612    },
613}
614
615impl CustomActionConfig {
616    /// Return a snapshot of the six shared base fields.
617    ///
618    /// Use this to read multiple base fields at once without repeated match arms.
619    /// For single-field reads, prefer the dedicated accessors (`id()`, `title()`, etc.).
620    pub fn base(&self) -> ActionBase {
621        match self {
622            Self::ShellCommand {
623                id,
624                title,
625                keybinding,
626                prefix_char,
627                keybinding_enabled,
628                description,
629                ..
630            }
631            | Self::NewTab {
632                id,
633                title,
634                keybinding,
635                prefix_char,
636                keybinding_enabled,
637                description,
638                ..
639            }
640            | Self::InsertText {
641                id,
642                title,
643                keybinding,
644                prefix_char,
645                keybinding_enabled,
646                description,
647                ..
648            }
649            | Self::KeySequence {
650                id,
651                title,
652                keybinding,
653                prefix_char,
654                keybinding_enabled,
655                description,
656                ..
657            }
658            | Self::SplitPane {
659                id,
660                title,
661                keybinding,
662                prefix_char,
663                keybinding_enabled,
664                description,
665                ..
666            }
667            | Self::Sequence {
668                id,
669                title,
670                keybinding,
671                prefix_char,
672                keybinding_enabled,
673                description,
674                ..
675            }
676            | Self::Condition {
677                id,
678                title,
679                keybinding,
680                prefix_char,
681                keybinding_enabled,
682                description,
683                ..
684            }
685            | Self::Repeat {
686                id,
687                title,
688                keybinding,
689                prefix_char,
690                keybinding_enabled,
691                description,
692                ..
693            } => ActionBase {
694                id: id.clone(),
695                title: title.clone(),
696                keybinding: keybinding.clone(),
697                prefix_char: *prefix_char,
698                keybinding_enabled: *keybinding_enabled,
699                description: description.clone(),
700            },
701        }
702    }
703
704    /// Overwrite all six shared base fields from an [`ActionBase`] snapshot.
705    ///
706    /// This is the single mutation point that replaces the eight-arm match duplication
707    /// previously found in `set_keybinding`, `set_prefix_char`, `set_keybinding_enabled`,
708    /// and `into_copy`.
709    pub fn apply_base(&mut self, base: ActionBase) {
710        match self {
711            Self::ShellCommand {
712                id,
713                title,
714                keybinding,
715                prefix_char,
716                keybinding_enabled,
717                description,
718                ..
719            }
720            | Self::NewTab {
721                id,
722                title,
723                keybinding,
724                prefix_char,
725                keybinding_enabled,
726                description,
727                ..
728            }
729            | Self::InsertText {
730                id,
731                title,
732                keybinding,
733                prefix_char,
734                keybinding_enabled,
735                description,
736                ..
737            }
738            | Self::KeySequence {
739                id,
740                title,
741                keybinding,
742                prefix_char,
743                keybinding_enabled,
744                description,
745                ..
746            }
747            | Self::SplitPane {
748                id,
749                title,
750                keybinding,
751                prefix_char,
752                keybinding_enabled,
753                description,
754                ..
755            }
756            | Self::Sequence {
757                id,
758                title,
759                keybinding,
760                prefix_char,
761                keybinding_enabled,
762                description,
763                ..
764            }
765            | Self::Condition {
766                id,
767                title,
768                keybinding,
769                prefix_char,
770                keybinding_enabled,
771                description,
772                ..
773            }
774            | Self::Repeat {
775                id,
776                title,
777                keybinding,
778                prefix_char,
779                keybinding_enabled,
780                description,
781                ..
782            } => {
783                *id = base.id;
784                *title = base.title;
785                *keybinding = base.keybinding;
786                *prefix_char = base.prefix_char;
787                *keybinding_enabled = base.keybinding_enabled;
788                *description = base.description;
789            }
790        }
791    }
792
793    /// Get the action ID (for keybinding reference).
794    pub fn id(&self) -> &str {
795        match self {
796            Self::ShellCommand { id, .. }
797            | Self::NewTab { id, .. }
798            | Self::InsertText { id, .. }
799            | Self::KeySequence { id, .. }
800            | Self::SplitPane { id, .. }
801            | Self::Sequence { id, .. }
802            | Self::Condition { id, .. }
803            | Self::Repeat { id, .. } => id,
804        }
805    }
806
807    /// Get the action title (for UI display).
808    pub fn title(&self) -> &str {
809        match self {
810            Self::ShellCommand { title, .. }
811            | Self::NewTab { title, .. }
812            | Self::InsertText { title, .. }
813            | Self::KeySequence { title, .. }
814            | Self::SplitPane { title, .. }
815            | Self::Sequence { title, .. }
816            | Self::Condition { title, .. }
817            | Self::Repeat { title, .. } => title,
818        }
819    }
820
821    /// Get the optional keybinding for this action.
822    pub fn keybinding(&self) -> Option<&str> {
823        match self {
824            Self::ShellCommand { keybinding, .. }
825            | Self::NewTab { keybinding, .. }
826            | Self::InsertText { keybinding, .. }
827            | Self::KeySequence { keybinding, .. }
828            | Self::SplitPane { keybinding, .. }
829            | Self::Sequence { keybinding, .. }
830            | Self::Condition { keybinding, .. }
831            | Self::Repeat { keybinding, .. } => keybinding.as_deref(),
832        }
833    }
834
835    /// Get the optional prefix character for this action.
836    pub fn prefix_char(&self) -> Option<char> {
837        match self {
838            Self::ShellCommand { prefix_char, .. }
839            | Self::NewTab { prefix_char, .. }
840            | Self::InsertText { prefix_char, .. }
841            | Self::KeySequence { prefix_char, .. }
842            | Self::SplitPane { prefix_char, .. }
843            | Self::Sequence { prefix_char, .. }
844            | Self::Condition { prefix_char, .. }
845            | Self::Repeat { prefix_char, .. } => *prefix_char,
846        }
847    }
848
849    /// Get the normalized prefix character for this action, if configured.
850    pub fn normalized_prefix_char(&self) -> Option<char> {
851        self.prefix_char().map(normalize_action_prefix_char)
852    }
853
854    /// Check if the keybinding is enabled.
855    pub fn keybinding_enabled(&self) -> bool {
856        match self {
857            Self::ShellCommand {
858                keybinding_enabled, ..
859            }
860            | Self::NewTab {
861                keybinding_enabled, ..
862            }
863            | Self::InsertText {
864                keybinding_enabled, ..
865            }
866            | Self::KeySequence {
867                keybinding_enabled, ..
868            }
869            | Self::SplitPane {
870                keybinding_enabled, ..
871            }
872            | Self::Sequence {
873                keybinding_enabled, ..
874            }
875            | Self::Condition {
876                keybinding_enabled, ..
877            }
878            | Self::Repeat {
879                keybinding_enabled, ..
880            } => *keybinding_enabled,
881        }
882    }
883
884    /// Set the keybinding for this action.
885    pub fn set_keybinding(&mut self, kb: Option<String>) {
886        let mut base = self.base();
887        base.keybinding = kb;
888        self.apply_base(base);
889    }
890
891    /// Set the prefix character for this action.
892    pub fn set_prefix_char(&mut self, prefix_char: Option<char>) {
893        let mut base = self.base();
894        base.prefix_char = prefix_char;
895        self.apply_base(base);
896    }
897
898    /// Set whether the keybinding is enabled.
899    pub fn set_keybinding_enabled(&mut self, enabled: bool) {
900        let mut base = self.base();
901        base.keybinding_enabled = enabled;
902        self.apply_base(base);
903    }
904
905    /// Check if this is a shell command action.
906    pub fn is_shell_command(&self) -> bool {
907        matches!(self, Self::ShellCommand { .. })
908    }
909
910    /// Check if this is a new tab action.
911    pub fn is_new_tab(&self) -> bool {
912        matches!(self, Self::NewTab { .. })
913    }
914
915    /// Check if this is an insert text action.
916    pub fn is_insert_text(&self) -> bool {
917        matches!(self, Self::InsertText { .. })
918    }
919
920    /// Check if this is a key sequence action.
921    pub fn is_key_sequence(&self) -> bool {
922        matches!(self, Self::KeySequence { .. })
923    }
924
925    /// Check if this is a split pane action.
926    pub fn is_split_pane(&self) -> bool {
927        matches!(self, Self::SplitPane { .. })
928    }
929
930    /// Check if this is a sequence action.
931    pub fn is_sequence(&self) -> bool {
932        matches!(self, Self::Sequence { .. })
933    }
934
935    /// Check if this is a condition action.
936    pub fn is_condition(&self) -> bool {
937        matches!(self, Self::Condition { .. })
938    }
939
940    /// Check if this is a repeat action.
941    pub fn is_repeat(&self) -> bool {
942        matches!(self, Self::Repeat { .. })
943    }
944
945    /// Produce a duplicate of this action suitable for "Clone" in the settings UI.
946    ///
947    /// The returned action has:
948    /// - A fresh UUID-based `id` to avoid keybinding conflicts.
949    /// - The original `title` suffixed with `"-copy"`.
950    /// - `keybinding` and `prefix_char` cleared to prevent immediate conflicts.
951    ///
952    /// All other fields are deep-cloned from `self`.
953    ///
954    /// This replaces the `clone_action` helper that was previously inlined in
955    /// `par-term-settings-ui/src/actions_tab.rs` (see ARC-006). Keeping the logic here
956    /// ensures it stays in sync with the `Clone` derive on `CustomActionConfig`.
957    pub fn into_copy(&self) -> Self {
958        let mut cloned = self.clone();
959        // Patch the four base fields that must differ on the copy; keep the rest.
960        let mut base = cloned.base();
961        base.id = format!("action_{}", uuid::Uuid::new_v4());
962        base.title = format!("{}-copy", base.title);
963        base.keybinding = None;
964        base.prefix_char = None;
965        cloned.apply_base(base);
966        cloned
967    }
968}
969
970/// Built-in variables available for snippet substitution.
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
972pub enum BuiltInVariable {
973    /// Current date (YYYY-MM-DD)
974    Date,
975    /// Current time (HH:MM:SS)
976    Time,
977    /// Current date and time
978    DateTime,
979    /// System hostname
980    Hostname,
981    /// Current username
982    User,
983    /// Current working directory
984    Path,
985    /// Current git branch (if in a git repository)
986    GitBranch,
987    /// Current git commit hash (if in a git repository)
988    GitCommit,
989    /// Random UUID
990    Uuid,
991    /// Random number (0-999999)
992    Random,
993}
994
995impl BuiltInVariable {
996    /// Get all built-in variables for UI display.
997    pub fn all() -> &'static [(&'static str, &'static str)] {
998        &[
999            ("date", "Current date (YYYY-MM-DD)"),
1000            ("time", "Current time (HH:MM:SS)"),
1001            ("datetime", "Current date and time"),
1002            ("hostname", "System hostname"),
1003            ("user", "Current username"),
1004            ("path", "Current working directory"),
1005            ("git_branch", "Current git branch"),
1006            ("git_commit", "Current git commit hash"),
1007            ("uuid", "Random UUID"),
1008            ("random", "Random number (0-999999)"),
1009        ]
1010    }
1011
1012    /// Parse a variable name into a BuiltInVariable.
1013    pub fn parse(name: &str) -> Option<Self> {
1014        match name {
1015            "date" => Some(Self::Date),
1016            "time" => Some(Self::Time),
1017            "datetime" => Some(Self::DateTime),
1018            "hostname" => Some(Self::Hostname),
1019            "user" => Some(Self::User),
1020            "path" => Some(Self::Path),
1021            "git_branch" => Some(Self::GitBranch),
1022            "git_commit" => Some(Self::GitCommit),
1023            "uuid" => Some(Self::Uuid),
1024            "random" => Some(Self::Random),
1025            _ => None,
1026        }
1027    }
1028
1029    /// Resolve the variable to its string value.
1030    pub fn resolve(&self) -> String {
1031        match self {
1032            // QA-001: Use chrono for correct calendar arithmetic. The previous
1033            // implementation derived the date from `secs / 86400` with every month
1034            // treated as 30 days, which produced month=13 in late December and a
1035            // wrong day-of-month year-round.
1036            Self::Date => chrono::Local::now().format("%Y-%m-%d").to_string(),
1037            Self::Time => {
1038                use std::time::{SystemTime, UNIX_EPOCH};
1039                let duration = SystemTime::now()
1040                    .duration_since(UNIX_EPOCH)
1041                    .unwrap_or_default();
1042                let secs = duration.as_secs();
1043                let hours = (secs % 86400) / 3600;
1044                let minutes = (secs % 3600) / 60;
1045                let seconds = secs % 60;
1046
1047                format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
1048            }
1049            Self::DateTime => {
1050                format!("{} {}", Self::Date.resolve(), Self::Time.resolve())
1051            }
1052            Self::Hostname => {
1053                std::env::var("HOSTNAME")
1054                    .or_else(|_| std::env::var("HOST"))
1055                    .unwrap_or_else(|_| {
1056                        // Fallback to system hostname
1057                        hostname::get()
1058                            .ok()
1059                            .and_then(|s| s.into_string().ok())
1060                            .unwrap_or_else(|| "unknown".to_string())
1061                    })
1062            }
1063            Self::User => std::env::var("USER")
1064                .or_else(|_| std::env::var("USERNAME"))
1065                .unwrap_or_else(|_| "unknown".to_string()),
1066            Self::Path => std::env::current_dir()
1067                .ok()
1068                .and_then(|p| p.to_str().map(|s| s.to_string()))
1069                .unwrap_or_else(|| ".".to_string()),
1070            Self::GitBranch => {
1071                // Try to get git branch from environment or command
1072                match std::env::var("GIT_BRANCH") {
1073                    Ok(branch) => branch,
1074                    Err(_) => git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"]),
1075                }
1076            }
1077            Self::GitCommit => {
1078                // Try to get git commit from environment or command
1079                match std::env::var("GIT_COMMIT") {
1080                    Ok(commit) => commit,
1081                    Err(_) => git_stdout(&["rev-parse", "--short", "HEAD"]),
1082                }
1083            }
1084            Self::Uuid => uuid::Uuid::new_v4().to_string(),
1085            Self::Random => {
1086                use std::time::{SystemTime, UNIX_EPOCH};
1087                let duration = SystemTime::now()
1088                    .duration_since(UNIX_EPOCH)
1089                    .unwrap_or_default();
1090                format!("{}", (duration.as_nanos() % 1_000_000) as u32)
1091            }
1092        }
1093    }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099
1100    #[test]
1101    fn test_snippet_new() {
1102        let snippet = SnippetConfig::new(
1103            "test".to_string(),
1104            "Test Snippet".to_string(),
1105            "echo 'hello'".to_string(),
1106        );
1107
1108        assert_eq!(snippet.id, "test");
1109        assert_eq!(snippet.title, "Test Snippet");
1110        assert_eq!(snippet.content, "echo 'hello'");
1111        assert!(snippet.enabled);
1112        assert!(snippet.keybinding.is_none());
1113        assert!(snippet.folder.is_none());
1114        assert!(snippet.variables.is_empty());
1115    }
1116
1117    #[test]
1118    fn test_snippet_builder() {
1119        let snippet = SnippetConfig::new(
1120            "test".to_string(),
1121            "Test Snippet".to_string(),
1122            "echo 'hello'".to_string(),
1123        )
1124        .with_keybinding("Ctrl+Shift+T".to_string())
1125        .with_folder("Test".to_string())
1126        .with_variable("name".to_string(), "value".to_string());
1127
1128        assert_eq!(snippet.keybinding, Some("Ctrl+Shift+T".to_string()));
1129        assert_eq!(snippet.folder, Some("Test".to_string()));
1130        assert_eq!(snippet.variables.get("name"), Some(&"value".to_string()));
1131    }
1132
1133    #[test]
1134    fn test_builtin_variable_resolution() {
1135        // These should not panic
1136        let date = BuiltInVariable::Date.resolve();
1137        assert!(!date.is_empty());
1138        // QA-001: `\(date)` must be a valid `YYYY-MM-DD` string. The previous
1139        // hand-rolled arithmetic produced month=13 in late December and a wrong
1140        // day-of-month year-round; assert the shape so the regression is caught.
1141        assert_eq!(
1142            date.len(),
1143            10,
1144            "date must be 10 chars (YYYY-MM-DD), got {date:?}"
1145        );
1146        let mut parts = date.split('-');
1147        let (year, month, day) = (
1148            parts.next().unwrap(),
1149            parts.next().unwrap(),
1150            parts.next().unwrap(),
1151        );
1152        assert!(
1153            parts.next().is_none(),
1154            "date has extra components: {date:?}"
1155        );
1156        assert!(
1157            (1..=9999).contains(&(year.parse::<u32>().unwrap())),
1158            "year out of range: {year:?}"
1159        );
1160        assert!(
1161            (1..=12).contains(&(month.parse::<u32>().unwrap())),
1162            "month must be 1..=12, got {month:?} (date={date:?})"
1163        );
1164        assert!(
1165            (1..=31).contains(&(day.parse::<u32>().unwrap())),
1166            "day must be 1..=31, got {day:?} (date={date:?})"
1167        );
1168
1169        let time = BuiltInVariable::Time.resolve();
1170        assert!(!time.is_empty());
1171
1172        let user = BuiltInVariable::User.resolve();
1173        assert!(!user.is_empty());
1174
1175        let path = BuiltInVariable::Path.resolve();
1176        assert!(!path.is_empty());
1177    }
1178
1179    #[test]
1180    fn test_builtin_variable_parse() {
1181        assert_eq!(BuiltInVariable::parse("date"), Some(BuiltInVariable::Date));
1182        assert_eq!(BuiltInVariable::parse("time"), Some(BuiltInVariable::Time));
1183        assert_eq!(BuiltInVariable::parse("unknown"), None);
1184    }
1185
1186    #[test]
1187    fn test_custom_action_id() {
1188        let action = CustomActionConfig::ShellCommand {
1189            id: "test-action".to_string(),
1190            title: "Test Action".to_string(),
1191            command: "echo".to_string(),
1192            args: vec!["hello".to_string()],
1193            notify_on_success: false,
1194            timeout_secs: 30,
1195            capture_output: false,
1196            keybinding: None,
1197            prefix_char: Some('G'),
1198            keybinding_enabled: true,
1199            description: None,
1200        };
1201
1202        assert_eq!(action.id(), "test-action");
1203        assert_eq!(action.title(), "Test Action");
1204        assert!(action.is_shell_command());
1205        assert!(!action.is_new_tab());
1206        assert!(!action.is_insert_text());
1207        assert!(!action.is_key_sequence());
1208        assert!(!action.is_split_pane());
1209        assert_eq!(action.prefix_char(), Some('G'));
1210        assert_eq!(action.normalized_prefix_char(), Some('g'));
1211    }
1212
1213    #[test]
1214    fn test_split_pane_action() {
1215        let action = CustomActionConfig::SplitPane {
1216            id: "split-htop".to_string(),
1217            title: "Split and run htop".to_string(),
1218            direction: ActionSplitDirection::Vertical,
1219            command: Some("htop".to_string()),
1220            command_is_direct: true,
1221            focus_new_pane: true,
1222            delay_ms: 200,
1223            split_percent: 66,
1224            keybinding: Some("Ctrl+Shift+H".to_string()),
1225            prefix_char: None,
1226            keybinding_enabled: true,
1227            description: None,
1228        };
1229
1230        assert_eq!(action.id(), "split-htop");
1231        assert_eq!(action.title(), "Split and run htop");
1232        assert!(action.is_split_pane());
1233        assert!(!action.is_shell_command());
1234        assert_eq!(action.keybinding(), Some("Ctrl+Shift+H"));
1235    }
1236
1237    #[test]
1238    fn test_new_tab_action() {
1239        let action = CustomActionConfig::NewTab {
1240            id: "new-tab-lazygit".to_string(),
1241            title: "Open lazygit tab".to_string(),
1242            command: Some("lazygit".to_string()),
1243            keybinding: Some("Ctrl+Shift+G".to_string()),
1244            prefix_char: Some('g'),
1245            keybinding_enabled: true,
1246            description: None,
1247        };
1248
1249        assert_eq!(action.id(), "new-tab-lazygit");
1250        assert_eq!(action.title(), "Open lazygit tab");
1251        assert!(action.is_new_tab());
1252        assert!(!action.is_shell_command());
1253        assert!(!action.is_split_pane());
1254        assert_eq!(action.keybinding(), Some("Ctrl+Shift+G"));
1255        assert_eq!(action.normalized_prefix_char(), Some('g'));
1256    }
1257
1258    #[test]
1259    fn test_sequence_action_round_trip() {
1260        let action = CustomActionConfig::Sequence {
1261            id: "build-and-test".to_string(),
1262            title: "Build and Test".to_string(),
1263            keybinding: None,
1264            prefix_char: None,
1265            keybinding_enabled: true,
1266            description: None,
1267            steps: vec![
1268                SequenceStep {
1269                    action_id: "build".to_string(),
1270                    delay_ms: 0,
1271                    on_failure: SequenceStepBehavior::Abort,
1272                },
1273                SequenceStep {
1274                    action_id: "test".to_string(),
1275                    delay_ms: 500,
1276                    on_failure: SequenceStepBehavior::Continue,
1277                },
1278            ],
1279        };
1280        let yaml = serde_yaml_ng::to_string(&action).unwrap();
1281        let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1282        assert_eq!(action, roundtrip);
1283        assert_eq!(action.id(), "build-and-test");
1284        assert_eq!(action.title(), "Build and Test");
1285    }
1286
1287    #[test]
1288    fn test_condition_action_round_trip() {
1289        let action = CustomActionConfig::Condition {
1290            id: "check-main".to_string(),
1291            title: "Check Main Branch".to_string(),
1292            keybinding: None,
1293            prefix_char: None,
1294            keybinding_enabled: true,
1295            description: None,
1296            check: ConditionCheck::GitBranch {
1297                pattern: "main".to_string(),
1298            },
1299            on_true_id: Some("deploy".to_string()),
1300            on_false_id: None,
1301        };
1302        let yaml = serde_yaml_ng::to_string(&action).unwrap();
1303        let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1304        assert_eq!(action, roundtrip);
1305        assert_eq!(action.id(), "check-main");
1306    }
1307
1308    #[test]
1309    fn test_repeat_action_round_trip() {
1310        let action = CustomActionConfig::Repeat {
1311            id: "retry-deploy".to_string(),
1312            title: "Retry Deploy".to_string(),
1313            keybinding: None,
1314            prefix_char: None,
1315            keybinding_enabled: true,
1316            description: None,
1317            action_id: "deploy".to_string(),
1318            count: 3,
1319            delay_ms: 1000,
1320            stop_on_success: true,
1321            stop_on_failure: false,
1322        };
1323        let yaml = serde_yaml_ng::to_string(&action).unwrap();
1324        let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1325        assert_eq!(action, roundtrip);
1326        assert_eq!(action.id(), "retry-deploy");
1327    }
1328
1329    #[test]
1330    fn test_shell_command_capture_output_default_false() {
1331        let yaml = r#"
1332type: shell_command
1333id: test
1334title: Test
1335command: echo
1336"#;
1337        let action: CustomActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
1338        if let CustomActionConfig::ShellCommand { capture_output, .. } = action {
1339            assert!(!capture_output);
1340        } else {
1341            panic!("expected ShellCommand");
1342        }
1343    }
1344}