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 WeztermTabBg,
43}
44
45impl LaunchMethod {
46 pub fn as_str(&self) -> &'static str {
48 match self {
49 Self::Foreground => "foreground",
50 Self::Detach => "detach",
51 Self::ItermWindow => "iterm-window",
52 Self::ItermTab => "iterm-tab",
53 Self::ItermPaneH => "iterm-pane-h",
54 Self::ItermPaneV => "iterm-pane-v",
55 Self::Tmux => "tmux",
56 Self::TmuxWindow => "tmux-window",
57 Self::TmuxPaneH => "tmux-pane-h",
58 Self::TmuxPaneV => "tmux-pane-v",
59 Self::Zellij => "zellij",
60 Self::ZellijTab => "zellij-tab",
61 Self::ZellijPaneH => "zellij-pane-h",
62 Self::ZellijPaneV => "zellij-pane-v",
63 Self::WeztermWindow => "wezterm-window",
64 Self::WeztermTab => "wezterm-tab",
65 Self::WeztermPaneH => "wezterm-pane-h",
66 Self::WeztermPaneV => "wezterm-pane-v",
67 Self::WeztermTabBg => "wezterm-tab-bg",
68 }
69 }
70
71 pub fn from_str_opt(s: &str) -> Option<Self> {
73 match s {
74 "foreground" => Some(Self::Foreground),
75 "detach" => Some(Self::Detach),
76 "iterm-window" => Some(Self::ItermWindow),
77 "iterm-tab" => Some(Self::ItermTab),
78 "iterm-pane-h" => Some(Self::ItermPaneH),
79 "iterm-pane-v" => Some(Self::ItermPaneV),
80 "tmux" => Some(Self::Tmux),
81 "tmux-window" => Some(Self::TmuxWindow),
82 "tmux-pane-h" => Some(Self::TmuxPaneH),
83 "tmux-pane-v" => Some(Self::TmuxPaneV),
84 "zellij" => Some(Self::Zellij),
85 "zellij-tab" => Some(Self::ZellijTab),
86 "zellij-pane-h" => Some(Self::ZellijPaneH),
87 "zellij-pane-v" => Some(Self::ZellijPaneV),
88 "wezterm-window" => Some(Self::WeztermWindow),
89 "wezterm-tab" => Some(Self::WeztermTab),
90 "wezterm-pane-h" => Some(Self::WeztermPaneH),
91 "wezterm-pane-v" => Some(Self::WeztermPaneV),
92 "wezterm-tab-bg" => Some(Self::WeztermTabBg),
93 _ => None,
94 }
95 }
96}
97
98impl LaunchMethod {
99 pub fn to_bg(&self) -> Option<Self> {
103 match self {
104 Self::Foreground => Some(Self::Detach),
105 Self::WeztermTab => Some(Self::WeztermTabBg),
106 _ => None,
107 }
108 }
109
110 pub fn to_fg(&self) -> Option<Self> {
113 match self {
114 Self::Detach => Some(Self::Foreground),
115 Self::WeztermTabBg => Some(Self::WeztermTab),
116 _ => None,
117 }
118 }
119}
120
121impl LaunchMethod {
122 pub fn display_name(&self) -> &'static str {
124 match self {
125 Self::Foreground => "Foreground",
126 Self::Detach => "Detach (background)",
127 Self::ItermWindow => "iTerm2 — New Window",
128 Self::ItermTab => "iTerm2 — New Tab",
129 Self::ItermPaneH => "iTerm2 — Horizontal Pane",
130 Self::ItermPaneV => "iTerm2 — Vertical Pane",
131 Self::Tmux => "tmux — New Session",
132 Self::TmuxWindow => "tmux — New Window",
133 Self::TmuxPaneH => "tmux — Horizontal Pane",
134 Self::TmuxPaneV => "tmux — Vertical Pane",
135 Self::Zellij => "Zellij — New Session",
136 Self::ZellijTab => "Zellij — New Tab",
137 Self::ZellijPaneH => "Zellij — Horizontal Pane",
138 Self::ZellijPaneV => "Zellij — Vertical Pane",
139 Self::WeztermWindow => "WezTerm — New Window",
140 Self::WeztermTab => "WezTerm — New Tab",
141 Self::WeztermPaneH => "WezTerm — Horizontal Pane",
142 Self::WeztermPaneV => "WezTerm — Vertical Pane",
143 Self::WeztermTabBg => "WezTerm — New Tab (Background)",
144 }
145 }
146}
147
148impl std::fmt::Display for LaunchMethod {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.write_str(self.as_str())
151 }
152}
153
154pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
159 HashMap::from([
160 ("fg", "foreground"),
161 ("d", "detach"),
162 ("i-w", "iterm-window"),
164 ("i-t", "iterm-tab"),
165 ("i-p-h", "iterm-pane-h"),
166 ("i-p-v", "iterm-pane-v"),
167 ("t", "tmux"),
169 ("t-w", "tmux-window"),
170 ("t-p-h", "tmux-pane-h"),
171 ("t-p-v", "tmux-pane-v"),
172 ("z", "zellij"),
174 ("z-t", "zellij-tab"),
175 ("z-p-h", "zellij-pane-h"),
176 ("z-p-v", "zellij-pane-v"),
177 ("w-w", "wezterm-window"),
179 ("w-t", "wezterm-tab"),
180 ("w-p-h", "wezterm-pane-h"),
181 ("w-p-v", "wezterm-pane-v"),
182 ("w-t-b", "wezterm-tab-bg"),
183 ])
184}
185
186pub const HOOK_EVENTS: &[&str] = &[
188 "worktree.pre_create",
189 "worktree.post_create",
190 "worktree.pre_delete",
191 "worktree.post_delete",
192 "merge.pre",
193 "merge.post",
194 "pr.pre",
195 "pr.post",
196 "resume.pre",
197 "resume.post",
198 "sync.pre",
199 "sync.post",
200];
201
202pub const PRESET_NAMES: &[&str] = &[
204 "claude",
205 "claude-remote",
206 "claude-yolo",
207 "claude-yolo-remote",
208 "codex",
209 "codex-yolo",
210 "no-op",
211];
212
213pub fn all_term_values() -> Vec<&'static str> {
215 let mut values: Vec<&str> = vec![
216 "foreground",
217 "detach",
218 "iterm-window",
219 "iterm-tab",
220 "iterm-pane-h",
221 "iterm-pane-v",
222 "tmux",
223 "tmux-window",
224 "tmux-pane-h",
225 "tmux-pane-v",
226 "zellij",
227 "zellij-tab",
228 "zellij-pane-h",
229 "zellij-pane-v",
230 "wezterm-window",
231 "wezterm-tab",
232 "wezterm-tab-bg",
233 "wezterm-pane-h",
234 "wezterm-pane-v",
235 ];
236 for alias in launch_method_aliases().keys() {
237 values.push(alias);
238 }
239 values.sort();
240 values
241}
242
243pub const SECS_PER_DAY: u64 = 86400;
245
246pub const SECS_PER_DAY_F64: f64 = 86400.0;
248
249pub const MIN_GIT_VERSION: &str = "2.31.0";
251
252pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
254
255pub const MIN_GIT_VERSION_MINOR: u32 = 31;
257
258pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
260
261pub const AI_TOOL_POLL_MS: u64 = 100;
263
264pub const MAX_SESSION_NAME_LENGTH: usize = 50;
267
268pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
270
271pub const CONFIG_KEY_BASE_BRANCH: &str = "branch.{}.worktreeBase";
273pub const CONFIG_KEY_BASE_PATH: &str = "worktree.{}.basePath";
274pub const CONFIG_KEY_INTENDED_BRANCH: &str = "worktree.{}.intendedBranch";
275
276pub fn format_config_key(template: &str, branch: &str) -> String {
278 template.replace("{}", branch)
279}
280
281pub fn home_dir_or_fallback() -> PathBuf {
283 dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
284}
285
286pub fn path_age_days(path: &Path) -> Option<f64> {
288 let mtime = path.metadata().and_then(|m| m.modified()).ok()?;
289 std::time::SystemTime::now()
290 .duration_since(mtime)
291 .ok()
292 .map(|d| d.as_secs_f64() / SECS_PER_DAY_F64)
293}
294
295pub fn version_meets_minimum(version_str: &str, min_major: u32, min_minor: u32) -> bool {
297 let parts: Vec<u32> = version_str
298 .split('.')
299 .filter_map(|p| p.parse().ok())
300 .collect();
301 parts.len() >= 2 && (parts[0] > min_major || (parts[0] == min_major && parts[1] >= min_minor))
302}
303
304pub fn sanitize_branch_name(branch_name: &str) -> String {
317 let safe = UNSAFE_RE.replace_all(branch_name, "-");
318 let safe = WHITESPACE_RE.replace_all(&safe, "-");
319 let safe = MULTI_HYPHEN_RE.replace_all(&safe, "-");
320 let safe = safe.trim_matches('-');
321
322 if safe.is_empty() {
323 "worktree".to_string()
324 } else {
325 safe.to_string()
326 }
327}
328
329pub fn default_worktree_path(repo_path: &Path, branch_name: &str) -> PathBuf {
331 let repo_path = strip_unc(
332 repo_path
333 .canonicalize()
334 .unwrap_or_else(|_| repo_path.to_path_buf()),
335 );
336 let safe_branch = sanitize_branch_name(branch_name);
337 let repo_name = repo_path
338 .file_name()
339 .map(|n| n.to_string_lossy().to_string())
340 .unwrap_or_else(|| "repo".to_string());
341
342 repo_path
343 .parent()
344 .unwrap_or(repo_path.as_path())
345 .join(format!("{}-{}", repo_name, safe_branch))
346}
347
348pub fn strip_unc(path: PathBuf) -> PathBuf {
351 #[cfg(target_os = "windows")]
352 {
353 let s = path.to_string_lossy();
354 if let Some(stripped) = s.strip_prefix(r"\\?\") {
355 return PathBuf::from(stripped);
356 }
357 }
358 path
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn test_sanitize_branch_name() {
367 assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
368 assert_eq!(sanitize_branch_name("bugfix/issue-123"), "bugfix-issue-123");
369 assert_eq!(
370 sanitize_branch_name("feature/user@login"),
371 "feature-user-login"
372 );
373 assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
374 assert_eq!(sanitize_branch_name("///"), "worktree");
375 assert_eq!(sanitize_branch_name(""), "worktree");
376 assert_eq!(sanitize_branch_name("simple"), "simple");
377 }
378
379 #[test]
380 fn test_launch_method_roundtrip() {
381 for method in [
382 LaunchMethod::Foreground,
383 LaunchMethod::Detach,
384 LaunchMethod::ItermWindow,
385 LaunchMethod::Tmux,
386 LaunchMethod::Zellij,
387 LaunchMethod::WeztermTab,
388 ] {
389 let s = method.as_str();
390 assert_eq!(LaunchMethod::from_str_opt(s), Some(method));
391 }
392 }
393
394 #[test]
395 fn test_format_config_key() {
396 assert_eq!(
397 format_config_key(CONFIG_KEY_BASE_BRANCH, "fix-auth"),
398 "branch.fix-auth.worktreeBase"
399 );
400 }
401
402 #[test]
403 fn test_home_dir_or_fallback() {
404 let home = home_dir_or_fallback();
405 assert!(!home.as_os_str().is_empty());
407 }
408
409 #[test]
410 fn test_path_age_days() {
411 assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
413
414 let tmp = std::env::temp_dir();
416 if let Some(age) = path_age_days(&tmp) {
417 assert!(age >= 0.0);
418 }
419 }
420
421 #[test]
422 fn test_all_term_values_contains_canonical_and_aliases() {
423 let values = all_term_values();
424 assert!(
426 values.len() >= 36,
427 "expected ≥36 term values, got {}",
428 values.len()
429 );
430 assert!(values.contains(&"foreground"));
432 assert!(values.contains(&"tmux"));
433 assert!(values.contains(&"wezterm-tab"));
434 assert!(values.contains(&"fg"));
436 assert!(values.contains(&"t"));
437 assert!(values.contains(&"w-t"));
438 }
439
440 #[test]
441 fn test_hook_events_contents() {
442 assert!(HOOK_EVENTS.contains(&"worktree.post_create"));
443 assert!(HOOK_EVENTS.contains(&"merge.pre"));
444 }
445
446 #[test]
447 fn test_preset_names_contents() {
448 assert!(PRESET_NAMES.contains(&"claude"));
449 assert!(PRESET_NAMES.contains(&"codex"));
450 assert!(PRESET_NAMES.contains(&"no-op"));
451 }
452
453 #[test]
454 fn test_to_bg_supported_methods() {
455 assert_eq!(LaunchMethod::Foreground.to_bg(), Some(LaunchMethod::Detach));
456 assert_eq!(
457 LaunchMethod::WeztermTab.to_bg(),
458 Some(LaunchMethod::WeztermTabBg)
459 );
460 }
461
462 #[test]
463 fn test_to_bg_unsupported_methods_silent_noop() {
464 assert_eq!(LaunchMethod::ItermTab.to_bg(), None);
467 assert_eq!(LaunchMethod::TmuxWindow.to_bg(), None);
468 assert_eq!(LaunchMethod::ZellijTab.to_bg(), None);
469 assert_eq!(LaunchMethod::Detach.to_bg(), None);
470 assert_eq!(LaunchMethod::WeztermTabBg.to_bg(), None);
471 }
472
473 #[test]
474 fn test_to_fg_supported_methods() {
475 assert_eq!(LaunchMethod::Detach.to_fg(), Some(LaunchMethod::Foreground));
476 assert_eq!(
477 LaunchMethod::WeztermTabBg.to_fg(),
478 Some(LaunchMethod::WeztermTab),
479 );
480 }
481
482 #[test]
483 fn test_to_fg_unsupported_methods_silent_noop() {
484 assert_eq!(LaunchMethod::ItermTab.to_fg(), None);
485 assert_eq!(LaunchMethod::Foreground.to_fg(), None);
486 assert_eq!(LaunchMethod::WeztermTab.to_fg(), None);
487 }
488
489 #[test]
490 fn test_bg_fg_roundtrip() {
491 for m in [LaunchMethod::Foreground, LaunchMethod::WeztermTab] {
493 assert_eq!(m.to_bg().and_then(|x| x.to_fg()), Some(m));
494 }
495 }
496
497 #[test]
498 fn test_version_meets_minimum() {
499 assert!(version_meets_minimum("2.31.0", 2, 31));
500 assert!(version_meets_minimum("2.40.0", 2, 31));
501 assert!(version_meets_minimum("3.0.0", 2, 31));
502 assert!(!version_meets_minimum("2.30.0", 2, 31));
503 assert!(!version_meets_minimum("1.99.0", 2, 31));
504 assert!(!version_meets_minimum("", 2, 31));
505 assert!(!version_meets_minimum("2", 2, 31));
506 }
507}