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        .output()
38        .ok()?;
39
40    if !output.status.success() {
41        return None;
42    }
43
44    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
45    if branch.is_empty() {
46        None
47    } else {
48        Some(branch)
49    }
50}
51
52/// Check if the directory is inside a git worktree (not the main working tree).
53pub fn is_worktree(dir: &str) -> bool {
54    let git_dir = Command::new("git")
55        .args(["-C", dir, "rev-parse", "--git-dir"])
56        .output()
57        .ok();
58    let common_dir = Command::new("git")
59        .args(["-C", dir, "rev-parse", "--git-common-dir"])
60        .output()
61        .ok();
62
63    match (git_dir, common_dir) {
64        (Some(gd), Some(cd)) if gd.status.success() && cd.status.success() => {
65            let gd_str = String::from_utf8_lossy(&gd.stdout).trim().to_string();
66            let cd_str = String::from_utf8_lossy(&cd.stdout).trim().to_string();
67            // In a worktree, git-dir is something like ../.git/worktrees/name
68            // while git-common-dir is the main ../.git
69            gd_str != cd_str
70        }
71        _ => false,
72    }
73}
74
75/// Build the full window name: {base}-{branch} with optional (wt) suffix.
76/// Falls back to just {base} if not a git repo.
77pub fn build_window_name(base: &str, dir: &str) -> String {
78    let branch = git_branch(dir);
79    let wt = is_worktree(dir);
80
81    match branch {
82        Some(branch) => {
83            let raw = format!("{base}-{branch}");
84            let name = sanitize_name(&raw);
85            if wt { format!("{name}(wt)") } else { name }
86        }
87        None => sanitize_name(base),
88    }
89}
90
91// ── Tests ──
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn simple_name() {
99        assert_eq!(sanitize_name("cove-main"), "cove-main");
100    }
101
102    #[test]
103    fn replaces_dots_colons_slashes() {
104        assert_eq!(sanitize_name("feature/add-auth"), "feature-add-auth");
105        assert_eq!(sanitize_name("v1.2.3"), "v1-2-3");
106        assert_eq!(sanitize_name("host:port"), "host-port");
107    }
108
109    #[test]
110    fn collapses_consecutive_dashes() {
111        assert_eq!(sanitize_name("a--b---c"), "a-b-c");
112        assert_eq!(sanitize_name("a/../b"), "a-b");
113    }
114
115    #[test]
116    fn strips_leading_trailing_dashes() {
117        assert_eq!(sanitize_name("-hello-"), "hello");
118        assert_eq!(sanitize_name("---test---"), "test");
119    }
120
121    #[test]
122    fn truncates_long_names() {
123        let long = "a".repeat(50);
124        let result = sanitize_name(&long);
125        assert!(result.len() <= 30);
126    }
127
128    #[test]
129    fn truncates_on_dash_boundary() {
130        // 35 chars total: "abcdefghijklmno-pqrstuvwxyz-12345"
131        let name = "abcdefghijklmno-pqrstuvwxyz-12345";
132        let result = sanitize_name(name);
133        assert!(result.len() <= 30);
134        // Should truncate at the last dash within 30 chars
135        assert_eq!(result, "abcdefghijklmno-pqrstuvwxyz");
136    }
137
138    #[test]
139    fn empty_string() {
140        assert_eq!(sanitize_name(""), "");
141    }
142
143    #[test]
144    fn only_special_chars() {
145        assert_eq!(sanitize_name("..."), "");
146    }
147
148    #[test]
149    fn build_name_no_git() {
150        // /tmp is not a git repo
151        let name = build_window_name("myapp", "/tmp");
152        assert_eq!(name, "myapp");
153    }
154}