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 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
117pub 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
131pub 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
171pub 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 let input = "weird:name:5:1";
210 let sessions = parse_sessions(input);
211 assert_eq!(sessions.len(), 0); }
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}