pub mod client;
pub mod control;
use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::process::Command;
pub use client::ControlClient;
pub use control::{Decoder, Event};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("could not run tmux: {0}")]
Spawn(#[source] std::io::Error),
#[error("tmux {args} failed: {stderr}")]
Failed { args: String, stderr: String },
#[error("session {0} does not exist")]
NoSuchSession(String),
}
pub type Result<T> = std::result::Result<T, Error>;
pub const DEFAULT_SIZE: (u16, u16) = (200, 50);
pub fn session_name(prefix: Option<&str>, task_id: i64) -> String {
match prefix {
Some(prefix) => format!("marver-{prefix}-{task_id}"),
None => format!("marver-{task_id}"),
}
}
pub fn session_prefix(data_dir: &std::path::Path) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in data_dir.as_os_str().as_encoded_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
format!("{:06x}", hash & 0xff_ffff)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Socket {
#[default]
Default,
Name(String),
Path(std::path::PathBuf),
}
impl Socket {
pub fn args(&self) -> Vec<std::ffi::OsString> {
match self {
Self::Default => Vec::new(),
Self::Name(name) => vec!["-L".into(), name.into()],
Self::Path(path) => vec!["-S".into(), path.clone().into_os_string()],
}
}
}
#[derive(Debug, Clone)]
pub struct Tmux {
socket: Socket,
binary: String,
}
impl Default for Tmux {
fn default() -> Self {
Self {
socket: Socket::default(),
binary: "tmux".to_string(),
}
}
}
impl Tmux {
pub fn new() -> Self {
Self::default()
}
pub fn with_socket(name: impl Into<String>) -> Self {
Self {
socket: Socket::Name(name.into()),
..Self::default()
}
}
pub fn with_socket_path(path: impl Into<std::path::PathBuf>) -> Self {
Self {
socket: Socket::Path(path.into()),
..Self::default()
}
}
#[cfg(test)]
pub(crate) fn with_binary(binary: impl Into<String>) -> Self {
Self {
binary: binary.into(),
..Self::default()
}
}
pub fn socket(&self) -> &Socket {
&self.socket
}
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.binary);
cmd.args(self.socket.args());
cmd
}
pub fn run<S: AsRef<OsStr>>(&self, args: &[S]) -> Result<String> {
let output = self.command().args(args).output().map_err(Error::Spawn)?;
if !output.status.success() {
return Err(Error::Failed {
args: args
.iter()
.map(|a| a.as_ref().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" "),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string())
}
pub fn server_running(&self) -> bool {
self.run(&["list-sessions", "-F", "#{session_name}"])
.is_ok()
}
pub fn has_session(&self, name: &str) -> bool {
self.session_exists(name).unwrap_or(false)
}
pub fn session_exists(&self, name: &str) -> Result<bool> {
match self.run(&["has-session", "-t", &exact(name)]) {
Ok(_) => Ok(true),
Err(Error::Failed { .. }) => Ok(false),
Err(other) => Err(other),
}
}
pub fn new_session(&self, name: &str, cwd: &Path, size: (u16, u16)) -> Result<()> {
self.new_session_running(name, cwd, size, &[])
}
pub fn new_session_running(
&self,
name: &str,
cwd: &Path,
size: (u16, u16),
argv: &[OsString],
) -> Result<()> {
let (cols, rows) = size;
let (cols, rows) = (cols.to_string(), rows.to_string());
let mut args: Vec<&OsStr> = vec![
OsStr::new("new-session"),
OsStr::new("-d"),
OsStr::new("-s"),
OsStr::new(name),
OsStr::new("-c"),
cwd.as_os_str(),
OsStr::new("-x"),
OsStr::new(&cols),
OsStr::new("-y"),
OsStr::new(&rows),
];
if !argv.is_empty() {
args.push(OsStr::new("--"));
args.extend(argv.iter().map(OsString::as_os_str));
}
self.run(&args)?;
Ok(())
}
pub fn kill_session(&self, name: &str) -> Result<()> {
if !self.has_session(name) {
return Err(Error::NoSuchSession(name.to_string()));
}
self.run(&["kill-session", "-t", &exact(name)])?;
Ok(())
}
pub fn kill_server(&self) -> Result<()> {
match self.run(&["kill-server"]) {
Ok(_) => Ok(()),
Err(Error::Failed { .. }) => Ok(()),
Err(other) => Err(other),
}
}
pub fn list_sessions(&self) -> Result<Vec<String>> {
match self.run(&["list-sessions", "-F", "#{session_name}"]) {
Ok(out) => Ok(non_empty_lines(&out)),
Err(Error::Failed { .. }) => Ok(Vec::new()),
Err(other) => Err(other),
}
}
pub fn list_panes(&self, session: &str) -> Result<Vec<String>> {
let out = self.run(&["list-panes", "-t", &exact(session), "-F", "#{pane_id}"])?;
Ok(non_empty_lines(&out))
}
pub fn send_keys(&self, target: &str, keys: &str) -> Result<()> {
self.run(&["send-keys", "-t", target, "-l", keys])?;
Ok(())
}
pub fn send_key(&self, target: &str, key: &str) -> Result<()> {
self.run(&["send-keys", "-t", target, key])?;
Ok(())
}
pub fn capture_pane(&self, target: &str) -> Result<String> {
self.run(&["capture-pane", "-p", "-t", target])
}
pub fn session_cwd(&self, session: &str) -> Result<String> {
let out = self.run(&[
"list-panes",
"-t",
&exact(session),
"-F",
"#{pane_current_path}",
])?;
non_empty_lines(&out)
.into_iter()
.next()
.ok_or_else(|| Error::NoSuchSession(session.to_string()))
}
}
fn exact(name: &str) -> String {
format!("={name}")
}
fn non_empty_lines(out: &str) -> Vec<String> {
out.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
use tempfile::TempDir;
pub struct TestServer {
pub tmux: Tmux,
_dir: TempDir,
}
impl TestServer {
pub fn new() -> Self {
let dir = TempDir::new().expect("socket dir");
Self {
tmux: Tmux::with_socket_path(dir.path().join("sock")),
_dir: dir,
}
}
}
impl Drop for TestServer {
fn drop(&mut self) {
let _ = self.tmux.kill_server();
}
}
}
#[cfg(test)]
mod tests {
use super::testing::TestServer;
use super::*;
use tempfile::TempDir;
#[test]
fn session_names_are_derived_from_the_task_and_the_marver_it_belongs_to() {
assert_eq!(session_name(None, 7), "marver-7");
assert_eq!(session_name(Some("a1b2c3"), 7), "marver-a1b2c3-7");
}
#[test]
fn two_data_directories_do_not_name_the_same_session() {
let a = session_prefix(Path::new("/Users/kit/.local/share/marver"));
let b = session_prefix(Path::new("/Users/ankit/.local/share/marver"));
assert_ne!(a, b);
assert_ne!(session_name(Some(&a), 1), session_name(Some(&b), 1));
}
#[test]
fn a_data_directorys_prefix_does_not_change_under_it() {
let dir = Path::new("/Users/kit/.local/share/marver");
assert_eq!(session_prefix(dir), session_prefix(dir));
assert_eq!(session_prefix(dir).len(), 6);
assert!(session_prefix(dir).chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn tests_never_use_the_default_socket() {
let server = TestServer::new();
assert!(
matches!(server.tmux.socket(), Socket::Path(_)),
"a test must not be able to reach the real tmux server"
);
}
#[test]
fn socket_selection_produces_the_right_flags() {
assert!(Tmux::new().socket().args().is_empty());
assert_eq!(Tmux::with_socket("x").socket().args(), ["-L", "x"]);
assert_eq!(
Tmux::with_socket_path("/tmp/s").socket().args(),
["-S", "/tmp/s"]
);
}
#[test]
fn a_test_socket_is_removed_with_its_directory() {
let path = {
let server = TestServer::new();
let tmp = TempDir::new().unwrap();
server
.tmux
.new_session("s", tmp.path(), DEFAULT_SIZE)
.unwrap();
let Socket::Path(path) = server.tmux.socket().clone() else {
panic!("expected a path socket");
};
assert!(path.exists(), "the socket should exist while running");
path
};
assert!(!path.exists(), "the socket must not outlive the test");
}
#[test]
fn creates_and_kills_a_session() {
let server = TestServer::new();
let tmp = TempDir::new().unwrap();
assert!(!server.tmux.has_session("work"));
server
.tmux
.new_session("work", tmp.path(), DEFAULT_SIZE)
.unwrap();
assert!(server.tmux.has_session("work"));
assert_eq!(server.tmux.list_sessions().unwrap(), ["work"]);
server.tmux.kill_session("work").unwrap();
assert!(!server.tmux.has_session("work"));
}
#[test]
fn a_session_starts_in_the_directory_it_was_given() {
let server = TestServer::new();
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("workspace");
std::fs::create_dir_all(&dir).unwrap();
server.tmux.new_session("t", &dir, DEFAULT_SIZE).unwrap();
let cwd = server.tmux.session_cwd("t").unwrap();
assert_eq!(
std::fs::canonicalize(cwd).unwrap(),
std::fs::canonicalize(&dir).unwrap()
);
}
#[test]
fn duplicate_session_names_are_refused() {
let server = TestServer::new();
let tmp = TempDir::new().unwrap();
server
.tmux
.new_session("dup", tmp.path(), DEFAULT_SIZE)
.unwrap();
assert!(
server
.tmux
.new_session("dup", tmp.path(), DEFAULT_SIZE)
.is_err(),
"an existing session must never be silently adopted"
);
}
#[test]
fn session_targets_are_matched_exactly() {
let server = TestServer::new();
let tmp = TempDir::new().unwrap();
server
.tmux
.new_session("marver-12", tmp.path(), DEFAULT_SIZE)
.unwrap();
assert!(
!server.tmux.has_session("marver-1"),
"marver-1 must not match marver-12"
);
assert!(server.tmux.has_session("marver-12"));
}
#[test]
fn killing_a_missing_session_is_an_error_not_a_silent_success() {
let server = TestServer::new();
assert!(matches!(
server.tmux.kill_session("ghost"),
Err(Error::NoSuchSession(_))
));
}
#[test]
fn listing_sessions_with_no_server_is_empty_not_an_error() {
let server = TestServer::new();
assert!(!server.tmux.server_running());
assert_eq!(server.tmux.list_sessions().unwrap(), Vec::<String>::new());
}
#[test]
fn panes_can_be_listed_typed_into_and_captured() {
let server = TestServer::new();
let tmp = TempDir::new().unwrap();
server
.tmux
.new_session("io", tmp.path(), DEFAULT_SIZE)
.unwrap();
let panes = server.tmux.list_panes("io").unwrap();
assert_eq!(panes.len(), 1);
assert!(panes[0].starts_with('%'), "pane ids look like %0");
server
.tmux
.send_keys(&panes[0], "printf MARVERTEST")
.unwrap();
server.tmux.send_key(&panes[0], "Enter").unwrap();
let mut captured = String::new();
for _ in 0..50 {
captured = server.tmux.capture_pane(&panes[0]).unwrap();
if captured.contains("MARVERTEST") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(40));
}
assert!(captured.contains("MARVERTEST"), "captured: {captured:?}");
}
#[test]
fn killing_the_server_is_idempotent() {
let server = TestServer::new();
server.tmux.kill_server().unwrap();
server.tmux.kill_server().unwrap();
}
}