use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
static UNSAFE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"[/<>:"|?*\\#@&;$`!~%^()\[\]{}=+]+"#).unwrap());
static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
static MULTI_HYPHEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-+").unwrap());
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LaunchMethod {
Foreground,
Detach,
ItermWindow,
ItermTab,
ItermPaneH,
ItermPaneV,
Tmux,
TmuxWindow,
TmuxPaneH,
TmuxPaneV,
Zellij,
ZellijTab,
ZellijPaneH,
ZellijPaneV,
WeztermWindow,
WeztermTab,
WeztermPaneH,
WeztermPaneV,
WeztermTabBg,
}
impl LaunchMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Foreground => "foreground",
Self::Detach => "detach",
Self::ItermWindow => "iterm-window",
Self::ItermTab => "iterm-tab",
Self::ItermPaneH => "iterm-pane-h",
Self::ItermPaneV => "iterm-pane-v",
Self::Tmux => "tmux",
Self::TmuxWindow => "tmux-window",
Self::TmuxPaneH => "tmux-pane-h",
Self::TmuxPaneV => "tmux-pane-v",
Self::Zellij => "zellij",
Self::ZellijTab => "zellij-tab",
Self::ZellijPaneH => "zellij-pane-h",
Self::ZellijPaneV => "zellij-pane-v",
Self::WeztermWindow => "wezterm-window",
Self::WeztermTab => "wezterm-tab",
Self::WeztermPaneH => "wezterm-pane-h",
Self::WeztermPaneV => "wezterm-pane-v",
Self::WeztermTabBg => "wezterm-tab-bg",
}
}
pub fn from_str_opt(s: &str) -> Option<Self> {
match s {
"foreground" => Some(Self::Foreground),
"detach" => Some(Self::Detach),
"iterm-window" => Some(Self::ItermWindow),
"iterm-tab" => Some(Self::ItermTab),
"iterm-pane-h" => Some(Self::ItermPaneH),
"iterm-pane-v" => Some(Self::ItermPaneV),
"tmux" => Some(Self::Tmux),
"tmux-window" => Some(Self::TmuxWindow),
"tmux-pane-h" => Some(Self::TmuxPaneH),
"tmux-pane-v" => Some(Self::TmuxPaneV),
"zellij" => Some(Self::Zellij),
"zellij-tab" => Some(Self::ZellijTab),
"zellij-pane-h" => Some(Self::ZellijPaneH),
"zellij-pane-v" => Some(Self::ZellijPaneV),
"wezterm-window" => Some(Self::WeztermWindow),
"wezterm-tab" => Some(Self::WeztermTab),
"wezterm-pane-h" => Some(Self::WeztermPaneH),
"wezterm-pane-v" => Some(Self::WeztermPaneV),
"wezterm-tab-bg" => Some(Self::WeztermTabBg),
_ => None,
}
}
}
impl LaunchMethod {
pub fn display_name(&self) -> &'static str {
match self {
Self::Foreground => "Foreground",
Self::Detach => "Detach (background)",
Self::ItermWindow => "iTerm2 — New Window",
Self::ItermTab => "iTerm2 — New Tab",
Self::ItermPaneH => "iTerm2 — Horizontal Pane",
Self::ItermPaneV => "iTerm2 — Vertical Pane",
Self::Tmux => "tmux — New Session",
Self::TmuxWindow => "tmux — New Window",
Self::TmuxPaneH => "tmux — Horizontal Pane",
Self::TmuxPaneV => "tmux — Vertical Pane",
Self::Zellij => "Zellij — New Session",
Self::ZellijTab => "Zellij — New Tab",
Self::ZellijPaneH => "Zellij — Horizontal Pane",
Self::ZellijPaneV => "Zellij — Vertical Pane",
Self::WeztermWindow => "WezTerm — New Window",
Self::WeztermTab => "WezTerm — New Tab",
Self::WeztermPaneH => "WezTerm — Horizontal Pane",
Self::WeztermPaneV => "WezTerm — Vertical Pane",
Self::WeztermTabBg => "WezTerm — New Tab (Background)",
}
}
}
impl std::fmt::Display for LaunchMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
HashMap::from([
("fg", "foreground"),
("d", "detach"),
("i-w", "iterm-window"),
("i-t", "iterm-tab"),
("i-p-h", "iterm-pane-h"),
("i-p-v", "iterm-pane-v"),
("t", "tmux"),
("t-w", "tmux-window"),
("t-p-h", "tmux-pane-h"),
("t-p-v", "tmux-pane-v"),
("z", "zellij"),
("z-t", "zellij-tab"),
("z-p-h", "zellij-pane-h"),
("z-p-v", "zellij-pane-v"),
("w-w", "wezterm-window"),
("w-t", "wezterm-tab"),
("w-p-h", "wezterm-pane-h"),
("w-p-v", "wezterm-pane-v"),
("w-t-b", "wezterm-tab-bg"),
])
}
pub const HOOK_EVENTS: &[&str] = &[
"worktree.pre_create",
"worktree.post_create",
"worktree.pre_delete",
"worktree.post_delete",
"merge.pre",
"merge.post",
"pr.pre",
"pr.post",
"resume.pre",
"resume.post",
"sync.pre",
"sync.post",
];
pub const PRESET_NAMES: &[&str] = &[
"claude",
"claude-remote",
"claude-yolo",
"claude-yolo-remote",
"codex",
"codex-yolo",
"no-op",
];
pub fn all_term_values() -> Vec<&'static str> {
let mut values: Vec<&str> = vec![
"foreground",
"detach",
"iterm-window",
"iterm-tab",
"iterm-pane-h",
"iterm-pane-v",
"tmux",
"tmux-window",
"tmux-pane-h",
"tmux-pane-v",
"zellij",
"zellij-tab",
"zellij-pane-h",
"zellij-pane-v",
"wezterm-window",
"wezterm-tab",
"wezterm-tab-bg",
"wezterm-pane-h",
"wezterm-pane-v",
];
for alias in launch_method_aliases().keys() {
values.push(alias);
}
values.sort();
values
}
pub const SECS_PER_DAY: u64 = 86400;
pub const SECS_PER_DAY_F64: f64 = 86400.0;
pub const MIN_GIT_VERSION: &str = "2.31.0";
pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
pub const MIN_GIT_VERSION_MINOR: u32 = 31;
pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
pub const AI_TOOL_POLL_MS: u64 = 100;
pub const MAX_SESSION_NAME_LENGTH: usize = 50;
pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
pub const CONFIG_KEY_BASE_BRANCH: &str = "branch.{}.worktreeBase";
pub const CONFIG_KEY_BASE_PATH: &str = "worktree.{}.basePath";
pub const CONFIG_KEY_INTENDED_BRANCH: &str = "worktree.{}.intendedBranch";
pub fn format_config_key(template: &str, branch: &str) -> String {
template.replace("{}", branch)
}
pub fn home_dir_or_fallback() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
pub fn path_age_days(path: &Path) -> Option<f64> {
let mtime = path.metadata().and_then(|m| m.modified()).ok()?;
std::time::SystemTime::now()
.duration_since(mtime)
.ok()
.map(|d| d.as_secs_f64() / SECS_PER_DAY_F64)
}
pub fn version_meets_minimum(version_str: &str, min_major: u32, min_minor: u32) -> bool {
let parts: Vec<u32> = version_str
.split('.')
.filter_map(|p| p.parse().ok())
.collect();
parts.len() >= 2 && (parts[0] > min_major || (parts[0] == min_major && parts[1] >= min_minor))
}
pub fn sanitize_branch_name(branch_name: &str) -> String {
let safe = UNSAFE_RE.replace_all(branch_name, "-");
let safe = WHITESPACE_RE.replace_all(&safe, "-");
let safe = MULTI_HYPHEN_RE.replace_all(&safe, "-");
let safe = safe.trim_matches('-');
if safe.is_empty() {
"worktree".to_string()
} else {
safe.to_string()
}
}
pub fn default_worktree_path(repo_path: &Path, branch_name: &str) -> PathBuf {
let repo_path = strip_unc(
repo_path
.canonicalize()
.unwrap_or_else(|_| repo_path.to_path_buf()),
);
let safe_branch = sanitize_branch_name(branch_name);
let repo_name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
repo_path
.parent()
.unwrap_or(repo_path.as_path())
.join(format!("{}-{}", repo_name, safe_branch))
}
pub fn strip_unc(path: PathBuf) -> PathBuf {
#[cfg(target_os = "windows")]
{
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
return PathBuf::from(stripped);
}
}
path
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_branch_name() {
assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
assert_eq!(sanitize_branch_name("bugfix/issue-123"), "bugfix-issue-123");
assert_eq!(
sanitize_branch_name("feature/user@login"),
"feature-user-login"
);
assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
assert_eq!(sanitize_branch_name("///"), "worktree");
assert_eq!(sanitize_branch_name(""), "worktree");
assert_eq!(sanitize_branch_name("simple"), "simple");
}
#[test]
fn test_launch_method_roundtrip() {
for method in [
LaunchMethod::Foreground,
LaunchMethod::Detach,
LaunchMethod::ItermWindow,
LaunchMethod::Tmux,
LaunchMethod::Zellij,
LaunchMethod::WeztermTab,
] {
let s = method.as_str();
assert_eq!(LaunchMethod::from_str_opt(s), Some(method));
}
}
#[test]
fn test_format_config_key() {
assert_eq!(
format_config_key(CONFIG_KEY_BASE_BRANCH, "fix-auth"),
"branch.fix-auth.worktreeBase"
);
}
#[test]
fn test_home_dir_or_fallback() {
let home = home_dir_or_fallback();
assert!(!home.as_os_str().is_empty());
}
#[test]
fn test_path_age_days() {
assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
let tmp = std::env::temp_dir();
if let Some(age) = path_age_days(&tmp) {
assert!(age >= 0.0);
}
}
#[test]
fn test_all_term_values_contains_canonical_and_aliases() {
let values = all_term_values();
assert!(
values.len() >= 36,
"expected ≥36 term values, got {}",
values.len()
);
assert!(values.contains(&"foreground"));
assert!(values.contains(&"tmux"));
assert!(values.contains(&"wezterm-tab"));
assert!(values.contains(&"fg"));
assert!(values.contains(&"t"));
assert!(values.contains(&"w-t"));
}
#[test]
fn test_hook_events_not_empty() {
assert!(!HOOK_EVENTS.is_empty());
assert!(HOOK_EVENTS.contains(&"worktree.post_create"));
assert!(HOOK_EVENTS.contains(&"merge.pre"));
}
#[test]
fn test_preset_names_not_empty() {
assert!(!PRESET_NAMES.is_empty());
assert!(PRESET_NAMES.contains(&"claude"));
assert!(PRESET_NAMES.contains(&"codex"));
assert!(PRESET_NAMES.contains(&"no-op"));
}
#[test]
fn test_version_meets_minimum() {
assert!(version_meets_minimum("2.31.0", 2, 31));
assert!(version_meets_minimum("2.40.0", 2, 31));
assert!(version_meets_minimum("3.0.0", 2, 31));
assert!(!version_meets_minimum("2.30.0", 2, 31));
assert!(!version_meets_minimum("1.99.0", 2, 31));
assert!(!version_meets_minimum("", 2, 31));
assert!(!version_meets_minimum("2", 2, 31));
}
}