1use std::io;
2use std::process::{Command, Stdio};
3
4use serde::Serialize;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
7pub struct Session {
8 pub name: String,
9 pub windows: u32,
10 pub attached: bool,
11}
12
13pub fn list_sessions() -> io::Result<Vec<Session>> {
14 let output = Command::new("tmux")
15 .args([
16 "list-sessions",
17 "-F",
18 "#{session_name}:#{session_windows}:#{session_attached}",
19 ])
20 .output()?;
21
22 if !output.status.success() {
23 return Err(io::Error::other(format!(
24 "tmux list-sessions failed: {}",
25 String::from_utf8_lossy(&output.stderr).trim()
26 )));
27 }
28
29 let stdout = String::from_utf8_lossy(&output.stdout);
30 Ok(parse_sessions(&stdout))
31}
32
33pub fn parse_sessions(stdout: &str) -> Vec<Session> {
34 stdout
35 .trim()
36 .lines()
37 .filter(|line| !line.is_empty())
38 .filter_map(parse_session_line)
39 .collect()
40}
41
42fn parse_session_line(line: &str) -> Option<Session> {
43 let mut parts = line.splitn(3, ':');
44 let name = parts.next()?.to_string();
45 let windows = parts.next()?.parse().ok()?;
46 let attached_raw = parts.next()?;
47 Some(Session {
48 name,
49 windows,
50 attached: attached_raw == "1",
51 })
52}
53
54pub fn session_exists(name: &str) -> bool {
55 Command::new("tmux")
56 .args(["has-session", "-t", name])
57 .stdout(Stdio::null())
58 .stderr(Stdio::null())
59 .status()
60 .map(|s| s.success())
61 .unwrap_or(false)
62}
63
64pub fn create_session(name: &str, path: &str) -> io::Result<()> {
65 let mut cmd = Command::new("tmux");
66 cmd.args(["new-session", "-d", "-s", name]);
67 if !path.is_empty() {
68 cmd.args(["-c", path]);
69 }
70 let status = cmd.status()?;
71 if !status.success() {
72 return Err(io::Error::other(format!(
73 "tmux new-session exited with status {status}"
74 )));
75 }
76 Ok(())
77}
78
79pub fn switch_session(name: &str) -> io::Result<()> {
80 if std::env::var_os("TMUX").is_some() {
82 let status = Command::new("tmux")
83 .args(["switch-client", "-t", name])
84 .status()?;
85 if !status.success() {
86 return Err(io::Error::other(format!(
87 "tmux switch-client exited with status {status}"
88 )));
89 }
90 return Ok(());
91 }
92 let status = Command::new("tmux")
94 .args(["attach-session", "-t", name])
95 .stdin(Stdio::inherit())
96 .stdout(Stdio::inherit())
97 .stderr(Stdio::inherit())
98 .status()?;
99 if !status.success() {
100 return Err(io::Error::other(format!(
101 "tmux attach-session exited with status {status}"
102 )));
103 }
104 Ok(())
105}
106
107pub fn capture_pane(target: &str) -> io::Result<String> {
111 let output = Command::new("tmux")
112 .args(["capture-pane", "-p", "-t", target])
113 .output()?;
114 if !output.status.success() {
115 return Err(io::Error::other(format!(
116 "tmux capture-pane -t {target} failed: {}",
117 String::from_utf8_lossy(&output.stderr).trim()
118 )));
119 }
120 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
121}
122
123pub fn switch_to_pane(target: &str) -> io::Result<()> {
129 let session = target.split(':').next().unwrap_or(target);
130 let status = Command::new("tmux")
131 .args(["select-pane", "-t", target])
132 .status()?;
133 if !status.success() {
134 return Err(io::Error::other(format!(
135 "tmux select-pane -t {target} exited with status {status}"
136 )));
137 }
138 if std::env::var_os("TMUX").is_some() {
139 let status = Command::new("tmux")
140 .args(["switch-client", "-t", session])
141 .status()?;
142 if !status.success() {
143 return Err(io::Error::other(format!(
144 "tmux switch-client -t {session} exited with status {status}"
145 )));
146 }
147 } else {
148 let status = Command::new("tmux")
149 .args(["attach-session", "-t", session])
150 .stdin(Stdio::inherit())
151 .stdout(Stdio::inherit())
152 .stderr(Stdio::inherit())
153 .status()?;
154 if !status.success() {
155 return Err(io::Error::other(format!(
156 "tmux attach-session -t {session} exited with status {status}"
157 )));
158 }
159 }
160 Ok(())
161}
162
163pub fn kill_session(name: &str) -> io::Result<()> {
164 let status = Command::new("tmux")
165 .args(["kill-session", "-t", name])
166 .status()?;
167 if !status.success() {
168 return Err(io::Error::other(format!(
169 "tmux kill-session exited with status {status}"
170 )));
171 }
172 Ok(())
173}
174
175pub fn kill_session_id(id: &str) -> io::Result<()> {
178 let status = Command::new("tmux")
179 .args(["kill-session", "-t", id])
180 .status()?;
181 if !status.success() {
182 return Err(io::Error::other(format!(
183 "tmux kill-session exited with status {status}"
184 )));
185 }
186 Ok(())
187}
188
189pub fn current_session_id() -> Option<String> {
192 let output = Command::new("tmux")
193 .args(["display-message", "-p", "#{session_id}"])
194 .output()
195 .ok()?;
196 if !output.status.success() {
197 return None;
198 }
199 let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
200 if id.is_empty() { None } else { Some(id) }
201}
202
203pub fn apply_layout(session_name: &str, _project_type: &str) -> io::Result<()> {
204 Command::new("tmux")
205 .args([
206 "rename-window",
207 "-t",
208 &format!("{session_name}:0"),
209 "editor",
210 ])
211 .stdout(Stdio::null())
212 .stderr(Stdio::null())
213 .status()
214 .ok();
215 Ok(())
216}
217
218pub fn cleanup_sessions() -> io::Result<Vec<String>> {
219 let sessions = list_sessions()?;
220 let mut killed = Vec::new();
221 for session in sessions {
222 if !session.attached && kill_session(&session.name).is_ok() {
223 killed.push(session.name);
224 }
225 }
226 Ok(killed)
227}
228
229pub fn safe_session_name(repo: &str, branch: &str) -> String {
232 let combined = format!("{repo}-{branch}");
233 combined.replace('/', "-").replace('.', "_")
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn parse_sessions_handles_multiple_lines() {
242 let input = "main:3:1\nside:1:0\n";
243 let sessions = parse_sessions(input);
244 assert_eq!(sessions.len(), 2);
245 assert_eq!(
246 sessions[0],
247 Session {
248 name: "main".into(),
249 windows: 3,
250 attached: true
251 }
252 );
253 assert_eq!(
254 sessions[1],
255 Session {
256 name: "side".into(),
257 windows: 1,
258 attached: false
259 }
260 );
261 }
262
263 #[test]
264 fn parse_sessions_handles_session_with_colons_in_name() {
265 let input = "weird:name:5:1";
268 let sessions = parse_sessions(input);
269 assert_eq!(sessions.len(), 0); }
271
272 #[test]
273 fn parse_sessions_skips_empty_lines() {
274 let input = "\nfoo:1:0\n\n";
275 let sessions = parse_sessions(input);
276 assert_eq!(sessions.len(), 1);
277 assert_eq!(sessions[0].name, "foo");
278 }
279
280 #[test]
281 fn parse_sessions_empty_input() {
282 assert!(parse_sessions("").is_empty());
283 assert!(parse_sessions("\n\n").is_empty());
284 }
285
286 #[test]
287 fn safe_session_name_replaces_special_chars() {
288 assert_eq!(safe_session_name("my.repo", "feat/x"), "my_repo-feat-x");
289 assert_eq!(safe_session_name("plain", "main"), "plain-main");
290 }
291}