use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const fn default_shell_command_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SnippetConfig {
pub id: String,
pub title: String,
pub content: String,
#[serde(default)]
pub keybinding: Option<String>,
#[serde(default = "crate::defaults::bool_true")]
pub keybinding_enabled: bool,
#[serde(default)]
pub folder: Option<String>,
#[serde(default = "crate::defaults::bool_true")]
pub enabled: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub auto_execute: bool,
#[serde(default)]
pub variables: HashMap<String, String>,
}
impl SnippetConfig {
pub fn new(id: String, title: String, content: String) -> Self {
Self {
id,
title,
content,
keybinding: None,
keybinding_enabled: true,
folder: None,
enabled: true,
description: None,
auto_execute: false,
variables: HashMap::new(),
}
}
pub fn with_keybinding(mut self, keybinding: String) -> Self {
self.keybinding = Some(keybinding);
self
}
pub fn with_keybinding_disabled(mut self) -> Self {
self.keybinding_enabled = false;
self
}
pub fn with_folder(mut self, folder: String) -> Self {
self.folder = Some(folder);
self
}
pub fn with_variable(mut self, name: String, value: String) -> Self {
self.variables.insert(name, value);
self
}
pub fn with_auto_execute(mut self) -> Self {
self.auto_execute = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnippetLibrary {
pub snippets: Vec<SnippetConfig>,
}
const fn default_split_pane_delay_ms() -> u64 {
200
}
pub fn normalize_action_prefix_char(ch: char) -> char {
if ch.is_ascii_alphabetic() {
ch.to_ascii_lowercase()
} else {
ch
}
}
const fn default_split_percent() -> u8 {
66
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ActionBase {
pub id: String,
pub title: String,
pub keybinding: Option<String>,
pub prefix_char: Option<char>,
pub keybinding_enabled: bool,
pub description: Option<String>,
}
impl ActionBase {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
keybinding: None,
prefix_char: None,
keybinding_enabled: true,
description: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ActionSplitDirection {
#[default]
Horizontal,
Vertical,
}
impl ActionSplitDirection {
pub fn all() -> &'static [ActionSplitDirection] {
&[Self::Horizontal, Self::Vertical]
}
pub fn label(self) -> &'static str {
match self {
Self::Horizontal => "Horizontal (below)",
Self::Vertical => "Vertical (right)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SequenceStepBehavior {
#[default]
Abort,
Stop,
Continue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SequenceStep {
pub action_id: String,
#[serde(default)]
pub delay_ms: u64,
#[serde(default)]
pub on_failure: SequenceStepBehavior,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ConditionCheck {
ExitCode { value: i32 },
OutputContains {
pattern: String,
#[serde(default)]
case_sensitive: bool,
},
EnvVar {
name: String,
#[serde(default)]
value: Option<String>,
},
DirMatches { pattern: String },
GitBranch { pattern: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CustomActionConfig {
ShellCommand {
id: String,
title: String,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
notify_on_success: bool,
#[serde(default = "default_shell_command_timeout_secs")]
timeout_secs: u64,
#[serde(default)]
capture_output: bool,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
},
NewTab {
id: String,
title: String,
#[serde(default)]
command: Option<String>,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
},
InsertText {
id: String,
title: String,
text: String,
#[serde(default)]
variables: HashMap<String, String>,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
},
KeySequence {
id: String,
title: String,
keys: String,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
},
SplitPane {
id: String,
title: String,
#[serde(default)]
direction: ActionSplitDirection,
#[serde(default)]
command: Option<String>,
#[serde(default)]
command_is_direct: bool,
#[serde(default = "crate::defaults::bool_true")]
focus_new_pane: bool,
#[serde(default = "default_split_pane_delay_ms")]
delay_ms: u64,
#[serde(default = "default_split_percent")]
split_percent: u8,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
},
Sequence {
id: String,
title: String,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
#[serde(default)]
steps: Vec<SequenceStep>,
},
Condition {
id: String,
title: String,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
check: ConditionCheck,
#[serde(default)]
on_true_id: Option<String>,
#[serde(default)]
on_false_id: Option<String>,
},
Repeat {
id: String,
title: String,
#[serde(default)]
keybinding: Option<String>,
#[serde(default)]
prefix_char: Option<char>,
#[serde(default = "crate::defaults::bool_true")]
keybinding_enabled: bool,
#[serde(default)]
description: Option<String>,
action_id: String,
count: u32,
#[serde(default)]
delay_ms: u64,
#[serde(default)]
stop_on_success: bool,
#[serde(default)]
stop_on_failure: bool,
},
}
impl CustomActionConfig {
pub fn base(&self) -> ActionBase {
match self {
Self::ShellCommand {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::NewTab {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::InsertText {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::KeySequence {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::SplitPane {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::Sequence {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::Condition {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::Repeat {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
} => ActionBase {
id: id.clone(),
title: title.clone(),
keybinding: keybinding.clone(),
prefix_char: *prefix_char,
keybinding_enabled: *keybinding_enabled,
description: description.clone(),
},
}
}
pub fn apply_base(&mut self, base: ActionBase) {
match self {
Self::ShellCommand {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::NewTab {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::InsertText {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::KeySequence {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::SplitPane {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::Sequence {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::Condition {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
}
| Self::Repeat {
id,
title,
keybinding,
prefix_char,
keybinding_enabled,
description,
..
} => {
*id = base.id;
*title = base.title;
*keybinding = base.keybinding;
*prefix_char = base.prefix_char;
*keybinding_enabled = base.keybinding_enabled;
*description = base.description;
}
}
}
pub fn id(&self) -> &str {
match self {
Self::ShellCommand { id, .. }
| Self::NewTab { id, .. }
| Self::InsertText { id, .. }
| Self::KeySequence { id, .. }
| Self::SplitPane { id, .. }
| Self::Sequence { id, .. }
| Self::Condition { id, .. }
| Self::Repeat { id, .. } => id,
}
}
pub fn title(&self) -> &str {
match self {
Self::ShellCommand { title, .. }
| Self::NewTab { title, .. }
| Self::InsertText { title, .. }
| Self::KeySequence { title, .. }
| Self::SplitPane { title, .. }
| Self::Sequence { title, .. }
| Self::Condition { title, .. }
| Self::Repeat { title, .. } => title,
}
}
pub fn keybinding(&self) -> Option<&str> {
match self {
Self::ShellCommand { keybinding, .. }
| Self::NewTab { keybinding, .. }
| Self::InsertText { keybinding, .. }
| Self::KeySequence { keybinding, .. }
| Self::SplitPane { keybinding, .. }
| Self::Sequence { keybinding, .. }
| Self::Condition { keybinding, .. }
| Self::Repeat { keybinding, .. } => keybinding.as_deref(),
}
}
pub fn prefix_char(&self) -> Option<char> {
match self {
Self::ShellCommand { prefix_char, .. }
| Self::NewTab { prefix_char, .. }
| Self::InsertText { prefix_char, .. }
| Self::KeySequence { prefix_char, .. }
| Self::SplitPane { prefix_char, .. }
| Self::Sequence { prefix_char, .. }
| Self::Condition { prefix_char, .. }
| Self::Repeat { prefix_char, .. } => *prefix_char,
}
}
pub fn normalized_prefix_char(&self) -> Option<char> {
self.prefix_char().map(normalize_action_prefix_char)
}
pub fn keybinding_enabled(&self) -> bool {
match self {
Self::ShellCommand {
keybinding_enabled, ..
}
| Self::NewTab {
keybinding_enabled, ..
}
| Self::InsertText {
keybinding_enabled, ..
}
| Self::KeySequence {
keybinding_enabled, ..
}
| Self::SplitPane {
keybinding_enabled, ..
}
| Self::Sequence {
keybinding_enabled, ..
}
| Self::Condition {
keybinding_enabled, ..
}
| Self::Repeat {
keybinding_enabled, ..
} => *keybinding_enabled,
}
}
pub fn set_keybinding(&mut self, kb: Option<String>) {
let mut base = self.base();
base.keybinding = kb;
self.apply_base(base);
}
pub fn set_prefix_char(&mut self, prefix_char: Option<char>) {
let mut base = self.base();
base.prefix_char = prefix_char;
self.apply_base(base);
}
pub fn set_keybinding_enabled(&mut self, enabled: bool) {
let mut base = self.base();
base.keybinding_enabled = enabled;
self.apply_base(base);
}
pub fn is_shell_command(&self) -> bool {
matches!(self, Self::ShellCommand { .. })
}
pub fn is_new_tab(&self) -> bool {
matches!(self, Self::NewTab { .. })
}
pub fn is_insert_text(&self) -> bool {
matches!(self, Self::InsertText { .. })
}
pub fn is_key_sequence(&self) -> bool {
matches!(self, Self::KeySequence { .. })
}
pub fn is_split_pane(&self) -> bool {
matches!(self, Self::SplitPane { .. })
}
pub fn is_sequence(&self) -> bool {
matches!(self, Self::Sequence { .. })
}
pub fn is_condition(&self) -> bool {
matches!(self, Self::Condition { .. })
}
pub fn is_repeat(&self) -> bool {
matches!(self, Self::Repeat { .. })
}
pub fn into_copy(&self) -> Self {
let mut cloned = self.clone();
let mut base = cloned.base();
base.id = format!("action_{}", uuid::Uuid::new_v4());
base.title = format!("{}-copy", base.title);
base.keybinding = None;
base.prefix_char = None;
cloned.apply_base(base);
cloned
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BuiltInVariable {
Date,
Time,
DateTime,
Hostname,
User,
Path,
GitBranch,
GitCommit,
Uuid,
Random,
}
impl BuiltInVariable {
pub fn all() -> &'static [(&'static str, &'static str)] {
&[
("date", "Current date (YYYY-MM-DD)"),
("time", "Current time (HH:MM:SS)"),
("datetime", "Current date and time"),
("hostname", "System hostname"),
("user", "Current username"),
("path", "Current working directory"),
("git_branch", "Current git branch"),
("git_commit", "Current git commit hash"),
("uuid", "Random UUID"),
("random", "Random number (0-999999)"),
]
}
pub fn parse(name: &str) -> Option<Self> {
match name {
"date" => Some(Self::Date),
"time" => Some(Self::Time),
"datetime" => Some(Self::DateTime),
"hostname" => Some(Self::Hostname),
"user" => Some(Self::User),
"path" => Some(Self::Path),
"git_branch" => Some(Self::GitBranch),
"git_commit" => Some(Self::GitCommit),
"uuid" => Some(Self::Uuid),
"random" => Some(Self::Random),
_ => None,
}
}
pub fn resolve(&self) -> String {
match self {
Self::Date => {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days_since_epoch = secs / 86400;
let years = 1970 + days_since_epoch / 365;
let day_of_year = (days_since_epoch % 365) as u32;
let month = (day_of_year / 30) + 1;
let day = (day_of_year % 30) + 1;
format!("{:04}-{:02}-{:02}", years, month, day)
}
Self::Time => {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let hours = (secs % 86400) / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}
Self::DateTime => {
format!("{} {}", Self::Date.resolve(), Self::Time.resolve())
}
Self::Hostname => {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| {
hostname::get()
.ok()
.and_then(|s| s.into_string().ok())
.unwrap_or_else(|| "unknown".to_string())
})
}
Self::User => std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string()),
Self::Path => std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| ".".to_string()),
Self::GitBranch => {
match std::env::var("GIT_BRANCH") {
Ok(branch) => branch,
Err(_) => {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
}
}
Self::GitCommit => {
match std::env::var("GIT_COMMIT") {
Ok(commit) => commit,
Err(_) => std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default(),
}
}
Self::Uuid => uuid::Uuid::new_v4().to_string(),
Self::Random => {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}", (duration.as_nanos() % 1_000_000) as u32)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snippet_new() {
let snippet = SnippetConfig::new(
"test".to_string(),
"Test Snippet".to_string(),
"echo 'hello'".to_string(),
);
assert_eq!(snippet.id, "test");
assert_eq!(snippet.title, "Test Snippet");
assert_eq!(snippet.content, "echo 'hello'");
assert!(snippet.enabled);
assert!(snippet.keybinding.is_none());
assert!(snippet.folder.is_none());
assert!(snippet.variables.is_empty());
}
#[test]
fn test_snippet_builder() {
let snippet = SnippetConfig::new(
"test".to_string(),
"Test Snippet".to_string(),
"echo 'hello'".to_string(),
)
.with_keybinding("Ctrl+Shift+T".to_string())
.with_folder("Test".to_string())
.with_variable("name".to_string(), "value".to_string());
assert_eq!(snippet.keybinding, Some("Ctrl+Shift+T".to_string()));
assert_eq!(snippet.folder, Some("Test".to_string()));
assert_eq!(snippet.variables.get("name"), Some(&"value".to_string()));
}
#[test]
fn test_builtin_variable_resolution() {
let date = BuiltInVariable::Date.resolve();
assert!(!date.is_empty());
let time = BuiltInVariable::Time.resolve();
assert!(!time.is_empty());
let user = BuiltInVariable::User.resolve();
assert!(!user.is_empty());
let path = BuiltInVariable::Path.resolve();
assert!(!path.is_empty());
}
#[test]
fn test_builtin_variable_parse() {
assert_eq!(BuiltInVariable::parse("date"), Some(BuiltInVariable::Date));
assert_eq!(BuiltInVariable::parse("time"), Some(BuiltInVariable::Time));
assert_eq!(BuiltInVariable::parse("unknown"), None);
}
#[test]
fn test_custom_action_id() {
let action = CustomActionConfig::ShellCommand {
id: "test-action".to_string(),
title: "Test Action".to_string(),
command: "echo".to_string(),
args: vec!["hello".to_string()],
notify_on_success: false,
timeout_secs: 30,
capture_output: false,
keybinding: None,
prefix_char: Some('G'),
keybinding_enabled: true,
description: None,
};
assert_eq!(action.id(), "test-action");
assert_eq!(action.title(), "Test Action");
assert!(action.is_shell_command());
assert!(!action.is_new_tab());
assert!(!action.is_insert_text());
assert!(!action.is_key_sequence());
assert!(!action.is_split_pane());
assert_eq!(action.prefix_char(), Some('G'));
assert_eq!(action.normalized_prefix_char(), Some('g'));
}
#[test]
fn test_split_pane_action() {
let action = CustomActionConfig::SplitPane {
id: "split-htop".to_string(),
title: "Split and run htop".to_string(),
direction: ActionSplitDirection::Vertical,
command: Some("htop".to_string()),
command_is_direct: true,
focus_new_pane: true,
delay_ms: 200,
split_percent: 66,
keybinding: Some("Ctrl+Shift+H".to_string()),
prefix_char: None,
keybinding_enabled: true,
description: None,
};
assert_eq!(action.id(), "split-htop");
assert_eq!(action.title(), "Split and run htop");
assert!(action.is_split_pane());
assert!(!action.is_shell_command());
assert_eq!(action.keybinding(), Some("Ctrl+Shift+H"));
}
#[test]
fn test_new_tab_action() {
let action = CustomActionConfig::NewTab {
id: "new-tab-lazygit".to_string(),
title: "Open lazygit tab".to_string(),
command: Some("lazygit".to_string()),
keybinding: Some("Ctrl+Shift+G".to_string()),
prefix_char: Some('g'),
keybinding_enabled: true,
description: None,
};
assert_eq!(action.id(), "new-tab-lazygit");
assert_eq!(action.title(), "Open lazygit tab");
assert!(action.is_new_tab());
assert!(!action.is_shell_command());
assert!(!action.is_split_pane());
assert_eq!(action.keybinding(), Some("Ctrl+Shift+G"));
assert_eq!(action.normalized_prefix_char(), Some('g'));
}
#[test]
fn test_sequence_action_round_trip() {
let action = CustomActionConfig::Sequence {
id: "build-and-test".to_string(),
title: "Build and Test".to_string(),
keybinding: None,
prefix_char: None,
keybinding_enabled: true,
description: None,
steps: vec![
SequenceStep {
action_id: "build".to_string(),
delay_ms: 0,
on_failure: SequenceStepBehavior::Abort,
},
SequenceStep {
action_id: "test".to_string(),
delay_ms: 500,
on_failure: SequenceStepBehavior::Continue,
},
],
};
let yaml = serde_yaml_ng::to_string(&action).unwrap();
let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(action, roundtrip);
assert_eq!(action.id(), "build-and-test");
assert_eq!(action.title(), "Build and Test");
}
#[test]
fn test_condition_action_round_trip() {
let action = CustomActionConfig::Condition {
id: "check-main".to_string(),
title: "Check Main Branch".to_string(),
keybinding: None,
prefix_char: None,
keybinding_enabled: true,
description: None,
check: ConditionCheck::GitBranch {
pattern: "main".to_string(),
},
on_true_id: Some("deploy".to_string()),
on_false_id: None,
};
let yaml = serde_yaml_ng::to_string(&action).unwrap();
let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(action, roundtrip);
assert_eq!(action.id(), "check-main");
}
#[test]
fn test_repeat_action_round_trip() {
let action = CustomActionConfig::Repeat {
id: "retry-deploy".to_string(),
title: "Retry Deploy".to_string(),
keybinding: None,
prefix_char: None,
keybinding_enabled: true,
description: None,
action_id: "deploy".to_string(),
count: 3,
delay_ms: 1000,
stop_on_success: true,
stop_on_failure: false,
};
let yaml = serde_yaml_ng::to_string(&action).unwrap();
let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(action, roundtrip);
assert_eq!(action.id(), "retry-deploy");
}
#[test]
fn test_shell_command_capture_output_default_false() {
let yaml = r#"
type: shell_command
id: test
title: Test
command: echo
"#;
let action: CustomActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
if let CustomActionConfig::ShellCommand { capture_output, .. } = action {
assert!(!capture_output);
} else {
panic!("expected ShellCommand");
}
}
}