1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::LazyLock;
6
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10static UNSAFE_RE: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r#"[/<>:"|?*\\#@&;$`!~%^()\[\]{}=+]+"#).unwrap());
13static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
14static MULTI_HYPHEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-+").unwrap());
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum LaunchMethod {
20 Foreground,
21 Detach,
22 ItermWindow,
24 ItermTab,
25 ItermPaneH,
26 ItermPaneV,
27 Tmux,
29 TmuxWindow,
30 TmuxPaneH,
31 TmuxPaneV,
32 Zellij,
34 ZellijTab,
35 ZellijPaneH,
36 ZellijPaneV,
37 WeztermWindow,
39 WeztermTab,
40 WeztermPaneH,
41 WeztermPaneV,
42}
43
44impl LaunchMethod {
45 pub fn as_str(&self) -> &'static str {
47 match self {
48 Self::Foreground => "foreground",
49 Self::Detach => "detach",
50 Self::ItermWindow => "iterm-window",
51 Self::ItermTab => "iterm-tab",
52 Self::ItermPaneH => "iterm-pane-h",
53 Self::ItermPaneV => "iterm-pane-v",
54 Self::Tmux => "tmux",
55 Self::TmuxWindow => "tmux-window",
56 Self::TmuxPaneH => "tmux-pane-h",
57 Self::TmuxPaneV => "tmux-pane-v",
58 Self::Zellij => "zellij",
59 Self::ZellijTab => "zellij-tab",
60 Self::ZellijPaneH => "zellij-pane-h",
61 Self::ZellijPaneV => "zellij-pane-v",
62 Self::WeztermWindow => "wezterm-window",
63 Self::WeztermTab => "wezterm-tab",
64 Self::WeztermPaneH => "wezterm-pane-h",
65 Self::WeztermPaneV => "wezterm-pane-v",
66 }
67 }
68
69 pub fn from_str_opt(s: &str) -> Option<Self> {
71 match s {
72 "foreground" => Some(Self::Foreground),
73 "detach" => Some(Self::Detach),
74 "iterm-window" => Some(Self::ItermWindow),
75 "iterm-tab" => Some(Self::ItermTab),
76 "iterm-pane-h" => Some(Self::ItermPaneH),
77 "iterm-pane-v" => Some(Self::ItermPaneV),
78 "tmux" => Some(Self::Tmux),
79 "tmux-window" => Some(Self::TmuxWindow),
80 "tmux-pane-h" => Some(Self::TmuxPaneH),
81 "tmux-pane-v" => Some(Self::TmuxPaneV),
82 "zellij" => Some(Self::Zellij),
83 "zellij-tab" => Some(Self::ZellijTab),
84 "zellij-pane-h" => Some(Self::ZellijPaneH),
85 "zellij-pane-v" => Some(Self::ZellijPaneV),
86 "wezterm-window" => Some(Self::WeztermWindow),
87 "wezterm-tab" => Some(Self::WeztermTab),
88 "wezterm-pane-h" => Some(Self::WeztermPaneH),
89 "wezterm-pane-v" => Some(Self::WeztermPaneV),
90 _ => None,
91 }
92 }
93}
94
95impl LaunchMethod {
96 pub fn display_name(&self) -> &'static str {
98 match self {
99 Self::Foreground => "Foreground",
100 Self::Detach => "Detach (background)",
101 Self::ItermWindow => "iTerm2 — New Window",
102 Self::ItermTab => "iTerm2 — New Tab",
103 Self::ItermPaneH => "iTerm2 — Horizontal Pane",
104 Self::ItermPaneV => "iTerm2 — Vertical Pane",
105 Self::Tmux => "tmux — New Session",
106 Self::TmuxWindow => "tmux — New Window",
107 Self::TmuxPaneH => "tmux — Horizontal Pane",
108 Self::TmuxPaneV => "tmux — Vertical Pane",
109 Self::Zellij => "Zellij — New Session",
110 Self::ZellijTab => "Zellij — New Tab",
111 Self::ZellijPaneH => "Zellij — Horizontal Pane",
112 Self::ZellijPaneV => "Zellij — Vertical Pane",
113 Self::WeztermWindow => "WezTerm — New Window",
114 Self::WeztermTab => "WezTerm — New Tab",
115 Self::WeztermPaneH => "WezTerm — Horizontal Pane",
116 Self::WeztermPaneV => "WezTerm — Vertical Pane",
117 }
118 }
119}
120
121impl std::fmt::Display for LaunchMethod {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(self.as_str())
124 }
125}
126
127pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
132 HashMap::from([
133 ("fg", "foreground"),
134 ("d", "detach"),
135 ("i-w", "iterm-window"),
137 ("i-t", "iterm-tab"),
138 ("i-p-h", "iterm-pane-h"),
139 ("i-p-v", "iterm-pane-v"),
140 ("t", "tmux"),
142 ("t-w", "tmux-window"),
143 ("t-p-h", "tmux-pane-h"),
144 ("t-p-v", "tmux-pane-v"),
145 ("z", "zellij"),
147 ("z-t", "zellij-tab"),
148 ("z-p-h", "zellij-pane-h"),
149 ("z-p-v", "zellij-pane-v"),
150 ("w-w", "wezterm-window"),
152 ("w-t", "wezterm-tab"),
153 ("w-p-h", "wezterm-pane-h"),
154 ("w-p-v", "wezterm-pane-v"),
155 ])
156}
157
158pub const SECS_PER_DAY: u64 = 86400;
160
161pub const SECS_PER_DAY_F64: f64 = 86400.0;
163
164pub const MIN_GIT_VERSION: &str = "2.31.0";
166
167pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
169
170pub const MIN_GIT_VERSION_MINOR: u32 = 31;
172
173pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
175
176pub const AI_TOOL_POLL_MS: u64 = 100;
178
179pub const MAX_SESSION_NAME_LENGTH: usize = 50;
182
183pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
185
186pub const CONFIG_KEY_BASE_BRANCH: &str = "branch.{}.worktreeBase";
188pub const CONFIG_KEY_BASE_PATH: &str = "worktree.{}.basePath";
189pub const CONFIG_KEY_INTENDED_BRANCH: &str = "worktree.{}.intendedBranch";
190
191pub fn format_config_key(template: &str, branch: &str) -> String {
193 template.replace("{}", branch)
194}
195
196pub fn home_dir_or_fallback() -> PathBuf {
198 dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
199}
200
201pub fn path_age_days(path: &Path) -> Option<f64> {
203 let mtime = path.metadata().and_then(|m| m.modified()).ok()?;
204 std::time::SystemTime::now()
205 .duration_since(mtime)
206 .ok()
207 .map(|d| d.as_secs_f64() / SECS_PER_DAY_F64)
208}
209
210pub fn version_meets_minimum(version_str: &str, min_major: u32, min_minor: u32) -> bool {
212 let parts: Vec<u32> = version_str
213 .split('.')
214 .filter_map(|p| p.parse().ok())
215 .collect();
216 parts.len() >= 2 && (parts[0] > min_major || (parts[0] == min_major && parts[1] >= min_minor))
217}
218
219pub fn sanitize_branch_name(branch_name: &str) -> String {
232 let safe = UNSAFE_RE.replace_all(branch_name, "-");
233 let safe = WHITESPACE_RE.replace_all(&safe, "-");
234 let safe = MULTI_HYPHEN_RE.replace_all(&safe, "-");
235 let safe = safe.trim_matches('-');
236
237 if safe.is_empty() {
238 "worktree".to_string()
239 } else {
240 safe.to_string()
241 }
242}
243
244pub fn default_worktree_path(repo_path: &Path, branch_name: &str) -> PathBuf {
246 let repo_path = strip_unc(
247 repo_path
248 .canonicalize()
249 .unwrap_or_else(|_| repo_path.to_path_buf()),
250 );
251 let safe_branch = sanitize_branch_name(branch_name);
252 let repo_name = repo_path
253 .file_name()
254 .map(|n| n.to_string_lossy().to_string())
255 .unwrap_or_else(|| "repo".to_string());
256
257 repo_path
258 .parent()
259 .unwrap_or(repo_path.as_path())
260 .join(format!("{}-{}", repo_name, safe_branch))
261}
262
263pub fn strip_unc(path: PathBuf) -> PathBuf {
266 #[cfg(target_os = "windows")]
267 {
268 let s = path.to_string_lossy();
269 if let Some(stripped) = s.strip_prefix(r"\\?\") {
270 return PathBuf::from(stripped);
271 }
272 }
273 path
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn test_sanitize_branch_name() {
282 assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
283 assert_eq!(sanitize_branch_name("bugfix/issue-123"), "bugfix-issue-123");
284 assert_eq!(
285 sanitize_branch_name("feature/user@login"),
286 "feature-user-login"
287 );
288 assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
289 assert_eq!(sanitize_branch_name("///"), "worktree");
290 assert_eq!(sanitize_branch_name(""), "worktree");
291 assert_eq!(sanitize_branch_name("simple"), "simple");
292 }
293
294 #[test]
295 fn test_launch_method_roundtrip() {
296 for method in [
297 LaunchMethod::Foreground,
298 LaunchMethod::Detach,
299 LaunchMethod::ItermWindow,
300 LaunchMethod::Tmux,
301 LaunchMethod::Zellij,
302 LaunchMethod::WeztermTab,
303 ] {
304 let s = method.as_str();
305 assert_eq!(LaunchMethod::from_str_opt(s), Some(method));
306 }
307 }
308
309 #[test]
310 fn test_format_config_key() {
311 assert_eq!(
312 format_config_key(CONFIG_KEY_BASE_BRANCH, "fix-auth"),
313 "branch.fix-auth.worktreeBase"
314 );
315 }
316
317 #[test]
318 fn test_home_dir_or_fallback() {
319 let home = home_dir_or_fallback();
320 assert!(!home.as_os_str().is_empty());
322 }
323
324 #[test]
325 fn test_path_age_days() {
326 assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
328
329 let tmp = std::env::temp_dir();
331 if let Some(age) = path_age_days(&tmp) {
332 assert!(age >= 0.0);
333 }
334 }
335
336 #[test]
337 fn test_version_meets_minimum() {
338 assert!(version_meets_minimum("2.31.0", 2, 31));
339 assert!(version_meets_minimum("2.40.0", 2, 31));
340 assert!(version_meets_minimum("3.0.0", 2, 31));
341 assert!(!version_meets_minimum("2.30.0", 2, 31));
342 assert!(!version_meets_minimum("1.99.0", 2, 31));
343 assert!(!version_meets_minimum("", 2, 31));
344 assert!(!version_meets_minimum("2", 2, 31));
345 }
346}