use std::ffi::OsString;
use crate::domain::Task;
use crate::tmux::{self, Tmux};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("set $VISUAL or $EDITOR to say what to open a task with")]
NotConfigured,
#[error("${variable} is not a command marver can read: {why}")]
BadCommand { variable: &'static str, why: String },
#[error("task {0} has no workspace on disk — not launched, or already reclaimed")]
NotOnDisk(i64),
#[error(transparent)]
Tmux(#[from] tmux::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub const VARIABLES: [&str; 2] = ["VISUAL", "EDITOR"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editor {
pub variable: &'static str,
pub program: String,
pub args: Vec<String>,
}
pub fn configured() -> Result<Editor> {
resolve(|name| std::env::var(name).ok())
}
pub fn resolve(mut look_up: impl FnMut(&str) -> Option<String>) -> Result<Editor> {
for variable in VARIABLES {
let Some(value) = look_up(variable) else {
continue;
};
let words = shell_words::split(&value).map_err(|err| Error::BadCommand {
variable,
why: err.to_string(),
})?;
let Some((program, args)) = words.split_first() else {
continue;
};
return Ok(Editor {
variable,
program: program.clone(),
args: args.to_vec(),
});
}
Err(Error::NotConfigured)
}
pub fn session_name(prefix: Option<&str>, task_id: i64) -> String {
format!("{}-edit", tmux::session_name(prefix, task_id))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Opened {
pub program: String,
pub session: String,
pub started: bool,
}
impl Opened {
pub fn says(&self, task_id: i64) -> String {
let Self {
program, session, ..
} = self;
if self.started {
format!("task {task_id} in {program} · tmux attach -t {session}")
} else {
format!("task {task_id} is already in {program} · tmux attach -t {session}")
}
}
}
pub fn open(tmux: &Tmux, prefix: Option<&str>, task: &Task) -> Result<Opened> {
if !task.workspace_dir.is_dir() {
return Err(Error::NotOnDisk(task.id));
}
start(tmux, prefix, task, &configured()?)
}
fn start(tmux: &Tmux, prefix: Option<&str>, task: &Task, editor: &Editor) -> Result<Opened> {
let session = session_name(prefix, task.id);
if tmux.session_exists(&session)? {
return Ok(Opened {
program: editor.program.clone(),
session,
started: false,
});
}
let mut argv: Vec<OsString> = vec![editor.program.clone().into()];
argv.extend(editor.args.iter().map(OsString::from));
argv.push(task.workspace_dir.clone().into_os_string());
tmux.new_session_running(&session, &task.workspace_dir, tmux::DEFAULT_SIZE, &argv)?;
Ok(Opened {
program: editor.program.clone(),
session,
started: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::tmux::testing::TestServer;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::path::Path;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn env(pairs: &[(&str, &str)]) -> impl FnMut(&str) -> Option<String> {
let held: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |name: &str| held.get(name).cloned()
}
fn task_on_disk() -> (Store, Task, TempDir) {
let tmp = TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
let task = store
.create_task("look at it", "p", &tmp.path().join("tasks"), &[], at(0))
.unwrap();
std::fs::create_dir_all(&task.workspace_dir).unwrap();
(store, task, tmp)
}
#[test]
fn visual_is_read_before_editor() {
let found = resolve(env(&[("VISUAL", "zed"), ("EDITOR", "ed")])).unwrap();
assert_eq!(found.variable, "VISUAL");
assert_eq!(found.program, "zed");
assert!(found.args.is_empty());
}
#[test]
fn an_editor_keeps_the_arguments_it_was_given() {
let found = resolve(env(&[("EDITOR", "code -w --new-window")])).unwrap();
assert_eq!(found.variable, "EDITOR");
assert_eq!(found.program, "code");
assert_eq!(found.args, ["-w", "--new-window"]);
}
#[test]
fn a_variable_left_blank_is_not_an_answer() {
let found = resolve(env(&[("VISUAL", " "), ("EDITOR", "hx")])).unwrap();
assert_eq!(found.program, "hx");
assert!(matches!(
resolve(env(&[("VISUAL", ""), ("EDITOR", "")])),
Err(Error::NotConfigured)
));
}
#[test]
fn an_unbalanced_quote_is_refused_rather_than_guessed_at() {
let err = resolve(env(&[("EDITOR", "code --arg \"unclosed")])).unwrap_err();
assert!(matches!(err, Error::BadCommand { .. }), "{err}");
assert!(err.to_string().contains("EDITOR"), "{err}");
}
#[test]
fn with_neither_set_it_says_which_variables_to_set() {
let err = resolve(env(&[])).unwrap_err();
for variable in VARIABLES {
assert!(err.to_string().contains(variable), "{err}");
}
}
#[test]
fn an_editors_session_cannot_be_mistaken_for_its_agents() {
assert_ne!(
session_name(Some("a1b2c3"), 7),
tmux::session_name(Some("a1b2c3"), 7)
);
assert!(session_name(None, 7).starts_with(&tmux::session_name(None, 7)));
}
#[test]
fn the_editor_runs_in_the_workspace_and_is_handed_it() {
let server = TestServer::new();
let (_store, task, tmp) = task_on_disk();
let program = tmp.path().join("pretend-editor");
std::fs::write(
&program,
"#!/bin/sh\nprintf '%s' \"$1\" > \"$1/opened\"\nsleep 30\n",
)
.unwrap();
make_executable(&program);
let editor = Editor {
variable: "EDITOR",
program: program.to_string_lossy().into_owned(),
args: Vec::new(),
};
let opened = start(&server.tmux, Some("a1b2c3"), &task, &editor).unwrap();
assert!(opened.started);
assert!(server.tmux.has_session(&opened.session));
let marker = task.workspace_dir.join("opened");
let recorded = wait_for(&marker);
assert_eq!(
Path::new(&recorded),
task.workspace_dir,
"the workspace is what the editor was handed"
);
assert_eq!(
std::fs::canonicalize(server.tmux.session_cwd(&opened.session).unwrap()).unwrap(),
std::fs::canonicalize(&task.workspace_dir).unwrap(),
"and where it is standing"
);
}
#[test]
fn pressing_the_key_again_finds_the_editor_rather_than_opening_a_second() {
let server = TestServer::new();
let (_store, task, _tmp) = task_on_disk();
let editor = Editor {
variable: "EDITOR",
program: "sh".to_string(),
args: vec!["-c".to_string(), "sleep 30".to_string()],
};
let first = start(&server.tmux, None, &task, &editor).unwrap();
let second = start(&server.tmux, None, &task, &editor).unwrap();
assert!(first.started);
assert!(!second.started);
assert_eq!(first.session, second.session);
assert_eq!(
server.tmux.list_sessions().unwrap(),
std::slice::from_ref(&first.session),
"one session, however many times the key is pressed"
);
assert!(
second.says(task.id).contains("already"),
"{}",
second.says(task.id)
);
}
#[test]
fn a_task_with_no_workspace_on_disk_is_refused_before_anything_is_started() {
let tmp = TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
let task = store
.create_task("not launched", "p", &tmp.path().join("tasks"), &[], at(0))
.unwrap();
let server = TestServer::new();
let err = open(&server.tmux, None, &task).unwrap_err();
assert!(matches!(err, Error::NotOnDisk(_)), "{err}");
assert!(
server.tmux.list_sessions().unwrap().is_empty(),
"nothing should have been started for a directory that is not there"
);
}
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
fn wait_for(path: &Path) -> String {
for _ in 0..100 {
if let Ok(text) = std::fs::read_to_string(path) {
return text;
}
std::thread::sleep(std::time::Duration::from_millis(40));
}
panic!("the editor never ran: {}", path.display());
}
}