Skip to main content

git_worktree_manager/
constants.rs

1/// Constants and default values for git-worktree-manager.
2///
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::LazyLock;
6
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10/// Pre-compiled regex patterns for branch name sanitization.
11static 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/// Terminal launch methods for AI tool execution.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum LaunchMethod {
20    Foreground,
21    Detach,
22    // iTerm (macOS)
23    ItermWindow,
24    ItermTab,
25    ItermPaneH,
26    ItermPaneV,
27    // tmux
28    Tmux,
29    TmuxWindow,
30    TmuxPaneH,
31    TmuxPaneV,
32    // Zellij
33    Zellij,
34    ZellijTab,
35    ZellijPaneH,
36    ZellijPaneV,
37    // WezTerm
38    WeztermWindow,
39    WeztermTab,
40    WeztermPaneH,
41    WeztermPaneV,
42    WeztermTabBg,
43}
44
45impl LaunchMethod {
46    /// Convert to the canonical kebab-case string.
47    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    /// Parse from a kebab-case string.
72    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    /// Return the background variant of this launch method, if one exists.
100    /// `--bg` uses this; methods without a paired variant are left unchanged
101    /// upstream (silent no-op).
102    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    /// Return the foreground variant of this launch method, if one exists.
111    /// Inverse of `to_bg`.
112    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    /// Human-readable display name.
123    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
154/// Build the alias map for launch methods.
155/// First letter: i=iTerm, t=tmux, z=Zellij, w=WezTerm
156/// Second: w=window, t=tab, p=pane
157/// For panes: h=horizontal, v=vertical
158pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
159    HashMap::from([
160        ("fg", "foreground"),
161        ("d", "detach"),
162        // iTerm
163        ("i-w", "iterm-window"),
164        ("i-t", "iterm-tab"),
165        ("i-p-h", "iterm-pane-h"),
166        ("i-p-v", "iterm-pane-v"),
167        // tmux
168        ("t", "tmux"),
169        ("t-w", "tmux-window"),
170        ("t-p-h", "tmux-pane-h"),
171        ("t-p-v", "tmux-pane-v"),
172        // Zellij
173        ("z", "zellij"),
174        ("z-t", "zellij-tab"),
175        ("z-p-h", "zellij-pane-h"),
176        ("z-p-v", "zellij-pane-v"),
177        // WezTerm
178        ("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
186/// Valid hook events for lifecycle hooks.
187pub 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
202/// Available AI tool preset names.
203pub 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
213/// Return all valid `--term` values: canonical launch methods + aliases.
214pub 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
243/// Seconds in one day (24 * 60 * 60).
244pub const SECS_PER_DAY: u64 = 86400;
245
246/// Seconds in one day as f64 (for floating-point age calculations).
247pub const SECS_PER_DAY_F64: f64 = 86400.0;
248
249/// Minimum required Git version for worktree features.
250pub const MIN_GIT_VERSION: &str = "2.31.0";
251
252/// Minimum Git major version.
253pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
254
255/// Minimum Git minor version (when major == MIN_GIT_VERSION_MAJOR).
256pub const MIN_GIT_VERSION_MINOR: u32 = 31;
257
258/// Timeout in seconds for AI tool execution (e.g., PR description generation).
259pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
260
261/// Poll interval in milliseconds when waiting for AI tool completion.
262pub const AI_TOOL_POLL_MS: u64 = 100;
263
264/// Maximum session name length for tmux/zellij compatibility.
265/// Zellij uses Unix sockets which have a ~108 byte path limit.
266pub const MAX_SESSION_NAME_LENGTH: usize = 50;
267
268/// Claude native session path prefix length threshold.
269pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
270
271/// Git config key templates for metadata storage.
272pub 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
276/// Format a git config key by replacing `{}` with the branch name.
277pub fn format_config_key(template: &str, branch: &str) -> String {
278    template.replace("{}", branch)
279}
280
281/// Return the user's home directory, falling back to `"."` if unavailable.
282pub fn home_dir_or_fallback() -> PathBuf {
283    dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
284}
285
286/// Compute the age of a file in fractional days, or `None` on error.
287pub 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
295/// Check if a semver version string meets a minimum (major, minor).
296pub 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
304/// Convert branch name to safe directory name.
305///
306/// Handles branch names with slashes (feat/auth), special characters,
307/// and other filesystem-unsafe characters.
308///
309/// # Examples
310/// ```
311/// use git_worktree_manager::constants::sanitize_branch_name;
312/// assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
313/// assert_eq!(sanitize_branch_name("feature/user@login"), "feature-user-login");
314/// assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
315/// ```
316pub 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
329/// Generate default worktree path: `../<repo>-<branch>`.
330pub 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
348/// Strip Windows UNC path prefix (`\\?\`) which `canonicalize()` adds.
349/// Git doesn't understand UNC paths, so we must strip them.
350pub 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        // Should return a non-empty path (either real home or ".")
406        assert!(!home.as_os_str().is_empty());
407    }
408
409    #[test]
410    fn test_path_age_days() {
411        // Non-existent path returns None
412        assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
413
414        // Existing path returns Some with non-negative value
415        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        // 19 canonical + aliases
425        assert!(
426            values.len() >= 36,
427            "expected ≥36 term values, got {}",
428            values.len()
429        );
430        // Check a few canonical values
431        assert!(values.contains(&"foreground"));
432        assert!(values.contains(&"tmux"));
433        assert!(values.contains(&"wezterm-tab"));
434        // Check a few aliases
435        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        // Unsupported launchers return None so the caller leaves the method
465        // unchanged instead of erroring.
466        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        // bg ∘ fg should return the original method for paired variants.
492        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}