marver 0.0.6

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! How the daemon is started, found, and refused — driven through the real
//! binary rather than the library.
//!
//! These live outside `src` because they need `marver` as an executable:
//! [`marver::daemon::ensure_running`] spawns `config.marver_bin`, and in a unit
//! test that path is the test harness itself, which would recursively re-run the
//! suite. `CARGO_BIN_EXE_marver` is only defined for integration tests.

use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;

use marver::daemon::{self, Config, Startup};
use tempfile::TempDir;

/// Kills the daemon it holds when the test ends, however the test ends.
struct Daemon(u32);

impl Drop for Daemon {
    fn drop(&mut self) {
        let _ = Command::new("kill").arg(self.0.to_string()).status();
    }
}

/// A config pointed at a private data directory and the real binary.
///
/// The directory has to stay short: a unix socket path is capped around 100
/// bytes, well under what any other path allows.
fn config(dir: &TempDir) -> Config {
    let mut config = Config::new(dir.path(), dir.path());
    config.marver_bin = PathBuf::from(env!("CARGO_BIN_EXE_marver"));
    config
}

/// The process group of `pid`, via ps. Avoids a libc dependency for one call.
fn process_group(pid: u32) -> String {
    let out = Command::new("ps")
        .args(["-o", "pgid=", "-p", &pid.to_string()])
        .output()
        .expect("ps");
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

#[test]
fn a_daemon_is_started_on_demand_and_then_found_rather_than_duplicated() {
    let dir = TempDir::new().unwrap();
    let config = config(&dir);

    assert!(!daemon::is_running(&config), "nothing should be listening");

    let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
        panic!("the first call should have started one");
    };
    let _guard = Daemon(pid);

    assert!(
        daemon::is_running(&config),
        "ensure_running must not return until the socket answers"
    );
    assert_eq!(
        daemon::ensure_running(&config).expect("second call"),
        Startup::AlreadyRunning,
        "a second caller must find the first daemon, not start another"
    );
}

#[test]
fn an_auto_started_daemon_is_in_its_own_process_group() {
    // The property behind "agents outlive the interface". Terminal signals go to
    // the foreground process group, so a daemon sharing ours would take ctrl-c
    // in the TUI along with it — killing the supervisor of every running agent.
    let dir = TempDir::new().unwrap();
    let config = config(&dir);

    let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
        panic!("expected a fresh daemon");
    };
    let _guard = Daemon(pid);

    let ours = process_group(std::process::id());
    let theirs = process_group(pid);
    assert!(!theirs.is_empty(), "the daemon should still be running");
    assert_ne!(
        ours, theirs,
        "the daemon shares our process group, so ctrl-c would kill it"
    );
}

#[test]
fn a_second_daemon_refuses_instead_of_stealing_the_socket() {
    let dir = TempDir::new().unwrap();
    let config = config(&dir);

    let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
        panic!("expected a fresh daemon");
    };
    let _guard = Daemon(pid);

    let out = Command::new(env!("CARGO_BIN_EXE_marver"))
        .args(["daemon", "--data-dir"])
        .arg(dir.path())
        .arg("--scan-root")
        .arg(dir.path())
        .output()
        .expect("run marver daemon");

    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(!out.status.success(), "a second daemon must exit non-zero");
    assert!(
        stderr.contains("already running"),
        "it should say a daemon is running, not leak an errno: {stderr:?}"
    );
    // The banner used to print before the bind was attempted, so a refusal read
    // as four lines of successful startup followed by a contradiction.
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("cap"),
        "a refused start should announce no configuration: {stdout:?}"
    );
    assert!(
        daemon::is_running(&config),
        "the original daemon must still hold its socket"
    );
}

#[test]
fn status_reports_what_is_running_and_exits_accordingly() {
    let dir = TempDir::new().unwrap();
    let config = config(&dir);

    let status = |dir: &TempDir| {
        Command::new(env!("CARGO_BIN_EXE_marver"))
            .args(["status", "--data-dir"])
            .arg(dir.path())
            .output()
            .expect("run marver status")
    };

    let before = status(&dir);
    let text = String::from_utf8_lossy(&before.stdout).to_string();
    assert!(text.contains("not running"), "{text:?}");
    assert!(
        !before.status.success(),
        "a shell should be able to ask, so this exits non-zero"
    );
    assert!(
        !config.db.exists(),
        "reporting on the system must not create part of it"
    );

    let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
        panic!("expected a fresh daemon");
    };
    let _guard = Daemon(pid);

    let after = status(&dir);
    let text = String::from_utf8_lossy(&after.stdout).to_string();
    assert!(after.status.success(), "{text:?}");
    assert!(
        text.contains("running") && !text.contains("not running"),
        "{text:?}"
    );
}

#[test]
fn a_liveness_probe_leaves_no_trace_in_the_log() {
    // `is_listening` connects and says nothing. If the daemon treated that as a
    // malformed hook, every status check would leave a complaint behind and the
    // log would stop being worth reading.
    let dir = TempDir::new().unwrap();
    let config = config(&dir);

    let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
        panic!("expected a fresh daemon");
    };
    let _guard = Daemon(pid);

    for _ in 0..5 {
        assert!(daemon::is_running(&config));
    }
    // A fixed pause rather than a poll: the assertion is that nothing arrives,
    // and waiting for a deadline that must expire would cost the full timeout.
    std::thread::sleep(Duration::from_millis(300));

    let log = std::fs::read_to_string(&config.log).unwrap_or_default();
    assert!(!log.contains("ignoring hook"), "{log:?}");
}