1use anyhow::{Context, Result};
2use tokio::process::Command;
3
4#[derive(Debug, Clone)]
6pub struct TmuxSession {
7 pub id: String,
8 pub created_ms: i64,
9 pub pid: Option<u32>,
10 pub tty: Option<String>,
11}
12
13async fn run(args: &[&str]) -> Result<String> {
15 let out = Command::new("tmux")
16 .args(args)
17 .kill_on_drop(true)
18 .output()
19 .await
20 .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
21 if !out.status.success() {
22 let stderr = String::from_utf8_lossy(&out.stderr);
23 anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
24 }
25 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
26}
27
28async fn run_best_effort(args: &[&str]) -> String {
31 match run(args).await {
32 Ok(result) => result,
33 Err(e) => {
34 tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
35 String::new()
36 }
37 }
38}
39
40fn shell_quote(s: &str) -> String {
43 format!("'{}'", s.replace('\'', "'\\''"))
44}
45
46pub async fn create_session(
49 id: &str,
50 workspace: &str,
51 cmd: &str,
52 env: &[(&str, &str)],
53) -> Result<()> {
54 let mut env_pairs: Vec<String> = Vec::new();
56 for (k, v) in env {
57 anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
58 env_pairs.push(format!("{k}={v}"));
59 }
60 let mut extra: Vec<&str> = Vec::new();
61 for pair in &env_pairs {
62 extra.push("-e");
64 extra.push(pair.as_str());
65 }
66
67 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
71 let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
72
73 let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
78 base.extend_from_slice(&extra);
79 base.push(&shell_cmd);
80
81 for attempt in 0..2u8 {
82 match run(&base).await {
83 Ok(_) => break,
84 Err(e) if attempt == 0 && e.to_string().contains("duplicate session") => {
85 run_best_effort(&["kill-session", "-t", id]).await;
86 }
87 Err(e) => return Err(e),
88 }
89 }
90
91 if let Err(e) = run(&["set-option", "-t", id, "status", "off"]).await {
93 tracing::warn!("failed to hide tmux status bar: {}", e);
94 }
95 Ok(())
96}
97
98pub async fn kill_session(id: &str) -> Result<()> {
100 match run(&["kill-session", "-t", id]).await {
101 Ok(_) => Ok(()),
102 Err(e) => {
103 let msg = e.to_string();
104 if msg.contains("no server running")
105 || msg.contains("can't find session")
106 || msg.contains("session not found")
107 || msg.contains("no sessions")
108 {
109 Ok(())
110 } else {
111 Err(e)
112 }
113 }
114 }
115}
116
117pub async fn has_session(id: &str) -> bool {
119 run(&["has-session", "-t", id]).await.is_ok()
120}
121
122pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
124 let raw = run_best_effort(&[
125 "list-sessions",
126 "-F",
127 "#{session_name}\t#{session_created}\t#{pane_pid}\t#{pane_tty}",
128 ])
129 .await;
130 Ok(raw
131 .lines()
132 .filter(|l| !l.is_empty())
133 .filter_map(|line| {
134 let mut cols = line.splitn(4, '\t');
135 let id = cols.next()?.to_string();
136 let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
137 let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
138 let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
139 Some(TmuxSession { id, created_ms: sec * 1000, pid, tty })
140 })
141 .collect())
142}
143
144pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
146 let out = run(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
147 Ok(out
148 .lines()
149 .next()
150 .map(|s| s.trim().to_string())
151 .filter(|s| !s.is_empty()))
152}
153
154pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
157 run(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
158 Ok(())
159}
160
161pub async fn resize_window(id: &str, cols: u16, rows: u16) -> Result<()> {
163 run(&[
164 "resize-window", "-t", id,
165 "-x", &cols.to_string(),
166 "-y", &rows.to_string(),
167 ]).await?;
168 Ok(())
169}
170
171pub async fn capture_pane(id: &str) -> Vec<u8> {
174 run(&["capture-pane", "-t", id, "-p", "-e"])
175 .await
176 .map(|s| s.into_bytes())
177 .unwrap_or_default()
178}
179
180pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
185 run(&["send-keys", "-t", session_id, "-l", text]).await?;
187 run(&["send-keys", "-t", session_id, "Enter"]).await?;
189 Ok(())
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn tmux_available() -> bool {
197 std::process::Command::new("tmux")
198 .args(["-V"])
199 .output()
200 .map(|o| o.status.success())
201 .unwrap_or(false)
202 }
203
204 fn unique_id() -> String {
205 format!(
206 "test-{}",
207 std::time::SystemTime::now()
208 .duration_since(std::time::UNIX_EPOCH)
209 .unwrap()
210 .as_millis()
211 )
212 }
213
214 #[tokio::test]
215 async fn create_and_has_and_kill() {
216 if !tmux_available() { return; }
217 let id = unique_id();
218 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
219 assert!(has_session(&id).await);
220 kill_session(&id).await.unwrap();
221 assert!(!has_session(&id).await);
222 }
223
224 #[tokio::test]
225 async fn list_includes_created() {
226 if !tmux_available() { return; }
227 let id = unique_id();
228 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
229 let sessions = list_sessions().await.unwrap();
230 assert!(sessions.iter().any(|s| s.id == id));
231 kill_session(&id).await.unwrap();
232 }
233
234 #[tokio::test]
235 async fn get_pane_tty_returns_dev_path() {
236 if !tmux_available() { return; }
237 let id = unique_id();
238 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
239 let tty = get_pane_tty(&id).await.unwrap();
240 assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
241 kill_session(&id).await.unwrap();
242 }
243
244 #[tokio::test]
245 async fn send_keys_builds_correct_command() {
246 let quoted = shell_quote("hello world");
249 assert_eq!(quoted, "'hello world'");
250 let with_apostrophe = shell_quote("don't");
251 assert_eq!(with_apostrophe, "'don'\\''t'");
252 }
253}