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 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 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 capture_pane(target: &str) -> io::Result<String> {
109 let output = Command::new("tmux")
110 .args(["capture-pane", "-p", "-t", target])
111 .output()?;
112 if !output.status.success() {
113 return Err(io::Error::other(format!(
114 "tmux capture-pane -t {target} failed: {}",
115 String::from_utf8_lossy(&output.stderr).trim()
116 )));
117 }
118 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
119}
120
121pub fn switch_to_pane(target: &str) -> io::Result<()> {
127 let session = target.split(':').next().unwrap_or(target);
128 let status = Command::new("tmux")
129 .args(["select-pane", "-t", target])
130 .status()?;
131 if !status.success() {
132 return Err(io::Error::other(format!(
133 "tmux select-pane -t {target} exited with status {status}"
134 )));
135 }
136 if std::env::var_os("TMUX").is_some() {
137 let status = Command::new("tmux")
138 .args(["switch-client", "-t", session])
139 .status()?;
140 if !status.success() {
141 return Err(io::Error::other(format!(
142 "tmux switch-client -t {session} exited with status {status}"
143 )));
144 }
145 } else {
146 let status = Command::new("tmux")
147 .args(["attach-session", "-t", session])
148 .stdin(Stdio::inherit())
149 .stdout(Stdio::inherit())
150 .stderr(Stdio::inherit())
151 .status()?;
152 if !status.success() {
153 return Err(io::Error::other(format!(
154 "tmux attach-session -t {session} exited with status {status}"
155 )));
156 }
157 }
158 Ok(())
159}
160
161pub fn kill_session(name: &str) -> io::Result<()> {
162 let status = Command::new("tmux")
163 .args(["kill-session", "-t", name])
164 .status()?;
165 if !status.success() {
166 return Err(io::Error::other(format!(
167 "tmux kill-session exited with status {status}"
168 )));
169 }
170 Ok(())
171}
172
173pub fn kill_session_id(id: &str) -> io::Result<()> {
176 let status = Command::new("tmux")
177 .args(["kill-session", "-t", id])
178 .status()?;
179 if !status.success() {
180 return Err(io::Error::other(format!(
181 "tmux kill-session exited with status {status}"
182 )));
183 }
184 Ok(())
185}
186
187pub fn current_session_id() -> Option<String> {
190 let output = Command::new("tmux")
191 .args(["display-message", "-p", "#{session_id}"])
192 .output()
193 .ok()?;
194 if !output.status.success() {
195 return None;
196 }
197 let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
198 if id.is_empty() { None } else { Some(id) }
199}
200
201pub fn apply_layout(session_name: &str, _project_type: &str) -> io::Result<()> {
202 Command::new("tmux")
203 .args([
204 "rename-window",
205 "-t",
206 &format!("{session_name}:0"),
207 "editor",
208 ])
209 .stdout(Stdio::null())
210 .stderr(Stdio::null())
211 .status()
212 .ok();
213 Ok(())
214}
215
216pub fn cleanup_sessions() -> io::Result<Vec<String>> {
217 let sessions = list_sessions()?;
218 let mut killed = Vec::new();
219 for session in sessions {
220 if !session.attached && kill_session(&session.name).is_ok() {
221 killed.push(session.name);
222 }
223 }
224 Ok(killed)
225}
226
227pub fn safe_session_name(repo: &str, branch: &str) -> String {
230 let combined = format!("{repo}-{branch}");
231 combined.replace('/', "-").replace('.', "_")
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn parse_sessions_handles_multiple_lines() {
240 let input = "main:3:1\nside:1:0\n";
241 let sessions = parse_sessions(input);
242 assert_eq!(sessions.len(), 2);
243 assert_eq!(
244 sessions[0],
245 Session {
246 name: "main".into(),
247 windows: 3,
248 attached: true
249 }
250 );
251 assert_eq!(
252 sessions[1],
253 Session {
254 name: "side".into(),
255 windows: 1,
256 attached: false
257 }
258 );
259 }
260
261 #[test]
262 fn parse_sessions_handles_session_with_colons_in_name() {
263 let input = "weird:name:5:1";
266 let sessions = parse_sessions(input);
267 assert_eq!(sessions.len(), 0); }
269
270 #[test]
271 fn parse_sessions_skips_empty_lines() {
272 let input = "\nfoo:1:0\n\n";
273 let sessions = parse_sessions(input);
274 assert_eq!(sessions.len(), 1);
275 assert_eq!(sessions[0].name, "foo");
276 }
277
278 #[test]
279 fn parse_sessions_empty_input() {
280 assert!(parse_sessions("").is_empty());
281 assert!(parse_sessions("\n\n").is_empty());
282 }
283
284 #[test]
285 fn safe_session_name_replaces_special_chars() {
286 assert_eq!(safe_session_name("my.repo", "feat/x"), "my_repo-feat-x");
287 assert_eq!(safe_session_name("plain", "main"), "plain-main");
288 }
289}