marver 0.0.28

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! What the binary says about itself.

use std::process::Command;

use marver::daemon::Config;
use tempfile::TempDir;

fn run(args: &[&str]) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(args)
        .output()
        .expect("run marver")
}

#[test]
fn the_version_flag_reports_the_crate_version() {
    // All three spellings. `-v` is what people actually type, and before it was
    // accepted it fell through to the interface — printing no version, failing
    // on the missing terminal, and leaving a daemon running behind it.
    for flag in ["-v", "-V", "--version"] {
        let out = run(&[flag]);
        let text = String::from_utf8_lossy(&out.stdout).trim().to_string();

        assert!(out.status.success(), "{flag} should exit 0");
        assert_eq!(text, format!("marver {}", env!("CARGO_PKG_VERSION")));
    }
}

#[test]
fn the_version_flag_does_not_open_the_interface() {
    // A leading flag otherwise falls through to the interface, which would try
    // to take over the terminal — and, since 0.0.6, start a daemon on the way.
    // The version arm has to come first.
    let out = run(&["--version"]);
    let text = String::from_utf8_lossy(&out.stdout);
    assert!(!text.contains("Tasks"), "{text:?}");
    assert!(text.lines().count() == 1, "one line and no more: {text:?}");
}

#[test]
fn an_unknown_subcommand_is_refused_rather_than_assumed() {
    let out = run(&["wat"]);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(!out.status.success());
    assert!(stderr.contains("unknown command"), "{stderr:?}");
    assert!(
        stderr.contains("usage:"),
        "and it shows what to do: {stderr:?}"
    );
}

#[test]
fn an_unknown_option_is_refused_instead_of_opening_the_interface() {
    // A leading dash used to mean "this is for the interface", so any typo
    // opened the TUI — and since the interface began spawning daemons, left one
    // running behind the error it then printed. The data dir is a throwaway, so
    // a regression here cannot touch the real one.
    let dir = TempDir::new().unwrap();
    for bad in ["--verbose", "-x", "--dat-dir"] {
        let out = Command::new(env!("CARGO_BIN_EXE_marver"))
            .args([bad, "--data-dir"])
            .arg(dir.path())
            .output()
            .expect("run marver");
        let stderr = String::from_utf8_lossy(&out.stderr);

        assert!(!out.status.success(), "{bad} should be refused");
        assert!(stderr.contains("unknown option"), "{bad}: {stderr:?}");
        assert!(stderr.contains("usage:"), "{bad}: {stderr:?}");
    }
    assert!(
        !marver::daemon::is_running(&Config::new(dir.path(), dir.path())),
        "a refused option must not have started anything"
    );
}

#[test]
fn known_options_still_reach_the_interface() {
    // The other side of the check: `--data-dir` is not a typo, and rejecting it
    // would make the interface unreachable with any configuration at all.
    // Nothing here has a terminal, so the TUI fails once it gets that far —
    // which is itself the proof it got there.
    let dir = TempDir::new().unwrap();
    let out = Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(["--data-dir"])
        .arg(dir.path())
        .output()
        .expect("run marver");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("unknown option"),
        "a real option was rejected: {stderr:?}"
    );

    // It got far enough to start a daemon, so clean it up.
    let config = Config::new(dir.path(), dir.path());
    if marver::daemon::is_running(&config) {
        let _ = Command::new("pkill")
            .arg("-f")
            .arg(format!("marver daemon --data-dir {}", dir.path().display()))
            .status();
    }
}

/// A stand-in for cargo that records its arguments and exits how it is told.
///
/// The real thing would reach the network and replace the binary running the
/// test, so the installer marver drives is overridable.
fn stub_cargo(dir: &TempDir, exit: i32) -> std::path::PathBuf {
    let path = dir.path().join("stub-cargo");
    std::fs::write(
        &path,
        format!(
            "#!/bin/sh\necho \"$@\" > {}/cargo-args\nexit {exit}\n",
            dir.path().display()
        ),
    )
    .expect("write stub");
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
    path
}

#[test]
fn upgrade_asks_cargo_for_the_published_crate() {
    let dir = TempDir::new().unwrap();
    let out = Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(["upgrade", "--data-dir"])
        .arg(dir.path())
        .env("MARVER_CARGO", stub_cargo(&dir, 0))
        .output()
        .expect("run marver upgrade");
    let stderr = String::from_utf8_lossy(&out.stderr);

    assert!(out.status.success(), "{stderr:?}");
    let asked = std::fs::read_to_string(dir.path().join("cargo-args")).expect("stub ran");
    assert_eq!(asked.trim(), "install marver --force");
    // The binary under test is the one already on disk, so the version it reads
    // back is unchanged — which is exactly the "already on" case.
    assert!(stderr.contains(env!("CARGO_PKG_VERSION")), "{stderr:?}");
}

#[test]
fn a_failed_upgrade_says_nothing_changed() {
    let dir = TempDir::new().unwrap();
    let out = Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(["upgrade", "--data-dir"])
        .arg(dir.path())
        .env("MARVER_CARGO", stub_cargo(&dir, 1))
        .output()
        .expect("run marver upgrade");
    let stderr = String::from_utf8_lossy(&out.stderr);

    assert!(
        !out.status.success(),
        "a failed install must not report success"
    );
    assert!(stderr.contains("nothing was changed"), "{stderr:?}");
}

#[test]
fn upgrade_without_cargo_explains_rather_than_failing_obscurely() {
    // Installed from a package manager or a binary drop, marver cannot upgrade
    // itself and should say which way is up.
    let dir = TempDir::new().unwrap();
    let out = Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(["upgrade", "--data-dir"])
        .arg(dir.path())
        .env("MARVER_CARGO", dir.path().join("no-such-cargo"))
        .output()
        .expect("run marver upgrade");
    let stderr = String::from_utf8_lossy(&out.stderr);

    assert!(!out.status.success());
    assert!(
        stderr.contains("the way you installed it"),
        "it should point elsewhere, not just error: {stderr:?}"
    );
}

#[test]
fn output_survives_a_reader_that_stops_reading() {
    // `println!` panics on EPIPE and Rust ignores SIGPIPE, so `marver scan |
    // head -1` crashed rather than ending. Every command that prints now goes
    // through one writer that treats a closed pipe as an ordinary end.
    let dir = TempDir::new().unwrap();
    let piped = Command::new("sh")
        .arg("-c")
        .arg(format!(
            "{} scan {} | head -1",
            env!("CARGO_BIN_EXE_marver"),
            dir.path().display()
        ))
        .output()
        .expect("run the pipeline");

    let stderr = String::from_utf8_lossy(&piped.stderr);
    assert!(
        !stderr.contains("panicked"),
        "a closed pipe must not panic: {stderr:?}"
    );
    assert!(!stderr.contains("Broken pipe"), "{stderr:?}");
}

#[test]
fn help_asked_for_goes_to_stdout_and_succeeds() {
    // `marver --help | less` is someone reading. A shell that treats it as a
    // failure is wrong about what happened, and the text belongs on stdout
    // where a pipe can reach it.
    let out = run(&["--help"]);
    let text = String::from_utf8_lossy(&out.stdout);

    assert!(out.status.success(), "help was asked for, not misused");
    assert!(
        String::from_utf8_lossy(&out.stderr).is_empty(),
        "nothing went wrong, so stderr stays quiet"
    );
    for command in ["status", "daemon", "scan", "hook", "--version"] {
        assert!(
            text.contains(command),
            "{command} missing from help: {text:?}"
        );
    }
}

#[test]
fn help_as_a_correction_goes_to_stderr_and_fails() {
    // The same text, the opposite event. Sharing one exit code between them is
    // what made `--help` report failure in the first place.
    let out = run(&["wat"]);
    let stderr = String::from_utf8_lossy(&out.stderr);

    assert!(!out.status.success());
    assert!(stderr.contains("usage:"), "{stderr:?}");
    assert!(
        String::from_utf8_lossy(&out.stdout).is_empty(),
        "a correction does not belong on stdout"
    );
}

#[test]
fn a_codex_notify_payload_is_read_from_its_argument_rather_than_stdin() {
    // Codex runs `notify` with the JSON as one argument and does not write to
    // the program's stdin, so a hook that waited on stdin would hang holding
    // nothing — and hooks must never wait.
    let tmp = TempDir::new().unwrap();
    let socket = tmp.path().join("s.sock");
    let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
    let heard = std::thread::spawn(move || {
        use std::io::Read;
        let (mut stream, _) = listener.accept().unwrap();
        let mut body = String::new();
        stream.read_to_string(&mut body).unwrap();
        body
    });

    let payload = r#"{"type":"agent-turn-complete","turn-id":"7","cwd":"/tmp"}"#;
    let out = Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(["hook", "--task", "3", "--socket"])
        .arg(&socket)
        .args(["--from", "codex", payload])
        .stdin(std::process::Stdio::null())
        .output()
        .expect("run marver hook");

    assert!(out.status.success(), "a hook always exits 0: {out:?}");
    let body = heard.join().unwrap();
    assert!(body.contains("agent-turn-complete"), "{body}");
    assert!(body.contains("\"task_id\":3"), "{body}");
}

#[test]
fn an_unknown_harness_is_refused_before_anything_is_started() {
    let out = run(&["--harness", "nosuchthing"]);
    let stderr = String::from_utf8_lossy(&out.stderr);

    assert!(!out.status.success());
    assert!(stderr.contains("no harness called"), "{stderr:?}");
    assert!(stderr.contains("claude, codex"), "{stderr:?}");
}