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    /// Sentinel: do not launch an AI tool at all. Selected via
21    /// `-T noop|none|skip`. Replaces the old `--no-term` flag.
22    Skip,
23    Foreground,
24    Detach,
25    // iTerm (macOS)
26    ItermWindow,
27    ItermTab,
28    ItermPaneH,
29    ItermPaneV,
30    // tmux
31    Tmux,
32    TmuxWindow,
33    TmuxPaneH,
34    TmuxPaneV,
35    // Zellij
36    Zellij,
37    ZellijTab,
38    ZellijPaneH,
39    ZellijPaneV,
40    // WezTerm
41    WeztermWindow,
42    WeztermTab,
43    WeztermPaneH,
44    WeztermPaneV,
45    WeztermTabBg,
46}
47
48impl LaunchMethod {
49    /// Convert to the canonical kebab-case string.
50    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    /// Parse from a kebab-case string.
76    pub fn from_str_opt(s: &str) -> Option<Self> {
77        match s {
78            // `skip`/`none`/`noop` all map to `Skip`. Three spellings because
79            // none is unambiguously best — `noop` is technically accurate
80            // (the launcher is a no-op), `none` is conventional, `skip` is
81            // imperative. We accept all three to avoid bikeshedding.
82            "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    /// Return the background variant of this launch method, if one exists.
109    /// `--bg` uses this; methods without a paired variant are left unchanged
110    /// upstream (silent no-op).
111    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    /// Return the foreground variant of this launch method, if one exists.
120    /// Inverse of `to_bg`.
121    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    /// Human-readable display name.
132    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
164/// Build the alias map for launch methods.
165/// First letter: i=iTerm, t=tmux, z=Zellij, w=WezTerm
166/// Second: w=window, t=tab, p=pane
167/// For panes: h=horizontal, v=vertical
168pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
169    HashMap::from([
170        ("fg", "foreground"),
171        ("d", "detach"),
172        // iTerm
173        ("i-w", "iterm-window"),
174        ("i-t", "iterm-tab"),
175        ("i-p-h", "iterm-pane-h"),
176        ("i-p-v", "iterm-pane-v"),
177        // tmux
178        ("t", "tmux"),
179        ("t-w", "tmux-window"),
180        ("t-p-h", "tmux-pane-h"),
181        ("t-p-v", "tmux-pane-v"),
182        // Zellij
183        ("z", "zellij"),
184        ("z-t", "zellij-tab"),
185        ("z-p-h", "zellij-pane-h"),
186        ("z-p-v", "zellij-pane-v"),
187        // WezTerm
188        ("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
196/// Available AI tool preset names.
197pub 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
207/// Seconds in one day (24 * 60 * 60).
208pub const SECS_PER_DAY: u64 = 86400;
209
210/// Seconds in one day as f64 (for floating-point age calculations).
211pub const SECS_PER_DAY_F64: f64 = 86400.0;
212
213/// Minimum required Git version for worktree features.
214pub const MIN_GIT_VERSION: &str = "2.31.0";
215
216/// Minimum Git major version.
217pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
218
219/// Minimum Git minor version (when major == MIN_GIT_VERSION_MAJOR).
220pub const MIN_GIT_VERSION_MINOR: u32 = 31;
221
222/// Timeout in seconds for AI tool execution (e.g., PR description generation).
223pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
224
225/// Poll interval in milliseconds when waiting for AI tool completion.
226pub const AI_TOOL_POLL_MS: u64 = 100;
227
228/// Maximum session name length for tmux/zellij compatibility.
229/// Zellij uses Unix sockets which have a ~108 byte path limit.
230pub const MAX_SESSION_NAME_LENGTH: usize = 50;
231
232/// Claude native session path prefix length threshold.
233pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
234
235/// Git config key templates for metadata storage.
236pub 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
240/// Format a git config key by replacing `{}` with the branch name.
241pub fn format_config_key(template: &str, branch: &str) -> String {
242    template.replace("{}", branch)
243}
244
245/// Return the user's home directory, falling back to `"."` if unavailable.
246pub fn home_dir_or_fallback() -> PathBuf {
247    dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
248}
249
250/// Compute the age of a file in fractional days, or `None` on error.
251pub 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
259/// Check if a semver version string meets a minimum (major, minor).
260pub 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
268/// Convert branch name to safe directory name.
269///
270/// Handles branch names with slashes (feat/auth), special characters,
271/// and other filesystem-unsafe characters.
272///
273/// # Examples
274/// ```
275/// use git_worktree_manager::constants::sanitize_branch_name;
276/// assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
277/// assert_eq!(sanitize_branch_name("feature/user@login"), "feature-user-login");
278/// assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
279/// ```
280pub 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
293/// Generate default worktree path: `../<repo>-<branch>`.
294pub 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
312/// Strip Windows UNC path prefix (`\\?\`) which `canonicalize()` adds.
313/// Git doesn't understand UNC paths, so we must strip them.
314pub 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        // Should return a non-empty path (either real home or ".")
370        assert!(!home.as_os_str().is_empty());
371    }
372
373    #[test]
374    fn test_path_age_days() {
375        // Non-existent path returns None
376        assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
377
378        // Existing path returns Some with non-negative value
379        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        // Unsupported launchers return None so the caller leaves the method
404        // unchanged instead of erroring.
405        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        // bg ∘ fg should return the original method for paired variants.
431        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}