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 prefix_follow_up_char(&self) -> Option<char> {
860 if let Some(ch) = self.normalized_prefix_char() {
861 return Some(ch);
862 }
863 self.single_char_keybinding_follow_up()
864 }
865
866 pub(crate) fn single_char_keybinding_follow_up(&self) -> Option<char> {
867 if !self.keybinding_enabled() {
868 return None;
869 }
870 let key = self.keybinding()?.trim();
871 let mut chars = key.chars();
872 let ch = chars.next()?;
873 if chars.next().is_some() {
874 return None;
875 }
876 Some(normalize_action_prefix_char(ch))
877 }
878
879 pub fn keybinding_enabled(&self) -> bool {
881 match self {
882 Self::ShellCommand {
883 keybinding_enabled, ..
884 }
885 | Self::NewTab {
886 keybinding_enabled, ..
887 }
888 | Self::InsertText {
889 keybinding_enabled, ..
890 }
891 | Self::KeySequence {
892 keybinding_enabled, ..
893 }
894 | Self::SplitPane {
895 keybinding_enabled, ..
896 }
897 | Self::Sequence {
898 keybinding_enabled, ..
899 }
900 | Self::Condition {
901 keybinding_enabled, ..
902 }
903 | Self::Repeat {
904 keybinding_enabled, ..
905 } => *keybinding_enabled,
906 }
907 }
908
909 pub fn set_keybinding(&mut self, kb: Option<String>) {
911 let mut base = self.base();
912 base.keybinding = kb;
913 self.apply_base(base);
914 }
915
916 pub fn set_prefix_char(&mut self, prefix_char: Option<char>) {
918 let mut base = self.base();
919 base.prefix_char = prefix_char;
920 self.apply_base(base);
921 }
922
923 pub fn set_keybinding_enabled(&mut self, enabled: bool) {
925 let mut base = self.base();
926 base.keybinding_enabled = enabled;
927 self.apply_base(base);
928 }
929
930 pub fn is_shell_command(&self) -> bool {
932 matches!(self, Self::ShellCommand { .. })
933 }
934
935 pub fn is_new_tab(&self) -> bool {
937 matches!(self, Self::NewTab { .. })
938 }
939
940 pub fn is_insert_text(&self) -> bool {
942 matches!(self, Self::InsertText { .. })
943 }
944
945 pub fn is_key_sequence(&self) -> bool {
947 matches!(self, Self::KeySequence { .. })
948 }
949
950 pub fn is_split_pane(&self) -> bool {
952 matches!(self, Self::SplitPane { .. })
953 }
954
955 pub fn is_sequence(&self) -> bool {
957 matches!(self, Self::Sequence { .. })
958 }
959
960 pub fn is_condition(&self) -> bool {
962 matches!(self, Self::Condition { .. })
963 }
964
965 pub fn is_repeat(&self) -> bool {
967 matches!(self, Self::Repeat { .. })
968 }
969
970 pub fn into_copy(&self) -> Self {
983 let mut cloned = self.clone();
984 let mut base = cloned.base();
986 base.id = format!("action_{}", uuid::Uuid::new_v4());
987 base.title = format!("{}-copy", base.title);
988 base.keybinding = None;
989 base.prefix_char = None;
990 cloned.apply_base(base);
991 cloned
992 }
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
997pub enum BuiltInVariable {
998 Date,
1000 Time,
1002 DateTime,
1004 Hostname,
1006 User,
1008 Path,
1010 GitBranch,
1012 GitCommit,
1014 Uuid,
1016 Random,
1018}
1019
1020impl BuiltInVariable {
1021 pub fn all() -> &'static [(&'static str, &'static str)] {
1023 &[
1024 ("date", "Current date (YYYY-MM-DD)"),
1025 ("time", "Current time (HH:MM:SS)"),
1026 ("datetime", "Current date and time"),
1027 ("hostname", "System hostname"),
1028 ("user", "Current username"),
1029 ("path", "Current working directory"),
1030 ("git_branch", "Current git branch"),
1031 ("git_commit", "Current git commit hash"),
1032 ("uuid", "Random UUID"),
1033 ("random", "Random number (0-999999)"),
1034 ]
1035 }
1036
1037 pub fn parse(name: &str) -> Option<Self> {
1039 match name {
1040 "date" => Some(Self::Date),
1041 "time" => Some(Self::Time),
1042 "datetime" => Some(Self::DateTime),
1043 "hostname" => Some(Self::Hostname),
1044 "user" => Some(Self::User),
1045 "path" => Some(Self::Path),
1046 "git_branch" => Some(Self::GitBranch),
1047 "git_commit" => Some(Self::GitCommit),
1048 "uuid" => Some(Self::Uuid),
1049 "random" => Some(Self::Random),
1050 _ => None,
1051 }
1052 }
1053
1054 pub fn resolve(&self) -> String {
1056 match self {
1057 Self::Date => chrono::Local::now().format("%Y-%m-%d").to_string(),
1062 Self::Time => {
1063 use std::time::{SystemTime, UNIX_EPOCH};
1064 let duration = SystemTime::now()
1065 .duration_since(UNIX_EPOCH)
1066 .unwrap_or_default();
1067 let secs = duration.as_secs();
1068 let hours = (secs % 86400) / 3600;
1069 let minutes = (secs % 3600) / 60;
1070 let seconds = secs % 60;
1071
1072 format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
1073 }
1074 Self::DateTime => {
1075 format!("{} {}", Self::Date.resolve(), Self::Time.resolve())
1076 }
1077 Self::Hostname => {
1078 std::env::var("HOSTNAME")
1079 .or_else(|_| std::env::var("HOST"))
1080 .unwrap_or_else(|_| {
1081 hostname::get()
1083 .ok()
1084 .and_then(|s| s.into_string().ok())
1085 .unwrap_or_else(|| "unknown".to_string())
1086 })
1087 }
1088 Self::User => std::env::var("USER")
1089 .or_else(|_| std::env::var("USERNAME"))
1090 .unwrap_or_else(|_| "unknown".to_string()),
1091 Self::Path => std::env::current_dir()
1092 .ok()
1093 .and_then(|p| p.to_str().map(|s| s.to_string()))
1094 .unwrap_or_else(|| ".".to_string()),
1095 Self::GitBranch => {
1096 match std::env::var("GIT_BRANCH") {
1098 Ok(branch) => branch,
1099 Err(_) => git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"]),
1100 }
1101 }
1102 Self::GitCommit => {
1103 match std::env::var("GIT_COMMIT") {
1105 Ok(commit) => commit,
1106 Err(_) => git_stdout(&["rev-parse", "--short", "HEAD"]),
1107 }
1108 }
1109 Self::Uuid => uuid::Uuid::new_v4().to_string(),
1110 Self::Random => {
1111 use std::time::{SystemTime, UNIX_EPOCH};
1112 let duration = SystemTime::now()
1113 .duration_since(UNIX_EPOCH)
1114 .unwrap_or_default();
1115 format!("{}", (duration.as_nanos() % 1_000_000) as u32)
1116 }
1117 }
1118 }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124
1125 #[test]
1126 fn test_snippet_new() {
1127 let snippet = SnippetConfig::new(
1128 "test".to_string(),
1129 "Test Snippet".to_string(),
1130 "echo 'hello'".to_string(),
1131 );
1132
1133 assert_eq!(snippet.id, "test");
1134 assert_eq!(snippet.title, "Test Snippet");
1135 assert_eq!(snippet.content, "echo 'hello'");
1136 assert!(snippet.enabled);
1137 assert!(snippet.keybinding.is_none());
1138 assert!(snippet.folder.is_none());
1139 assert!(snippet.variables.is_empty());
1140 }
1141
1142 #[test]
1143 fn test_snippet_builder() {
1144 let snippet = SnippetConfig::new(
1145 "test".to_string(),
1146 "Test Snippet".to_string(),
1147 "echo 'hello'".to_string(),
1148 )
1149 .with_keybinding("Ctrl+Shift+T".to_string())
1150 .with_folder("Test".to_string())
1151 .with_variable("name".to_string(), "value".to_string());
1152
1153 assert_eq!(snippet.keybinding, Some("Ctrl+Shift+T".to_string()));
1154 assert_eq!(snippet.folder, Some("Test".to_string()));
1155 assert_eq!(snippet.variables.get("name"), Some(&"value".to_string()));
1156 }
1157
1158 #[test]
1159 fn test_builtin_variable_resolution() {
1160 let date = BuiltInVariable::Date.resolve();
1162 assert!(!date.is_empty());
1163 assert_eq!(
1167 date.len(),
1168 10,
1169 "date must be 10 chars (YYYY-MM-DD), got {date:?}"
1170 );
1171 let mut parts = date.split('-');
1172 let (year, month, day) = (
1173 parts.next().unwrap(),
1174 parts.next().unwrap(),
1175 parts.next().unwrap(),
1176 );
1177 assert!(
1178 parts.next().is_none(),
1179 "date has extra components: {date:?}"
1180 );
1181 assert!(
1182 (1..=9999).contains(&(year.parse::<u32>().unwrap())),
1183 "year out of range: {year:?}"
1184 );
1185 assert!(
1186 (1..=12).contains(&(month.parse::<u32>().unwrap())),
1187 "month must be 1..=12, got {month:?} (date={date:?})"
1188 );
1189 assert!(
1190 (1..=31).contains(&(day.parse::<u32>().unwrap())),
1191 "day must be 1..=31, got {day:?} (date={date:?})"
1192 );
1193
1194 let time = BuiltInVariable::Time.resolve();
1195 assert!(!time.is_empty());
1196
1197 let user = BuiltInVariable::User.resolve();
1198 assert!(!user.is_empty());
1199
1200 let path = BuiltInVariable::Path.resolve();
1201 assert!(!path.is_empty());
1202 }
1203
1204 #[test]
1205 fn test_builtin_variable_parse() {
1206 assert_eq!(BuiltInVariable::parse("date"), Some(BuiltInVariable::Date));
1207 assert_eq!(BuiltInVariable::parse("time"), Some(BuiltInVariable::Time));
1208 assert_eq!(BuiltInVariable::parse("unknown"), None);
1209 }
1210
1211 #[test]
1212 fn test_custom_action_id() {
1213 let action = CustomActionConfig::ShellCommand {
1214 id: "test-action".to_string(),
1215 title: "Test Action".to_string(),
1216 command: "echo".to_string(),
1217 args: vec!["hello".to_string()],
1218 notify_on_success: false,
1219 timeout_secs: 30,
1220 capture_output: false,
1221 keybinding: None,
1222 prefix_char: Some('G'),
1223 keybinding_enabled: true,
1224 description: None,
1225 };
1226
1227 assert_eq!(action.id(), "test-action");
1228 assert_eq!(action.title(), "Test Action");
1229 assert!(action.is_shell_command());
1230 assert!(!action.is_new_tab());
1231 assert!(!action.is_insert_text());
1232 assert!(!action.is_key_sequence());
1233 assert!(!action.is_split_pane());
1234 assert_eq!(action.prefix_char(), Some('G'));
1235 assert_eq!(action.normalized_prefix_char(), Some('g'));
1236 }
1237
1238 #[test]
1239 fn prefix_follow_up_char_uses_single_char_keybinding_when_prefix_char_absent() {
1240 let action = CustomActionConfig::NewTab {
1241 id: "lenny1".to_string(),
1242 title: "lenny1".to_string(),
1243 command: Some("ssh root@lenny1".to_string()),
1244 keybinding: Some("1".to_string()),
1245 prefix_char: None,
1246 keybinding_enabled: true,
1247 description: None,
1248 };
1249
1250 assert_eq!(action.prefix_follow_up_char(), Some('1'));
1251 }
1252
1253 #[test]
1254 fn prefix_follow_up_char_prefers_explicit_prefix_char() {
1255 let action = CustomActionConfig::InsertText {
1256 id: "both".to_string(),
1257 title: "Both".to_string(),
1258 text: "x".to_string(),
1259 variables: HashMap::new(),
1260 keybinding: Some("1".to_string()),
1261 prefix_char: Some('G'),
1262 keybinding_enabled: true,
1263 description: None,
1264 };
1265
1266 assert_eq!(action.prefix_follow_up_char(), Some('g'));
1267 }
1268
1269 #[test]
1270 fn prefix_follow_up_char_ignores_chord_and_disabled_keybindings() {
1271 let chord = CustomActionConfig::InsertText {
1272 id: "chord".to_string(),
1273 title: "Chord".to_string(),
1274 text: "x".to_string(),
1275 variables: HashMap::new(),
1276 keybinding: Some("Ctrl+1".to_string()),
1277 prefix_char: None,
1278 keybinding_enabled: true,
1279 description: None,
1280 };
1281 let disabled = CustomActionConfig::InsertText {
1282 id: "disabled".to_string(),
1283 title: "Disabled".to_string(),
1284 text: "x".to_string(),
1285 variables: HashMap::new(),
1286 keybinding: Some("1".to_string()),
1287 prefix_char: None,
1288 keybinding_enabled: false,
1289 description: None,
1290 };
1291
1292 assert_eq!(chord.prefix_follow_up_char(), None);
1293 assert_eq!(disabled.prefix_follow_up_char(), None);
1294 }
1295
1296 #[test]
1297 fn test_split_pane_action() {
1298 let action = CustomActionConfig::SplitPane {
1299 id: "split-htop".to_string(),
1300 title: "Split and run htop".to_string(),
1301 direction: ActionSplitDirection::Vertical,
1302 command: Some("htop".to_string()),
1303 command_is_direct: true,
1304 focus_new_pane: true,
1305 delay_ms: 200,
1306 split_percent: 66,
1307 keybinding: Some("Ctrl+Shift+H".to_string()),
1308 prefix_char: None,
1309 keybinding_enabled: true,
1310 description: None,
1311 };
1312
1313 assert_eq!(action.id(), "split-htop");
1314 assert_eq!(action.title(), "Split and run htop");
1315 assert!(action.is_split_pane());
1316 assert!(!action.is_shell_command());
1317 assert_eq!(action.keybinding(), Some("Ctrl+Shift+H"));
1318 }
1319
1320 #[test]
1321 fn test_new_tab_action() {
1322 let action = CustomActionConfig::NewTab {
1323 id: "new-tab-lazygit".to_string(),
1324 title: "Open lazygit tab".to_string(),
1325 command: Some("lazygit".to_string()),
1326 keybinding: Some("Ctrl+Shift+G".to_string()),
1327 prefix_char: Some('g'),
1328 keybinding_enabled: true,
1329 description: None,
1330 };
1331
1332 assert_eq!(action.id(), "new-tab-lazygit");
1333 assert_eq!(action.title(), "Open lazygit tab");
1334 assert!(action.is_new_tab());
1335 assert!(!action.is_shell_command());
1336 assert!(!action.is_split_pane());
1337 assert_eq!(action.keybinding(), Some("Ctrl+Shift+G"));
1338 assert_eq!(action.normalized_prefix_char(), Some('g'));
1339 }
1340
1341 #[test]
1342 fn test_sequence_action_round_trip() {
1343 let action = CustomActionConfig::Sequence {
1344 id: "build-and-test".to_string(),
1345 title: "Build and Test".to_string(),
1346 keybinding: None,
1347 prefix_char: None,
1348 keybinding_enabled: true,
1349 description: None,
1350 steps: vec![
1351 SequenceStep {
1352 action_id: "build".to_string(),
1353 delay_ms: 0,
1354 on_failure: SequenceStepBehavior::Abort,
1355 },
1356 SequenceStep {
1357 action_id: "test".to_string(),
1358 delay_ms: 500,
1359 on_failure: SequenceStepBehavior::Continue,
1360 },
1361 ],
1362 };
1363 let yaml = serde_yaml_ng::to_string(&action).unwrap();
1364 let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1365 assert_eq!(action, roundtrip);
1366 assert_eq!(action.id(), "build-and-test");
1367 assert_eq!(action.title(), "Build and Test");
1368 }
1369
1370 #[test]
1371 fn test_condition_action_round_trip() {
1372 let action = CustomActionConfig::Condition {
1373 id: "check-main".to_string(),
1374 title: "Check Main Branch".to_string(),
1375 keybinding: None,
1376 prefix_char: None,
1377 keybinding_enabled: true,
1378 description: None,
1379 check: ConditionCheck::GitBranch {
1380 pattern: "main".to_string(),
1381 },
1382 on_true_id: Some("deploy".to_string()),
1383 on_false_id: None,
1384 };
1385 let yaml = serde_yaml_ng::to_string(&action).unwrap();
1386 let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1387 assert_eq!(action, roundtrip);
1388 assert_eq!(action.id(), "check-main");
1389 }
1390
1391 #[test]
1392 fn test_repeat_action_round_trip() {
1393 let action = CustomActionConfig::Repeat {
1394 id: "retry-deploy".to_string(),
1395 title: "Retry Deploy".to_string(),
1396 keybinding: None,
1397 prefix_char: None,
1398 keybinding_enabled: true,
1399 description: None,
1400 action_id: "deploy".to_string(),
1401 count: 3,
1402 delay_ms: 1000,
1403 stop_on_success: true,
1404 stop_on_failure: false,
1405 };
1406 let yaml = serde_yaml_ng::to_string(&action).unwrap();
1407 let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1408 assert_eq!(action, roundtrip);
1409 assert_eq!(action.id(), "retry-deploy");
1410 }
1411
1412 #[test]
1413 fn test_shell_command_capture_output_default_false() {
1414 let yaml = r#"
1415type: shell_command
1416id: test
1417title: Test
1418command: echo
1419"#;
1420 let action: CustomActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
1421 if let CustomActionConfig::ShellCommand { capture_output, .. } = action {
1422 assert!(!capture_output);
1423 } else {
1424 panic!("expected ShellCommand");
1425 }
1426 }
1427}