Skip to main content

cove_cli/
naming.rs

1// ── Window naming logic ──
2//
3// Builds tmux window names in the format: {base}-{branch}
4// Worktree sessions get a (wt) suffix.
5
6use std::process::Command;
7
8/// Replace problematic characters and clean up the name for tmux.
9pub fn sanitize_name(name: &str) -> String {
10    let replaced: String = name
11        .chars()
12        .map(|c| if matches!(c, '.' | ':' | '/') { '-' } else { c })
13        .collect();
14
15    // Collapse consecutive dashes, strip leading/trailing dashes
16    let collapsed = replaced
17        .split('-')
18        .filter(|s| !s.is_empty())
19        .collect::<Vec<_>>()
20        .join("-");
21
22    // Truncate to 30 chars on a dash boundary if possible
23    if collapsed.len() <= 30 {
24        collapsed
25    } else {
26        match collapsed[..30].rfind('-') {
27            Some(pos) if pos > 10 => collapsed[..pos].to_string(),
28            _ => collapsed[..30].to_string(),
29        }
30    }
31}
32
33/// Get the current git branch name, or None if not a git repo.
34pub fn git_branch(dir: &str) -> Option<String> {
35    let output = Command::new("git")
36        .args(["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"])
37        .env_remove("GIT_DIR")
38        .env_remove("GIT_WORK_TREE")
39        .output()
40        .ok()?;
41
42    if !output.status.success() {
43        return None;
44    }
45
46    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
47    if branch.is_empty() {
48        None
49    } else {
50        Some(branch)
51    }
52}
53
54/// Check if the directory is inside a git worktree (not the main working tree).
55pub fn is_worktree(dir: &str) -> bool {
56    let git_dir = Command::new("git")
57        .args(["-C", dir, "rev-parse", "--git-dir"])
58        .env_remove("GIT_DIR")
59        .env_remove("GIT_WORK_TREE")
60        .output()
61        .ok();
62    let common_dir = Command::new("git")
63        .args(["-C", dir, "rev-parse", "--git-common-dir"])
64        .env_remove("GIT_DIR")
65        .env_remove("GIT_WORK_TREE")
66        .output()
67        .ok();
68
69    match (git_dir, common_dir) {
70        (Some(gd), Some(cd)) if gd.status.success() && cd.status.success() => {
71            let gd_str = String::from_utf8_lossy(&gd.stdout).trim().to_string();
72            let cd_str = String::from_utf8_lossy(&cd.stdout).trim().to_string();
73            // In a worktree, git-dir is something like ../.git/worktrees/name
74            // while git-common-dir is the main ../.git
75            gd_str != cd_str
76        }
77        _ => false,
78    }
79}
80
81/// Build the full window name: {base}-{branch} with optional (wt) suffix.
82/// Falls back to just {base} if not a git repo.
83pub fn build_window_name(base: &str, dir: &str) -> String {
84    let branch = git_branch(dir);
85    let wt = is_worktree(dir);
86
87    match branch {
88        Some(branch) => {
89            let raw = format!("{base}-{branch}");
90            let name = sanitize_name(&raw);
91            if wt { format!("{name}(wt)") } else { name }
92        }
93        None => sanitize_name(base),
94    }
95}
96
97// ── Tests ──
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn simple_name() {
105        assert_eq!(sanitize_name("cove-main"), "cove-main");
106    }
107
108    #[test]
109    fn replaces_dots_colons_slashes() {
110        assert_eq!(sanitize_name("feature/add-auth"), "feature-add-auth");
111        assert_eq!(sanitize_name("v1.2.3"), "v1-2-3");
112        assert_eq!(sanitize_name("host:port"), "host-port");
113    }
114
115    #[test]
116    fn collapses_consecutive_dashes() {
117        assert_eq!(sanitize_name("a--b---c"), "a-b-c");
118        assert_eq!(sanitize_name("a/../b"), "a-b");
119    }
120
121    #[test]
122    fn strips_leading_trailing_dashes() {
123        assert_eq!(sanitize_name("-hello-"), "hello");
124        assert_eq!(sanitize_name("---test---"), "test");
125    }
126
127    #[test]
128    fn truncates_long_names() {
129        let long = "a".repeat(50);
130        let result = sanitize_name(&long);
131        assert!(result.len() <= 30);
132    }
133
134    #[test]
135    fn truncates_on_dash_boundary() {
136        // 35 chars total: "abcdefghijklmno-pqrstuvwxyz-12345"
137        let name = "abcdefghijklmno-pqrstuvwxyz-12345";
138        let result = sanitize_name(name);
139        assert!(result.len() <= 30);
140        // Should truncate at the last dash within 30 chars
141        assert_eq!(result, "abcdefghijklmno-pqrstuvwxyz");
142    }
143
144    #[test]
145    fn empty_string() {
146        assert_eq!(sanitize_name(""), "");
147    }
148
149    #[test]
150    fn only_special_chars() {
151        assert_eq!(sanitize_name("..."), "");
152    }
153
154    #[test]
155    fn build_name_no_git() {
156        // /tmp is not a git repo
157        let name = build_window_name("myapp", "/tmp");
158        assert_eq!(name, "myapp");
159    }
160}