use std::path::Path;
use std::process::{Command, Stdio};
use crate::{Error, require};
const TMUX: &str = "tmux";
pub const TEST_SESSION_PREFIX: &str = "test-";
pub fn is_reserved_session_name(name: &str) -> bool {
if name == "agentinfinity" || name == "netsky-ticker" {
return true;
}
matches!(
name.strip_prefix("agent"),
Some(rest) if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
)
}
pub fn validate_session_name(name: &str) -> Result<(), Error> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(Error::Tmux(format!("invalid session name: {name}")));
}
Ok(())
}
pub fn has_session(name: &str) -> bool {
if validate_session_name(name).is_err() {
return false;
}
Command::new(TMUX)
.args(["has-session", "-t", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn new_session_detached(
name: &str,
cmd: &str,
cwd: Option<&Path>,
env: &[(&str, &str)],
) -> Result<(), Error> {
validate_session_name(name)?;
require(TMUX)?;
if has_session(name) {
return Err(Error::Tmux(format!("session '{name}' already exists")));
}
let mut args: Vec<String> = vec!["new-session".into(), "-d".into(), "-s".into(), name.into()];
if let Some(dir) = cwd {
args.push("-c".into());
args.push(dir.display().to_string());
}
for (k, v) in env {
args.push("-e".into());
args.push(format!("{k}={v}"));
}
args.push(cmd.into());
let output = Command::new(TMUX).args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
};
return Err(Error::Tmux(format!(
"failed to create session '{name}'{detail}"
)));
}
let status = Command::new(TMUX)
.args(["set-option", "-t", name, "remain-on-exit", "on"])
.status()?;
if !status.success() {
return Err(Error::Tmux(format!(
"failed to enable remain-on-exit for session '{name}'"
)));
}
Ok(())
}
pub fn new_test_session_detached(
name: &str,
cmd: &str,
cwd: Option<&Path>,
env: &[(&str, &str)],
) -> Result<(), Error> {
if !name.starts_with(TEST_SESSION_PREFIX) {
return Err(Error::Tmux(format!(
"test tmux session '{name}' must start with '{TEST_SESSION_PREFIX}'"
)));
}
if is_reserved_session_name(name) {
return Err(Error::Tmux(format!(
"refusing to create tmux session '{name}': reserved constellation name"
)));
}
new_session_detached(name, cmd, cwd, env)
}
pub fn session_is_alive(name: &str) -> bool {
if !has_session(name) {
return false;
}
let output = Command::new(TMUX)
.args(["list-panes", "-t", name, "-F", "#{pane_dead}"])
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.any(|line| line.trim() == "0"),
_ => false,
}
}
pub fn kill_session(name: &str) -> Result<(), Error> {
validate_session_name(name)?;
require(TMUX)?;
if !has_session(name) {
return Ok(());
}
let status = Command::new(TMUX)
.args(["kill-session", "-t", name])
.status()?;
if !status.success() {
return Err(Error::Tmux(format!("failed to kill session '{name}'")));
}
Ok(())
}
pub fn attach(name: &str) -> Result<(), Error> {
validate_session_name(name)?;
require(TMUX)?;
if !has_session(name) {
return Err(Error::Tmux(format!("session '{name}' does not exist")));
}
let status = Command::new(TMUX).args(["attach", "-t", name]).status()?;
if !status.success() {
return Err(Error::Tmux(format!("failed to attach to session '{name}'")));
}
Ok(())
}
pub fn send_keys(name: &str, keys: &str) -> Result<(), Error> {
validate_session_name(name)?;
require(TMUX)?;
if !has_session(name) {
return Err(Error::Tmux(format!("session '{name}' does not exist")));
}
let status = Command::new(TMUX)
.args(["send-keys", "-t", name, keys, "Enter"])
.status()?;
if !status.success() {
return Err(Error::Tmux(format!(
"failed to send keys to session '{name}'"
)));
}
Ok(())
}
pub fn capture_pane(name: &str, lines: Option<usize>) -> Result<String, Error> {
validate_session_name(name)?;
require(TMUX)?;
if !has_session(name) {
return Err(Error::Tmux(format!("session '{name}' does not exist")));
}
let start_arg;
let mut args: Vec<&str> = vec!["capture-pane", "-t", name, "-p"];
if let Some(n) = lines {
start_arg = format!("-{n}");
args.extend(["-S", &start_arg]);
}
let output = Command::new(TMUX).args(&args).output()?;
if !output.status.success() {
return Err(Error::Tmux(format!(
"failed to capture pane from session '{name}'"
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn list_sessions() -> Vec<String> {
let output = Command::new(TMUX)
.args(["list-sessions", "-F", "#{session_name}"])
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|s| s.to_string())
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_names() {
assert!(validate_session_name("agent0").is_ok());
assert!(validate_session_name("agent-infinity").is_ok());
assert!(validate_session_name("agent_0").is_ok());
assert!(validate_session_name("").is_err());
assert!(validate_session_name("bad name").is_err());
assert!(validate_session_name("bad:name").is_err());
assert!(validate_session_name("bad.name").is_err());
}
#[test]
fn has_session_nonexistent() {
assert!(!has_session("netsky_sh_test_nonexistent_99999"));
assert!(!session_is_alive("netsky_sh_test_nonexistent_99999"));
}
#[test]
fn reserved_names_are_detected() {
assert!(is_reserved_session_name("agent0"));
assert!(is_reserved_session_name("agent7"));
assert!(is_reserved_session_name("agent999"));
assert!(is_reserved_session_name("agentinfinity"));
assert!(is_reserved_session_name("netsky-ticker"));
}
#[test]
fn reserved_names_exclude_test_prefixed_and_arbitrary() {
assert!(!is_reserved_session_name("test-agent0"));
assert!(!is_reserved_session_name("test-agent97"));
assert!(!is_reserved_session_name("test-agentinfinity"));
assert!(!is_reserved_session_name("agentfoo"));
assert!(!is_reserved_session_name("scratch"));
assert!(!is_reserved_session_name(""));
}
#[test]
fn new_test_session_rejects_missing_prefix() {
let err = new_test_session_detached("agent97", "sleep 1", None, &[]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("must start with 'test-'"), "msg={msg}");
}
#[test]
fn new_test_session_rejects_reserved_test_prefix_edge() {
assert!(is_reserved_session_name("agent0"));
assert!(!"agent0".starts_with(TEST_SESSION_PREFIX));
}
}