Skip to main content

tmx/
tmux.rs

1use std::io;
2use std::process::{Command, Stdio};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct Session {
6    pub name: String,
7    pub windows: u32,
8    pub attached: bool,
9}
10
11pub fn list_sessions() -> io::Result<Vec<Session>> {
12    let output = Command::new("tmux")
13        .args([
14            "list-sessions",
15            "-F",
16            "#{session_name}:#{session_windows}:#{session_attached}",
17        ])
18        .output()?;
19
20    if !output.status.success() {
21        return Err(io::Error::other(format!(
22            "tmux list-sessions failed: {}",
23            String::from_utf8_lossy(&output.stderr).trim()
24        )));
25    }
26
27    let stdout = String::from_utf8_lossy(&output.stdout);
28    Ok(parse_sessions(&stdout))
29}
30
31pub fn parse_sessions(stdout: &str) -> Vec<Session> {
32    stdout
33        .trim()
34        .lines()
35        .filter(|line| !line.is_empty())
36        .filter_map(parse_session_line)
37        .collect()
38}
39
40fn parse_session_line(line: &str) -> Option<Session> {
41    let mut parts = line.splitn(3, ':');
42    let name = parts.next()?.to_string();
43    let windows = parts.next()?.parse().ok()?;
44    let attached_raw = parts.next()?;
45    Some(Session {
46        name,
47        windows,
48        attached: attached_raw == "1",
49    })
50}
51
52pub fn session_exists(name: &str) -> bool {
53    Command::new("tmux")
54        .args(["has-session", "-t", name])
55        .stdout(Stdio::null())
56        .stderr(Stdio::null())
57        .status()
58        .map(|s| s.success())
59        .unwrap_or(false)
60}
61
62pub fn create_session(name: &str, path: &str) -> io::Result<()> {
63    let mut cmd = Command::new("tmux");
64    cmd.args(["new-session", "-d", "-s", name]);
65    if !path.is_empty() {
66        cmd.args(["-c", path]);
67    }
68    let status = cmd.status()?;
69    if !status.success() {
70        return Err(io::Error::other(format!(
71            "tmux new-session exited with status {status}"
72        )));
73    }
74    Ok(())
75}
76
77pub fn switch_session(name: &str) -> io::Result<()> {
78    // Inside tmux: switch the current client.
79    if std::env::var_os("TMUX").is_some() {
80        let status = Command::new("tmux")
81            .args(["switch-client", "-t", name])
82            .status()?;
83        if !status.success() {
84            return Err(io::Error::other(format!(
85                "tmux switch-client exited with status {status}"
86            )));
87        }
88        return Ok(());
89    }
90    // Otherwise attach — inherit stdio so the user sees the session.
91    let status = Command::new("tmux")
92        .args(["attach-session", "-t", name])
93        .stdin(Stdio::inherit())
94        .stdout(Stdio::inherit())
95        .stderr(Stdio::inherit())
96        .status()?;
97    if !status.success() {
98        return Err(io::Error::other(format!(
99            "tmux attach-session exited with status {status}"
100        )));
101    }
102    Ok(())
103}
104
105pub fn kill_session(name: &str) -> io::Result<()> {
106    let status = Command::new("tmux")
107        .args(["kill-session", "-t", name])
108        .status()?;
109    if !status.success() {
110        return Err(io::Error::other(format!(
111            "tmux kill-session exited with status {status}"
112        )));
113    }
114    Ok(())
115}
116
117/// Kill a session by its stable id (e.g. `$3`). Safer than by name when
118/// sessions may be renamed concurrently.
119pub fn kill_session_id(id: &str) -> io::Result<()> {
120    let status = Command::new("tmux")
121        .args(["kill-session", "-t", id])
122        .status()?;
123    if !status.success() {
124        return Err(io::Error::other(format!(
125            "tmux kill-session exited with status {status}"
126        )));
127    }
128    Ok(())
129}
130
131/// Return the current tmux session id (e.g. `$3`), or `None` if not inside
132/// a tmux server or the query fails.
133pub fn current_session_id() -> Option<String> {
134    let output = Command::new("tmux")
135        .args(["display-message", "-p", "#{session_id}"])
136        .output()
137        .ok()?;
138    if !output.status.success() {
139        return None;
140    }
141    let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
142    if id.is_empty() { None } else { Some(id) }
143}
144
145pub fn apply_layout(session_name: &str, _project_type: &str) -> io::Result<()> {
146    Command::new("tmux")
147        .args([
148            "rename-window",
149            "-t",
150            &format!("{session_name}:0"),
151            "editor",
152        ])
153        .stdout(Stdio::null())
154        .stderr(Stdio::null())
155        .status()
156        .ok();
157    Ok(())
158}
159
160pub fn cleanup_sessions() -> io::Result<Vec<String>> {
161    let sessions = list_sessions()?;
162    let mut killed = Vec::new();
163    for session in sessions {
164        if !session.attached && kill_session(&session.name).is_ok() {
165            killed.push(session.name);
166        }
167    }
168    Ok(killed)
169}
170
171/// Normalise a tmux session name. Mirrors the convention used elsewhere
172/// (dots and slashes become safe characters).
173pub fn safe_session_name(repo: &str, branch: &str) -> String {
174    let combined = format!("{repo}-{branch}");
175    combined.replace('/', "-").replace('.', "_")
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn parse_sessions_handles_multiple_lines() {
184        let input = "main:3:1\nside:1:0\n";
185        let sessions = parse_sessions(input);
186        assert_eq!(sessions.len(), 2);
187        assert_eq!(
188            sessions[0],
189            Session {
190                name: "main".into(),
191                windows: 3,
192                attached: true
193            }
194        );
195        assert_eq!(
196            sessions[1],
197            Session {
198                name: "side".into(),
199                windows: 1,
200                attached: false
201            }
202        );
203    }
204
205    #[test]
206    fn parse_sessions_handles_session_with_colons_in_name() {
207        // splitn(3) means trailing colons stay in the windows/attached fields,
208        // not the name. The Go version behaves identically.
209        let input = "weird:name:5:1";
210        let sessions = parse_sessions(input);
211        assert_eq!(sessions.len(), 0); // "name" is not a valid u32 → dropped
212    }
213
214    #[test]
215    fn parse_sessions_skips_empty_lines() {
216        let input = "\nfoo:1:0\n\n";
217        let sessions = parse_sessions(input);
218        assert_eq!(sessions.len(), 1);
219        assert_eq!(sessions[0].name, "foo");
220    }
221
222    #[test]
223    fn parse_sessions_empty_input() {
224        assert!(parse_sessions("").is_empty());
225        assert!(parse_sessions("\n\n").is_empty());
226    }
227
228    #[test]
229    fn safe_session_name_replaces_special_chars() {
230        assert_eq!(safe_session_name("my.repo", "feat/x"), "my_repo-feat-x");
231        assert_eq!(safe_session_name("plain", "main"), "plain-main");
232    }
233}