use anyhow::{Context, Result};
use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct TmuxSession {
pub id: String,
pub created_ms: i64,
pub pid: Option<u32>,
pub tty: Option<String>,
}
async fn run(args: &[&str]) -> Result<String> {
let out = Command::new("tmux")
.args(args)
.kill_on_drop(true)
.output()
.await
.context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
}
async fn run_best_effort(args: &[&str]) -> String {
match run(args).await {
Ok(result) => result,
Err(e) => {
tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
String::new()
}
}
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub async fn create_session(
id: &str,
workspace: &str,
cmd: &str,
env: &[(&str, &str)],
) -> Result<()> {
let mut env_pairs: Vec<String> = Vec::new();
for (k, v) in env {
anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
env_pairs.push(format!("{k}={v}"));
}
let mut extra: Vec<&str> = Vec::new();
for pair in &env_pairs {
extra.push("-e");
extra.push(pair.as_str());
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
base.extend_from_slice(&extra);
base.push(&shell_cmd);
for attempt in 0..2u8 {
match run(&base).await {
Ok(_) => break,
Err(e) if attempt == 0 && e.to_string().contains("duplicate session") => {
run_best_effort(&["kill-session", "-t", id]).await;
}
Err(e) => return Err(e),
}
}
if let Err(e) = run(&["set-option", "-t", id, "status", "off"]).await {
tracing::warn!("failed to hide tmux status bar: {}", e);
}
Ok(())
}
pub async fn kill_session(id: &str) -> Result<()> {
match run(&["kill-session", "-t", id]).await {
Ok(_) => Ok(()),
Err(e) => {
let msg = e.to_string();
if msg.contains("no server running")
|| msg.contains("can't find session")
|| msg.contains("session not found")
|| msg.contains("no sessions")
{
Ok(())
} else {
Err(e)
}
}
}
}
pub async fn has_session(id: &str) -> bool {
run(&["has-session", "-t", id]).await.is_ok()
}
pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
let raw = run_best_effort(&[
"list-sessions",
"-F",
"#{session_name}\t#{session_created}\t#{pane_pid}\t#{pane_tty}",
])
.await;
Ok(raw
.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let mut cols = line.splitn(4, '\t');
let id = cols.next()?.to_string();
let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
Some(TmuxSession { id, created_ms: sec * 1000, pid, tty })
})
.collect())
}
pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
let out = run(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
Ok(out
.lines()
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()))
}
pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
run(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
Ok(())
}
pub async fn resize_window(id: &str, cols: u16, rows: u16) -> Result<()> {
run(&[
"resize-window", "-t", id,
"-x", &cols.to_string(),
"-y", &rows.to_string(),
]).await?;
Ok(())
}
pub async fn capture_pane(id: &str) -> Vec<u8> {
run(&["capture-pane", "-t", id, "-p", "-e"])
.await
.map(|s| s.into_bytes())
.unwrap_or_default()
}
pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
run(&["send-keys", "-t", session_id, "-l", text]).await?;
run(&["send-keys", "-t", session_id, "Enter"]).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmux_available() -> bool {
std::process::Command::new("tmux")
.args(["-V"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn unique_id() -> String {
format!(
"test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
)
}
#[tokio::test]
async fn create_and_has_and_kill() {
if !tmux_available() { return; }
let id = unique_id();
create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
assert!(has_session(&id).await);
kill_session(&id).await.unwrap();
assert!(!has_session(&id).await);
}
#[tokio::test]
async fn list_includes_created() {
if !tmux_available() { return; }
let id = unique_id();
create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
let sessions = list_sessions().await.unwrap();
assert!(sessions.iter().any(|s| s.id == id));
kill_session(&id).await.unwrap();
}
#[tokio::test]
async fn get_pane_tty_returns_dev_path() {
if !tmux_available() { return; }
let id = unique_id();
create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
let tty = get_pane_tty(&id).await.unwrap();
assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
kill_session(&id).await.unwrap();
}
#[tokio::test]
async fn send_keys_builds_correct_command() {
let quoted = shell_quote("hello world");
assert_eq!(quoted, "'hello world'");
let with_apostrophe = shell_quote("don't");
assert_eq!(with_apostrophe, "'don'\\''t'");
}
}