1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11const fn default_shell_command_timeout_secs() -> u64 {
13 30
14}
15
16const GIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
18
19const GIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
21
22fn 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct SnippetConfig {
94 pub id: String,
96
97 pub title: String,
99
100 pub content: String,
102
103 #[serde(default)]
105 pub keybinding: Option<String>,
106
107 #[serde(default = "crate::defaults::bool_true")]
110 pub keybinding_enabled: bool,
111
112 #[serde(default)]
114 pub folder: Option<String>,
115
116 #[serde(default = "crate::defaults::bool_true")]
118 pub enabled: bool,
119
120 #[serde(default)]
122 pub description: Option<String>,
123
124 #[serde(default)]
127 pub auto_execute: bool,
128
129 #[serde(default)]
131 pub variables: HashMap<String, String>,
132}
133
134impl SnippetConfig {
135 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 pub fn with_keybinding(mut self, keybinding: String) -> Self {
153 self.keybinding = Some(keybinding);
154 self
155 }
156
157 pub fn with_keybinding_disabled(mut self) -> Self {
159 self.keybinding_enabled = false;
160 self
161 }
162
163 pub fn with_folder(mut self, folder: String) -> Self {
165 self.folder = Some(folder);
166 self
167 }
168
169 pub fn with_variable(mut self, name: String, value: String) -> Self {
171 self.variables.insert(name, value);
172 self
173 }
174
175 pub fn with_auto_execute(mut self) -> Self {
177 self.auto_execute = true;
178 self
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SnippetLibrary {
187 pub snippets: Vec<SnippetConfig>,
189}
190
191const fn default_split_pane_delay_ms() -> u64 {
193 200
194}
195
196pub 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
207const fn default_split_percent() -> u8 {
209 66
210}
211
212#[derive(Debug, Clone, PartialEq, Default)]
226pub struct ActionBase {
227 pub id: String,
229 pub title: String,
231 pub keybinding: Option<String>,
233 pub prefix_char: Option<char>,
235 pub keybinding_enabled: bool,
237 pub description: Option<String>,
239}
240
241impl ActionBase {
242 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
257#[serde(rename_all = "snake_case")]
258pub enum ActionSplitDirection {
259 #[default]
261 Horizontal,
262 Vertical,
264}
265
266impl ActionSplitDirection {
267 pub fn all() -> &'static [ActionSplitDirection] {
269 &[Self::Horizontal, Self::Vertical]
270 }
271
272 pub fn label(self) -> &'static str {
274 match self {
275 Self::Horizontal => "Horizontal (below)",
276 Self::Vertical => "Vertical (right)",
277 }
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
283#[serde(rename_all = "snake_case")]
284pub enum SequenceStepBehavior {
285 #[default]
287 Abort,
288 Stop,
290 Continue,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct SequenceStep {
297 pub action_id: String,
299 #[serde(default)]
301 pub delay_ms: u64,
302 #[serde(default)]
304 pub on_failure: SequenceStepBehavior,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309#[serde(tag = "kind", rename_all = "snake_case")]
310pub enum ConditionCheck {
311 ExitCode { value: i32 },
313 OutputContains {
315 pattern: String,
316 #[serde(default)]
317 case_sensitive: bool,
318 },
319 EnvVar {
321 name: String,
322 #[serde(default)]
323 value: Option<String>,
324 },
325 DirMatches { pattern: String },
327 GitBranch { pattern: String },
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
336#[serde(tag = "type", rename_all = "snake_case")]
337pub enum CustomActionConfig {
338 ShellCommand {
340 id: String,
342
343 title: String,
345
346 command: String,
348
349 #[serde(default)]
351 args: Vec<String>,
352
353 #[serde(default)]
355 notify_on_success: bool,
356
357 #[serde(default = "default_shell_command_timeout_secs")]
359 timeout_secs: u64,
360
361 #[serde(default)]
364 capture_output: bool,
365
366 #[serde(default)]
368 keybinding: Option<String>,
369
370 #[serde(default)]
372 prefix_char: Option<char>,
373
374 #[serde(default = "crate::defaults::bool_true")]
376 keybinding_enabled: bool,
377
378 #[serde(default)]
380 description: Option<String>,
381 },
382
383 NewTab {
385 id: String,
387
388 title: String,
390
391 #[serde(default)]
393 command: Option<String>,
394
395 #[serde(default)]
397 keybinding: Option<String>,
398
399 #[serde(default)]
401 prefix_char: Option<char>,
402
403 #[serde(default = "crate::defaults::bool_true")]
405 keybinding_enabled: bool,
406
407 #[serde(default)]
409 description: Option<String>,
410 },
411
412 InsertText {
414 id: String,
416
417 title: String,
419
420 text: String,
422
423 #[serde(default)]
425 variables: HashMap<String, String>,
426
427 #[serde(default)]
429 keybinding: Option<String>,
430
431 #[serde(default)]
433 prefix_char: Option<char>,
434
435 #[serde(default = "crate::defaults::bool_true")]
437 keybinding_enabled: bool,
438
439 #[serde(default)]
441 description: Option<String>,
442 },
443
444 KeySequence {
446 id: String,
448
449 title: String,
451
452 keys: String,
454
455 #[serde(default)]
457 keybinding: Option<String>,
458
459 #[serde(default)]
461 prefix_char: Option<char>,
462
463 #[serde(default = "crate::defaults::bool_true")]
465 keybinding_enabled: bool,
466
467 #[serde(default)]
469 description: Option<String>,
470 },
471
472 SplitPane {
474 id: String,
476
477 title: String,
479
480 #[serde(default)]
482 direction: ActionSplitDirection,
483
484 #[serde(default)]
491 command: Option<String>,
492
493 #[serde(default)]
497 command_is_direct: bool,
498
499 #[serde(default = "crate::defaults::bool_true")]
501 focus_new_pane: bool,
502
503 #[serde(default = "default_split_pane_delay_ms")]
506 delay_ms: u64,
507
508 #[serde(default = "default_split_percent")]
511 split_percent: u8,
512
513 #[serde(default)]
515 keybinding: Option<String>,
516
517 #[serde(default)]
519 prefix_char: Option<char>,
520
521 #[serde(default = "crate::defaults::bool_true")]
523 keybinding_enabled: bool,
524
525 #[serde(default)]
527 description: Option<String>,
528 },
529
530 Sequence {
532 id: String,
534 title: String,
536 #[serde(default)]
538 keybinding: Option<String>,
539 #[serde(default)]
541 prefix_char: Option<char>,
542 #[serde(default = "crate::defaults::bool_true")]
544 keybinding_enabled: bool,
545 #[serde(default)]
547 description: Option<String>,
548 #[serde(default)]
550 steps: Vec<SequenceStep>,
551 },
552
553 Condition {
555 id: String,
557 title: String,
559 #[serde(default)]
561 keybinding: Option<String>,
562 #[serde(default)]
564 prefix_char: Option<char>,
565 #[serde(default = "crate::defaults::bool_true")]
567 keybinding_enabled: bool,
568 #[serde(default)]
570 description: Option<String>,
571 check: ConditionCheck,
573 #[serde(default)]
575 on_true_id: Option<String>,
576 #[serde(default)]
578 on_false_id: Option<String>,
579 },
580
581 Repeat {
583 id: String,
585 title: String,
587 #[serde(default)]
589 keybinding: Option<String>,
590 #[serde(default)]
592 prefix_char: Option<char>,
593 #[serde(default = "crate::defaults::bool_true")]
595 keybinding_enabled: bool,
596 #[serde(default)]
598 description: Option<String>,
599 action_id: String,
601 count: u32,
603 #[serde(default)]
605 delay_ms: u64,
606 #[serde(default)]
608 stop_on_success: bool,
609 #[serde(default)]
611 stop_on_failure: bool,
612 },
613}
614
615impl CustomActionConfig {
616 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 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 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 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 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 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 pub fn normalized_prefix_char(&self) -> Option<char> {
851 self.prefix_char().map(normalize_action_prefix_char)
852 }
853
854 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 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 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 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 pub fn is_shell_command(&self) -> bool {
907 matches!(self, Self::ShellCommand { .. })
908 }
909
910 pub fn is_new_tab(&self) -> bool {
912 matches!(self, Self::NewTab { .. })
913 }
914
915 pub fn is_insert_text(&self) -> bool {
917 matches!(self, Self::InsertText { .. })
918 }
919
920 pub fn is_key_sequence(&self) -> bool {
922 matches!(self, Self::KeySequence { .. })
923 }
924
925 pub fn is_split_pane(&self) -> bool {
927 matches!(self, Self::SplitPane { .. })
928 }
929
930 pub fn is_sequence(&self) -> bool {
932 matches!(self, Self::Sequence { .. })
933 }
934
935 pub fn is_condition(&self) -> bool {
937 matches!(self, Self::Condition { .. })
938 }
939
940 pub fn is_repeat(&self) -> bool {
942 matches!(self, Self::Repeat { .. })
943 }
944
945 pub fn into_copy(&self) -> Self {
958 let mut cloned = self.clone();
959 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
972pub enum BuiltInVariable {
973 Date,
975 Time,
977 DateTime,
979 Hostname,
981 User,
983 Path,
985 GitBranch,
987 GitCommit,
989 Uuid,
991 Random,
993}
994
995impl BuiltInVariable {
996 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 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 pub fn resolve(&self) -> String {
1031 match self {
1032 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 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 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 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 let date = BuiltInVariable::Date.resolve();
1137 assert!(!date.is_empty());
1138 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}