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 Skip,
23 Foreground,
24 Detach,
25 ItermWindow,
27 ItermTab,
28 ItermPaneH,
29 ItermPaneV,
30 Tmux,
32 TmuxWindow,
33 TmuxPaneH,
34 TmuxPaneV,
35 Zellij,
37 ZellijTab,
38 ZellijPaneH,
39 ZellijPaneV,
40 WeztermWindow,
42 WeztermTab,
43 WeztermPaneH,
44 WeztermPaneV,
45 WeztermTabBg,
46}
47
48impl LaunchMethod {
49 pub fn as_str(&self) -> &'static str {
51 match self {
52 Self::Skip => "skip",
53 Self::Foreground => "foreground",
54 Self::Detach => "detach",
55 Self::ItermWindow => "iterm-window",
56 Self::ItermTab => "iterm-tab",
57 Self::ItermPaneH => "iterm-pane-h",
58 Self::ItermPaneV => "iterm-pane-v",
59 Self::Tmux => "tmux",
60 Self::TmuxWindow => "tmux-window",
61 Self::TmuxPaneH => "tmux-pane-h",
62 Self::TmuxPaneV => "tmux-pane-v",
63 Self::Zellij => "zellij",
64 Self::ZellijTab => "zellij-tab",
65 Self::ZellijPaneH => "zellij-pane-h",
66 Self::ZellijPaneV => "zellij-pane-v",
67 Self::WeztermWindow => "wezterm-window",
68 Self::WeztermTab => "wezterm-tab",
69 Self::WeztermPaneH => "wezterm-pane-h",
70 Self::WeztermPaneV => "wezterm-pane-v",
71 Self::WeztermTabBg => "wezterm-tab-bg",
72 }
73 }
74
75 pub fn from_str_opt(s: &str) -> Option<Self> {
77 match s {
78 "skip" | "none" | "noop" => Some(Self::Skip),
83 "foreground" => Some(Self::Foreground),
84 "detach" => Some(Self::Detach),
85 "iterm-window" => Some(Self::ItermWindow),
86 "iterm-tab" => Some(Self::ItermTab),
87 "iterm-pane-h" => Some(Self::ItermPaneH),
88 "iterm-pane-v" => Some(Self::ItermPaneV),
89 "tmux" => Some(Self::Tmux),
90 "tmux-window" => Some(Self::TmuxWindow),
91 "tmux-pane-h" => Some(Self::TmuxPaneH),
92 "tmux-pane-v" => Some(Self::TmuxPaneV),
93 "zellij" => Some(Self::Zellij),
94 "zellij-tab" => Some(Self::ZellijTab),
95 "zellij-pane-h" => Some(Self::ZellijPaneH),
96 "zellij-pane-v" => Some(Self::ZellijPaneV),
97 "wezterm-window" => Some(Self::WeztermWindow),
98 "wezterm-tab" => Some(Self::WeztermTab),
99 "wezterm-pane-h" => Some(Self::WeztermPaneH),
100 "wezterm-pane-v" => Some(Self::WeztermPaneV),
101 "wezterm-tab-bg" => Some(Self::WeztermTabBg),
102 _ => None,
103 }
104 }
105}
106
107impl LaunchMethod {
108 pub fn to_bg(&self) -> Option<Self> {
112 match self {
113 Self::Foreground => Some(Self::Detach),
114 Self::WeztermTab => Some(Self::WeztermTabBg),
115 _ => None,
116 }
117 }
118
119 pub fn to_fg(&self) -> Option<Self> {
122 match self {
123 Self::Detach => Some(Self::Foreground),
124 Self::WeztermTabBg => Some(Self::WeztermTab),
125 _ => None,
126 }
127 }
128}
129
130impl LaunchMethod {
131 pub fn display_name(&self) -> &'static str {
133 match self {
134 Self::Skip => "Skip (don't launch AI tool)",
135 Self::Foreground => "Foreground",
136 Self::Detach => "Detach (background)",
137 Self::ItermWindow => "iTerm2 — New Window",
138 Self::ItermTab => "iTerm2 — New Tab",
139 Self::ItermPaneH => "iTerm2 — Horizontal Pane",
140 Self::ItermPaneV => "iTerm2 — Vertical Pane",
141 Self::Tmux => "tmux — New Session",
142 Self::TmuxWindow => "tmux — New Window",
143 Self::TmuxPaneH => "tmux — Horizontal Pane",
144 Self::TmuxPaneV => "tmux — Vertical Pane",
145 Self::Zellij => "Zellij — New Session",
146 Self::ZellijTab => "Zellij — New Tab",
147 Self::ZellijPaneH => "Zellij — Horizontal Pane",
148 Self::ZellijPaneV => "Zellij — Vertical Pane",
149 Self::WeztermWindow => "WezTerm — New Window",
150 Self::WeztermTab => "WezTerm — New Tab",
151 Self::WeztermPaneH => "WezTerm — Horizontal Pane",
152 Self::WeztermPaneV => "WezTerm — Vertical Pane",
153 Self::WeztermTabBg => "WezTerm — New Tab (Background)",
154 }
155 }
156}
157
158impl std::fmt::Display for LaunchMethod {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.write_str(self.as_str())
161 }
162}
163
164pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
169 HashMap::from([
170 ("fg", "foreground"),
171 ("d", "detach"),
172 ("i-w", "iterm-window"),
174 ("i-t", "iterm-tab"),
175 ("i-p-h", "iterm-pane-h"),
176 ("i-p-v", "iterm-pane-v"),
177 ("t", "tmux"),
179 ("t-w", "tmux-window"),
180 ("t-p-h", "tmux-pane-h"),
181 ("t-p-v", "tmux-pane-v"),
182 ("z", "zellij"),
184 ("z-t", "zellij-tab"),
185 ("z-p-h", "zellij-pane-h"),
186 ("z-p-v", "zellij-pane-v"),
187 ("w-w", "wezterm-window"),
189 ("w-t", "wezterm-tab"),
190 ("w-p-h", "wezterm-pane-h"),
191 ("w-p-v", "wezterm-pane-v"),
192 ("w-t-b", "wezterm-tab-bg"),
193 ])
194}
195
196pub const PRESET_NAMES: &[&str] = &[
198 "claude",
199 "claude-remote",
200 "claude-yolo",
201 "claude-yolo-remote",
202 "codex",
203 "codex-yolo",
204 "no-op",
205];
206
207pub const SECS_PER_DAY: u64 = 86400;
209
210pub const SECS_PER_DAY_F64: f64 = 86400.0;
212
213pub const MIN_GIT_VERSION: &str = "2.31.0";
215
216pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
218
219pub const MIN_GIT_VERSION_MINOR: u32 = 31;
221
222pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
224
225pub const AI_TOOL_POLL_MS: u64 = 100;
227
228pub const MAX_SESSION_NAME_LENGTH: usize = 50;
231
232pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
234
235pub const CONFIG_KEY_BASE_BRANCH: &str = "branch.{}.worktreeBase";
237pub const CONFIG_KEY_BASE_PATH: &str = "worktree.{}.basePath";
238pub const CONFIG_KEY_INTENDED_BRANCH: &str = "worktree.{}.intendedBranch";
239
240pub fn format_config_key(template: &str, branch: &str) -> String {
242 template.replace("{}", branch)
243}
244
245pub fn home_dir_or_fallback() -> PathBuf {
247 dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
248}
249
250pub fn path_age_days(path: &Path) -> Option<f64> {
252 let mtime = path.metadata().and_then(|m| m.modified()).ok()?;
253 std::time::SystemTime::now()
254 .duration_since(mtime)
255 .ok()
256 .map(|d| d.as_secs_f64() / SECS_PER_DAY_F64)
257}
258
259pub fn version_meets_minimum(version_str: &str, min_major: u32, min_minor: u32) -> bool {
261 let parts: Vec<u32> = version_str
262 .split('.')
263 .filter_map(|p| p.parse().ok())
264 .collect();
265 parts.len() >= 2 && (parts[0] > min_major || (parts[0] == min_major && parts[1] >= min_minor))
266}
267
268pub fn sanitize_branch_name(branch_name: &str) -> String {
281 let safe = UNSAFE_RE.replace_all(branch_name, "-");
282 let safe = WHITESPACE_RE.replace_all(&safe, "-");
283 let safe = MULTI_HYPHEN_RE.replace_all(&safe, "-");
284 let safe = safe.trim_matches('-');
285
286 if safe.is_empty() {
287 "worktree".to_string()
288 } else {
289 safe.to_string()
290 }
291}
292
293pub fn default_worktree_path(repo_path: &Path, branch_name: &str) -> PathBuf {
295 let repo_path = strip_unc(
296 repo_path
297 .canonicalize()
298 .unwrap_or_else(|_| repo_path.to_path_buf()),
299 );
300 let safe_branch = sanitize_branch_name(branch_name);
301 let repo_name = repo_path
302 .file_name()
303 .map(|n| n.to_string_lossy().to_string())
304 .unwrap_or_else(|| "repo".to_string());
305
306 repo_path
307 .parent()
308 .unwrap_or(repo_path.as_path())
309 .join(format!("{}-{}", repo_name, safe_branch))
310}
311
312pub fn strip_unc(path: PathBuf) -> PathBuf {
315 #[cfg(target_os = "windows")]
316 {
317 let s = path.to_string_lossy();
318 if let Some(stripped) = s.strip_prefix(r"\\?\") {
319 return PathBuf::from(stripped);
320 }
321 }
322 path
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn test_sanitize_branch_name() {
331 assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
332 assert_eq!(sanitize_branch_name("bugfix/issue-123"), "bugfix-issue-123");
333 assert_eq!(
334 sanitize_branch_name("feature/user@login"),
335 "feature-user-login"
336 );
337 assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
338 assert_eq!(sanitize_branch_name("///"), "worktree");
339 assert_eq!(sanitize_branch_name(""), "worktree");
340 assert_eq!(sanitize_branch_name("simple"), "simple");
341 }
342
343 #[test]
344 fn test_launch_method_roundtrip() {
345 for method in [
346 LaunchMethod::Foreground,
347 LaunchMethod::Detach,
348 LaunchMethod::ItermWindow,
349 LaunchMethod::Tmux,
350 LaunchMethod::Zellij,
351 LaunchMethod::WeztermTab,
352 ] {
353 let s = method.as_str();
354 assert_eq!(LaunchMethod::from_str_opt(s), Some(method));
355 }
356 }
357
358 #[test]
359 fn test_format_config_key() {
360 assert_eq!(
361 format_config_key(CONFIG_KEY_BASE_BRANCH, "fix-auth"),
362 "branch.fix-auth.worktreeBase"
363 );
364 }
365
366 #[test]
367 fn test_home_dir_or_fallback() {
368 let home = home_dir_or_fallback();
369 assert!(!home.as_os_str().is_empty());
371 }
372
373 #[test]
374 fn test_path_age_days() {
375 assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
377
378 let tmp = std::env::temp_dir();
380 if let Some(age) = path_age_days(&tmp) {
381 assert!(age >= 0.0);
382 }
383 }
384
385 #[test]
386 fn test_preset_names_contents() {
387 assert!(PRESET_NAMES.contains(&"claude"));
388 assert!(PRESET_NAMES.contains(&"codex"));
389 assert!(PRESET_NAMES.contains(&"no-op"));
390 }
391
392 #[test]
393 fn test_to_bg_supported_methods() {
394 assert_eq!(LaunchMethod::Foreground.to_bg(), Some(LaunchMethod::Detach));
395 assert_eq!(
396 LaunchMethod::WeztermTab.to_bg(),
397 Some(LaunchMethod::WeztermTabBg)
398 );
399 }
400
401 #[test]
402 fn test_to_bg_unsupported_methods_silent_noop() {
403 assert_eq!(LaunchMethod::ItermTab.to_bg(), None);
406 assert_eq!(LaunchMethod::TmuxWindow.to_bg(), None);
407 assert_eq!(LaunchMethod::ZellijTab.to_bg(), None);
408 assert_eq!(LaunchMethod::Detach.to_bg(), None);
409 assert_eq!(LaunchMethod::WeztermTabBg.to_bg(), None);
410 }
411
412 #[test]
413 fn test_to_fg_supported_methods() {
414 assert_eq!(LaunchMethod::Detach.to_fg(), Some(LaunchMethod::Foreground));
415 assert_eq!(
416 LaunchMethod::WeztermTabBg.to_fg(),
417 Some(LaunchMethod::WeztermTab),
418 );
419 }
420
421 #[test]
422 fn test_to_fg_unsupported_methods_silent_noop() {
423 assert_eq!(LaunchMethod::ItermTab.to_fg(), None);
424 assert_eq!(LaunchMethod::Foreground.to_fg(), None);
425 assert_eq!(LaunchMethod::WeztermTab.to_fg(), None);
426 }
427
428 #[test]
429 fn test_bg_fg_roundtrip() {
430 for m in [LaunchMethod::Foreground, LaunchMethod::WeztermTab] {
432 assert_eq!(m.to_bg().and_then(|x| x.to_fg()), Some(m));
433 }
434 }
435
436 #[test]
437 fn test_version_meets_minimum() {
438 assert!(version_meets_minimum("2.31.0", 2, 31));
439 assert!(version_meets_minimum("2.40.0", 2, 31));
440 assert!(version_meets_minimum("3.0.0", 2, 31));
441 assert!(!version_meets_minimum("2.30.0", 2, 31));
442 assert!(!version_meets_minimum("1.99.0", 2, 31));
443 assert!(!version_meets_minimum("", 2, 31));
444 assert!(!version_meets_minimum("2", 2, 31));
445 }
446}