marver 0.0.10

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();
    }
}

#[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"
    );
}