use std::ffi::OsString;
use std::path::PathBuf;
use crate::domain::Task;
use crate::tmux::{self, Tmux};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("tmux: {0}")]
Tmux(#[from] tmux::Error),
#[error("task {task} has nothing at {} to open a shell in", .workspace.display())]
NoWorkspace { task: i64, workspace: PathBuf },
#[error("session {session} is not working in task {task}'s workspace")]
NotOurs { task: i64, session: String },
}
pub type Result<T> = std::result::Result<T, Error>;
pub fn program() -> OsString {
match std::env::var_os("SHELL") {
Some(shell) if !shell.is_empty() => shell,
_ => OsString::from("/bin/sh"),
}
}
pub fn open(tmux: &Tmux, task: &Task, prefix: Option<&str>, size: (u16, u16)) -> Result<String> {
let session = tmux::shell_session_name(prefix, task.id);
if tmux.session_exists(&session)? {
if !is_ours(tmux, task, &session) {
return Err(Error::NotOurs {
task: task.id,
session,
});
}
return Ok(session);
}
if !task.workspace_dir.is_dir() {
return Err(Error::NoWorkspace {
task: task.id,
workspace: task.workspace_dir.clone(),
});
}
tmux.new_session_running(&session, &task.workspace_dir, size, &[program()])?;
Ok(session)
}
pub fn close(tmux: &Tmux, task: &Task, prefix: Option<&str>) -> Result<()> {
let session = tmux::shell_session_name(prefix, task.id);
if !tmux.session_exists(&session)? {
return Ok(());
}
if !is_ours(tmux, task, &session) {
return Ok(());
}
match tmux.kill_session(&session) {
Ok(()) | Err(tmux::Error::NoSuchSession(_)) => Ok(()),
Err(err) => Err(err.into()),
}
}
fn is_ours(tmux: &Tmux, task: &Task, session: &str) -> bool {
let Ok(Some(opened)) = tmux.session_start_path(session) else {
return false;
};
let (Ok(opened), Ok(workspace)) = (
std::fs::canonicalize(&opened),
std::fs::canonicalize(&task.workspace_dir),
) else {
return false;
};
opened.starts_with(workspace)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::tmux::testing::TestServer;
use chrono::Utc;
use std::path::Path;
use tempfile::TempDir;
fn task_in(dir: &Path, store: &mut Store) -> Task {
let task = store
.create_task("a task", "do it", &dir.join("tasks"), &[], Utc::now())
.unwrap();
std::fs::create_dir_all(&task.workspace_dir).unwrap();
task
}
fn wait_for_pane(server: &TestServer, session: &str, needle: &str) -> String {
let mut seen = String::new();
for _ in 0..80 {
if let Ok(panes) = server.tmux.list_panes(session)
&& let Some(pane) = panes.first()
&& let Ok(text) = server.tmux.capture_pane(pane)
{
seen = text;
if seen.contains(needle) {
return seen;
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
seen
}
#[test]
fn a_shell_starts_in_the_tasks_workspace_and_can_be_typed_at() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
let session = open(&server.tmux, &task, None, (80, 24)).unwrap();
assert!(server.tmux.has_session(&session));
assert_eq!(
std::fs::canonicalize(server.tmux.session_cwd(&session).unwrap()).unwrap(),
std::fs::canonicalize(&task.workspace_dir).unwrap()
);
let pane = server.tmux.list_panes(&session).unwrap().remove(0);
server.tmux.send_keys(&pane, "printf MARVERSHELL").unwrap();
server.tmux.send_key(&pane, "Enter").unwrap();
let seen = wait_for_pane(&server, &session, "MARVERSHELL");
assert!(seen.contains("MARVERSHELL"), "captured: {seen:?}");
}
#[test]
fn opening_a_shell_twice_comes_back_to_the_one_already_there() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
let first = open(&server.tmux, &task, None, (80, 24)).unwrap();
let pane = server.tmux.list_panes(&first).unwrap().remove(0);
server.tmux.send_keys(&pane, "printf MARVERAGAIN").unwrap();
server.tmux.send_key(&pane, "Enter").unwrap();
wait_for_pane(&server, &first, "MARVERAGAIN");
let again = open(&server.tmux, &task, None, (80, 24)).unwrap();
assert_eq!(first, again);
assert!(
server
.tmux
.capture_pane(&pane)
.unwrap()
.contains("MARVERAGAIN"),
"the same session, with what was in it"
);
}
#[test]
fn a_task_with_no_workspace_yet_is_told_so_rather_than_left_with_nothing() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = store
.create_task(
"waiting",
"do it",
&dir.path().join("tasks"),
&[],
Utc::now(),
)
.unwrap();
assert!(matches!(
open(&server.tmux, &task, None, (80, 24)),
Err(Error::NoWorkspace { .. })
));
assert!(
!server
.tmux
.has_session(&tmux::shell_session_name(None, task.id))
);
}
#[test]
fn a_workspace_that_has_been_reclaimed_is_the_same_answer() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
std::fs::remove_dir_all(&task.workspace_dir).unwrap();
assert!(matches!(
open(&server.tmux, &task, None, (80, 24)),
Err(Error::NoWorkspace { .. })
));
}
#[test]
fn a_namesake_shell_is_refused_rather_than_adopted() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
let elsewhere = dir.path().join("not-the-workspace");
std::fs::create_dir_all(&elsewhere).unwrap();
let name = tmux::shell_session_name(None, task.id);
server
.tmux
.new_session(&name, &elsewhere, (80, 24))
.unwrap();
assert!(matches!(
open(&server.tmux, &task, None, (80, 24)),
Err(Error::NotOurs { .. })
));
close(&server.tmux, &task, None).unwrap();
assert!(
server.tmux.has_session(&name),
"and a session marver did not open must survive being closed over"
);
}
#[test]
fn a_shell_that_has_been_cd_ed_out_of_the_workspace_is_still_closed() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
let session = open(&server.tmux, &task, None, (80, 24)).unwrap();
let pane = server.tmux.list_panes(&session).unwrap().remove(0);
let mut cwd = String::new();
for _ in 0..80 {
server.tmux.send_keys(&pane, "cd /").unwrap();
server.tmux.send_key(&pane, "Enter").unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
if let Ok(seen) = server.tmux.session_cwd(&session) {
cwd = seen;
if cwd == "/" {
break;
}
}
}
assert_eq!(cwd, "/", "the shell must really have moved");
assert!(
!crate::agent::owns_session(&server.tmux, &task, &session),
"the agent's test would already have given up on it"
);
close(&server.tmux, &task, None).unwrap();
assert!(!server.tmux.has_session(&session));
}
#[test]
fn closing_a_task_that_never_opened_a_shell_is_harmless() {
let dir = TempDir::new().unwrap();
let server = TestServer::new();
let mut store = Store::open_in_memory().unwrap();
let task = task_in(dir.path(), &mut store);
close(&server.tmux, &task, None).unwrap();
}
#[test]
fn the_shell_is_the_users_own_with_a_fallback_every_unix_has() {
let was = std::env::var_os("SHELL");
unsafe { std::env::set_var("SHELL", "/bin/zsh") };
assert_eq!(program(), OsString::from("/bin/zsh"));
unsafe { std::env::set_var("SHELL", "") };
assert_eq!(program(), OsString::from("/bin/sh"));
unsafe { std::env::remove_var("SHELL") };
assert_eq!(program(), OsString::from("/bin/sh"));
match was {
Some(value) => unsafe { std::env::set_var("SHELL", value) },
None => unsafe { std::env::remove_var("SHELL") },
}
}
}