marver 0.0.29

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Opening a task's work in the user's editor.
//!
//! A task spans N repos and therefore owns N worktrees, so "open the worktree"
//! has no single answer — but the task's **workspace directory** does. It is the
//! parent the worktrees sit inside, it is the agent's own working directory, and
//! it is where `task.md` and `setup.log` are written. Opening it shows the whole
//! task at once, which is exactly what the agent was given; opening one worktree
//! would mean marver picking a repo on behalf of somebody who named several.
//!
//! **The editor is started in its own tmux session**, never in the terminal
//! marver is drawing into. A terminal editor launched there would take the
//! alternate screen and the raw-mode keyboard out from under the interface, and
//! two full-screen programs sharing one terminal is not a state either of them
//! can redraw its way out of.
//!
//! The other way out — refusing terminal editors — needs marver to tell one kind
//! from the other by name, and a list of names is wrong for somebody. Guess
//! "windowed" for a terminal editor nobody thought to add and the screen is
//! destroyed anyway, which is the failure the list existed to prevent. A tmux
//! session is right for both, so nothing is guessed: a terminal editor gets a
//! terminal to itself, and a windowed one puts its window up and exits, taking
//! the session with it.
//!
//! marver starts that session and never touches it again. It is not recorded
//! against the task and nothing reaps it, because an editor holds unsaved
//! buffers that no `git status` can see — killing one on marver's judgement
//! would lose work nobody could have been warned about.

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>;

/// The variables consulted, in order.
///
/// `VISUAL` first, which is the order every tool carrying both settled on:
/// `EDITOR` is allowed to name a line editor a script can drive, and `VISUAL` is
/// the one a person wants to look at.
pub const VARIABLES: [&str; 2] = ["VISUAL", "EDITOR"];

/// What to run, and which variable said so.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editor {
    pub variable: &'static str,
    pub program: String,
    /// Arguments the variable carried, placed before the path.
    pub args: Vec<String>,
}

/// The editor this environment names.
///
/// There is no third fallback. git guesses `vi` because it has to produce an
/// editor — a commit needs a message — and marver does not: a person who has
/// never asked for a modal editor should not meet one because they pressed a key
/// to look at a directory. Saying which variables to set is a shorter way out
/// than finding out how to leave `vi`.
pub fn configured() -> Result<Editor> {
    resolve(|name| std::env::var(name).ok())
}

/// [`configured`], with the environment supplied.
///
/// A seam rather than a convenience: `std::env::set_var` is unsafe under edition
/// 2024 and the environment is shared with every other test running at the same
/// moment, so the alternative is a test that fails depending on who else is
/// running.
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;
        };
        // Split the way a shell would, because these routinely hold arguments:
        // `code -w`, `emacsclient -nw`, `subl -n`. Same reason `--harness` is
        // parsed this way, and the same refusal for an unbalanced quote —
        // guessing where the word ends means running a different program.
        let words = shell_words::split(&value).map_err(|err| Error::BadCommand {
            variable,
            why: err.to_string(),
        })?;
        // `VISUAL=` is how a profile clears one, and it splits into no words at
        // all. There is no program in it, so it is not an answer: fall through
        // to `EDITOR` rather than failing on a variable that said nothing.
        let Some((program, args)) = words.split_first() else {
            continue;
        };
        return Ok(Editor {
            variable,
            program: program.clone(),
            args: args.to_vec(),
        });
    }
    Err(Error::NotConfigured)
}

/// The tmux session an editor for this task runs in.
///
/// The task's own session name with a suffix, so the two can never be confused.
/// [`crate::agent::owns_session`] decides ownership by working directory and
/// both stand in the same one, so the name is all that separates the editor from
/// the agent marver may be about to interrupt or kill.
pub fn session_name(prefix: Option<&str>, task_id: i64) -> String {
    format!("{}-edit", tmux::session_name(prefix, task_id))
}

/// What pressing the key amounted to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Opened {
    pub program: String,
    pub session: String,
    /// False when the session was already there. Pressing the key twice is how
    /// anyone asks "did that work?", and two editors over one directory is how
    /// a file gets saved over itself.
    pub started: bool,
}

impl Opened {
    /// One line for the status bar.
    ///
    /// The session name is in it because a detached session is otherwise
    /// unreachable: nothing in marver attaches to this one, so the name is the
    /// whole of how to get to a terminal editor sitting in it.
    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}")
        }
    }
}

/// Open a task's workspace directory in the user's editor.
pub fn open(tmux: &Tmux, prefix: Option<&str>, task: &Task) -> Result<Opened> {
    // Asked of the directory rather than of the state, because two very
    // different tasks arrive here as the same nothing to open: a queued one has
    // never had a workspace — `task_repos` carries the selection long before any
    // worktree exists — and `X` takes the whole directory away from a finished
    // one.
    if !task.workspace_dir.is_dir() {
        return Err(Error::NotOnDisk(task.id));
    }
    // After the directory check: "there is nothing to open" is a truer answer
    // than "you have not said what to open it with".
    start(tmux, prefix, task, &configured()?)
}

/// [`open`], with the editor already chosen.
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));
    // Last, and as an argument of its own. A workspace path is a real path from
    // a `--data-dir` marver did not choose, and tmux is handed an argv rather
    // than a line for a shell to re-split — the same reason an agent's prompt is
    // never typed into a session.
    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()
    }

    /// An environment holding exactly what a test put in it.
    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()
    }

    /// A task whose workspace directory exists, and the temporary root it is
    /// under.
    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() {
        // `EDITOR` is allowed to be a line editor a script can drive; `VISUAL`
        // is the one a person asked to look at things with.
        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() {
        // `code -w`, `emacsclient -nw`, `subl -n`: the useful spellings all
        // carry one.
        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() {
        // `export VISUAL=` is how a profile clears one, and taking it at face
        // value means trying to run a program with no name at all.
        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() {
        // Both stand in the task's workspace directory, so `owns_session`
        // cannot tell them apart and the name is the only thing that does.
        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();
        // An "editor" that says what it was given and then stays up, the way a
        // real one does.
        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() {
        // Two editors over one directory is how a file gets saved over itself,
        // and pressing a key twice is how anyone asks whether it worked.
        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() {
        // A queued task never had one; `X` takes it away from a finished one.
        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();
    }

    /// Wait for the pretend editor to run, rather than assuming a fixed delay.
    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());
    }
}