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}
43
44impl LaunchMethod {
45    /// Convert to the canonical kebab-case string.
46    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    /// Parse from a kebab-case string.
70    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    /// Human-readable display name.
97    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
127/// Build the alias map for launch methods.
128/// First letter: i=iTerm, t=tmux, z=Zellij, w=WezTerm
129/// Second: w=window, t=tab, p=pane
130/// For panes: h=horizontal, v=vertical
131pub fn launch_method_aliases() -> HashMap<&'static str, &'static str> {
132    HashMap::from([
133        ("fg", "foreground"),
134        ("d", "detach"),
135        // iTerm
136        ("i-w", "iterm-window"),
137        ("i-t", "iterm-tab"),
138        ("i-p-h", "iterm-pane-h"),
139        ("i-p-v", "iterm-pane-v"),
140        // tmux
141        ("t", "tmux"),
142        ("t-w", "tmux-window"),
143        ("t-p-h", "tmux-pane-h"),
144        ("t-p-v", "tmux-pane-v"),
145        // Zellij
146        ("z", "zellij"),
147        ("z-t", "zellij-tab"),
148        ("z-p-h", "zellij-pane-h"),
149        ("z-p-v", "zellij-pane-v"),
150        // WezTerm
151        ("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
158/// Seconds in one day (24 * 60 * 60).
159pub const SECS_PER_DAY: u64 = 86400;
160
161/// Seconds in one day as f64 (for floating-point age calculations).
162pub const SECS_PER_DAY_F64: f64 = 86400.0;
163
164/// Minimum required Git version for worktree features.
165pub const MIN_GIT_VERSION: &str = "2.31.0";
166
167/// Minimum Git major version.
168pub const MIN_GIT_VERSION_MAJOR: u32 = 2;
169
170/// Minimum Git minor version (when major == MIN_GIT_VERSION_MAJOR).
171pub const MIN_GIT_VERSION_MINOR: u32 = 31;
172
173/// Timeout in seconds for AI tool execution (e.g., PR description generation).
174pub const AI_TOOL_TIMEOUT_SECS: u64 = 60;
175
176/// Poll interval in milliseconds when waiting for AI tool completion.
177pub const AI_TOOL_POLL_MS: u64 = 100;
178
179/// Maximum session name length for tmux/zellij compatibility.
180/// Zellij uses Unix sockets which have a ~108 byte path limit.
181pub const MAX_SESSION_NAME_LENGTH: usize = 50;
182
183/// Claude native session path prefix length threshold.
184pub const CLAUDE_SESSION_PREFIX_LENGTH: usize = 200;
185
186/// Git config key templates for metadata storage.
187pub 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
191/// Format a git config key by replacing `{}` with the branch name.
192pub fn format_config_key(template: &str, branch: &str) -> String {
193    template.replace("{}", branch)
194}
195
196/// Return the user's home directory, falling back to `"."` if unavailable.
197pub fn home_dir_or_fallback() -> PathBuf {
198    dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
199}
200
201/// Compute the age of a file in fractional days, or `None` on error.
202pub 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
210/// Check if a semver version string meets a minimum (major, minor).
211pub 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
219/// Convert branch name to safe directory name.
220///
221/// Handles branch names with slashes (feat/auth), special characters,
222/// and other filesystem-unsafe characters.
223///
224/// # Examples
225/// ```
226/// use git_worktree_manager::constants::sanitize_branch_name;
227/// assert_eq!(sanitize_branch_name("feat/auth"), "feat-auth");
228/// assert_eq!(sanitize_branch_name("feature/user@login"), "feature-user-login");
229/// assert_eq!(sanitize_branch_name("hotfix/v2.0"), "hotfix-v2.0");
230/// ```
231pub 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
244/// Generate default worktree path: `../<repo>-<branch>`.
245pub 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
263/// Strip Windows UNC path prefix (`\\?\`) which `canonicalize()` adds.
264/// Git doesn't understand UNC paths, so we must strip them.
265pub 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        // Should return a non-empty path (either real home or ".")
321        assert!(!home.as_os_str().is_empty());
322    }
323
324    #[test]
325    fn test_path_age_days() {
326        // Non-existent path returns None
327        assert!(path_age_days(std::path::Path::new("/nonexistent/path")).is_none());
328
329        // Existing path returns Some with non-negative value
330        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}