qqq-cli 0.8.0

Local-first task queue for coding agent sessions
#[path = "common/next_output.rs"]
mod next_output;

use serde_json::Value;
use std::{
    fs,
    io::{BufRead, BufReader},
    path::Path,
    process::{Command, Output, Stdio},
};
use tempfile::TempDir;

fn command(root: &Path) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_qqq"));
    command.current_dir(root).env("HOME", root);
    for variable in [
        "QQQ_SESSION",
        "CODEX_THREAD_ID",
        "CODEX_SESSION_ID",
        "HERDR_ENV",
        "HERDR_PANE_ID",
        "HERDR_SOCKET_PATH",
        "EDITOR",
    ] {
        command.env_remove(variable);
    }
    command
}

fn agent(root: &Path) -> Command {
    let mut command = command(root);
    command.env("CODEX_THREAD_ID", "agent-thread");
    command
}

fn success(command: &mut Command) -> Output {
    let output = command.output().unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output.stderr.is_empty());
    output
}

fn json(command: &mut Command) -> Value {
    let output = success(command);
    serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
        panic!(
            "Expected JSON: {error}: {}",
            String::from_utf8_lossy(&output.stdout)
        )
    })
}

fn project() -> TempDir {
    let root = TempDir::new().unwrap();
    success(command(root.path()).arg("init"));
    root
}

fn config(root: &Path, text: &str) {
    let directory = root.join(".config/qqq");
    fs::create_dir_all(&directory).unwrap();
    fs::write(directory.join("config.toml"), text).unwrap();
}

fn json_error(command: &mut Command, status: i32, code: &str) {
    let output = command.output().unwrap();
    assert_eq!(output.status.code(), Some(status));
    assert!(output.stdout.is_empty());
    let error: Value = serde_json::from_slice(&output.stderr).unwrap_or_else(|error| {
        panic!(
            "Expected JSON error: {error}: {}",
            String::from_utf8_lossy(&output.stderr)
        )
    });
    assert_eq!(error["code"], code);
    assert!(error["details"].is_object());
}

#[test]
fn native_agent_defaults_to_json_through_task_lifecycle() {
    let root = TempDir::new().unwrap();
    let p = root.path();
    assert!(json(agent(p).arg("init"))["database"].is_string());
    assert_eq!(
        json(agent(p).args(["add", "First\nSecond"]))["description"],
        "First\nSecond"
    );
    assert_eq!(json(agent(p).arg("list"))[0]["id"], 1);
    assert_eq!(json(agent(p).args(["show", "1"]))["task"]["id"], 1);
    assert_eq!(
        json(agent(p).args(["next", "--local"]))["status"],
        "in_progress"
    );
    assert_eq!(
        json(agent(p).args(["complete", "1"]))["status"],
        "completed"
    );
    assert!(next_output::json(agent(p).args(["next", "--local"]).output().unwrap()).is_null());
}

#[test]
fn native_session_marker_also_selects_json_without_thread_marker() {
    let root = project();
    let mut cmd = command(root.path());
    cmd.env("CODEX_SESSION_ID", "agent-session");
    assert_eq!(json(cmd.arg("list")), serde_json::json!([]));
}

#[test]
fn native_agent_parser_runtime_and_alias_failures_are_json() {
    let root = project();
    let p = root.path();
    json_error(agent(p).args(["show", "42"]), 1, "TASK_NOT_FOUND");
    json_error(agent(p).args(["show", "wrong"]), 2, "INVALID_ARGUMENT");
    json_error(agent(p).arg("unknown-command"), 2, "INVALID_ARGUMENT");
    config(p, "[alias]\nbad = '!exit 1'\n");
    json_error(agent(p).arg("bad"), 1, "INVALID_ARGUMENT");
}

#[test]
fn human_override_works_before_after_commands_and_aliases() {
    let root = project();
    let p = root.path();
    success(command(p).args(["add", "Task"]));
    config(p, "[alias]\ns = 'show'\nh = '--human show'\n");
    for args in [
        vec!["--human", "show", "1"],
        vec!["show", "1", "--human"],
        vec!["--human", "s", "1"],
        vec!["s", "1", "--human"],
        vec!["h", "1"],
    ] {
        let output = success(agent(p).args(args));
        let text = String::from_utf8(output.stdout).unwrap();
        assert!(text.contains("Task"));
        assert!(serde_json::from_str::<Value>(&text).is_err(), "{text}");
    }
    let output = agent(p).args(["--human", "show", "42"]).output().unwrap();
    assert_eq!(output.status.code(), Some(1));
    assert!(String::from_utf8_lossy(&output.stderr).starts_with("error:"));
    let output = agent(p)
        .args(["show", "wrong", "--human"])
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(2));
    assert!(String::from_utf8_lossy(&output.stderr).starts_with("error:"));
}

#[test]
fn explicit_json_keeps_working_and_conflicting_modes_have_json_error() {
    let root = project();
    let p = root.path();
    assert_eq!(
        json(command(p).args(["list", "--json"])),
        serde_json::json!([])
    );
    for args in [
        vec!["--human", "--json", "list"],
        vec!["list", "--json", "--human"],
    ] {
        json_error(command(p).args(args), 2, "INVALID_ARGUMENT");
    }
}

#[test]
fn literal_output_flags_do_not_override_agent_default() {
    let root = project();
    let p = root.path();
    assert_eq!(
        json(agent(p).args(["add", "--", "--human"]))["description"],
        "--human"
    );
    assert_eq!(
        json(agent(p).args(["add", "--description=--human"]))["description"],
        "--human"
    );
    assert_eq!(
        json(agent(p).args(["message", "1", "--", "--human"]))["body"],
        "--human"
    );
    json_error(
        agent(p).args(["add", "Task", "--priority", "--human"]),
        2,
        "INVALID_ARGUMENT",
    );
    let output = success(command(p).args(["add", "--", "--json"]));
    assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
}

#[test]
fn aliases_consuming_output_flags_as_values_keep_agent_default() {
    let root = project();
    let p = root.path();
    config(p, "[alias]\nl = 'list'\nq = 'list --query'\nn = 'add'\n");
    for args in [
        vec!["l", "--query", "--human"],
        vec!["q", "--human"],
        vec!["l", "--query", "--json"],
    ] {
        assert_eq!(json(agent(p).args(args)), serde_json::json!([]));
    }
    json_error(
        agent(p).args(["n", "Task", "--priority", "--human"]),
        2,
        "INVALID_ARGUMENT",
    );
}

#[test]
fn no_agent_context_keeps_human_output_even_when_piped_or_session_supplied() {
    let root = project();
    let p = root.path();
    for marker in ["CODEX_THREAD_ID", "CODEX_SESSION_ID"] {
        let output = success(command(p).env(marker, "  ").arg("list"));
        assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
    }
    let output = success(command(p).env("QQQ_SESSION", "manual-owner").arg("list"));
    assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
    let output = success(command(p).args(["list", "--harness-name", "codex"]));
    assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
}

#[test]
fn agent_lists_keep_all_completed_tasks_despite_human_display_limit() {
    let root = project();
    let p = root.path();
    config(p, "[display]\nmax-completed = 1\n");
    for id in ["1", "2"] {
        json(agent(p).args(["add", "Done"]));
        json(agent(p).args(["next", "--local"]));
        json(agent(p).args(["complete", id]));
    }
    assert_eq!(json(agent(p).arg("list")).as_array().unwrap().len(), 2);
}

#[test]
fn agent_help_and_version_remain_text() {
    let root = TempDir::new().unwrap();
    let help = success(agent(root.path()).arg("--help"));
    assert!(String::from_utf8_lossy(&help.stdout).contains("Usage: qqq"));
    let version = success(agent(root.path()).arg("--version"));
    assert_eq!(
        String::from_utf8(version.stdout).unwrap(),
        format!("qqq {}\n", env!("CARGO_PKG_VERSION"))
    );
}

#[test]
fn agent_watch_defaults_to_json_snapshots() {
    let root = project();
    let mut child = agent(root.path())
        .args(["list", "--watch"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    let mut line = String::new();
    BufReader::new(child.stdout.take().unwrap())
        .read_line(&mut line)
        .unwrap();
    child.kill().unwrap();
    child.wait().unwrap();
    assert_eq!(
        serde_json::from_str::<Value>(&line).unwrap(),
        serde_json::json!([])
    );
}

#[cfg(unix)]
mod herdr {
    use super::*;
    use std::os::unix::fs::PermissionsExt;

    fn caller(root: &Path, pane: Value) -> Command {
        let script = root.join("herdr");
        fs::write(&script, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$QQQ_TEST_CALLS\"\n[ \"$1 $2\" = 'pane current' ] || exit 1\n/bin/cat \"$QQQ_TEST_RESPONSE\"\n").unwrap();
        fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
        fs::write(
            root.join("pane.json"),
            serde_json::to_vec(&serde_json::json!({"result":{"pane":pane}})).unwrap(),
        )
        .unwrap();
        let mut cmd = command(root);
        cmd.env("HERDR_ENV", "1")
            .env("HERDR_PANE_ID", "w1:p1")
            .env("PATH", root)
            .env("QQQ_TEST_RESPONSE", root.join("pane.json"))
            .env("QQQ_TEST_CALLS", root.join("calls"));
        cmd
    }

    #[test]
    fn exact_agent_pane_selects_json_without_native_marker_or_project_db() {
        for metadata in [
            serde_json::json!({"agent":"claude"}),
            serde_json::json!({"agent_session":{"agent":"codex","kind":"id","value":"session"}}),
        ] {
            let root = TempDir::new().unwrap();
            let mut pane =
                serde_json::json!({"pane_id":"w1:p1","workspace_id":"w1","tab_id":"w1:t1"});
            pane.as_object_mut()
                .unwrap()
                .extend(metadata.as_object().unwrap().clone());
            assert!(json(caller(root.path(), pane).arg("init"))["database"].is_string());
            assert_eq!(
                fs::read_to_string(root.path().join("calls")).unwrap(),
                "pane current --pane w1:p1\n"
            );
        }
    }

    #[test]
    fn ordinary_panes_and_unavailable_herdr_keep_human_output() {
        let root = project();
        let pane = serde_json::json!({"pane_id":"w1:p1","workspace_id":"w1","tab_id":"w1:t1","agent":"  "});
        let output = success(caller(root.path(), pane).arg("list"));
        assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
        fs::remove_file(root.path().join("herdr")).unwrap();
        let output = success(
            command(root.path())
                .env("HERDR_ENV", "1")
                .env("HERDR_PANE_ID", "w1:p1")
                .env("PATH", root.path())
                .arg("list"),
        );
        assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
    }

    #[test]
    fn exact_agent_pane_parser_errors_are_json() {
        let root = project();
        let pane = serde_json::json!({"pane_id":"w1:p1","workspace_id":"w1","tab_id":"w1:t1","agent":"codex"});
        json_error(
            caller(root.path(), pane).args(["show", "wrong"]),
            2,
            "INVALID_ARGUMENT",
        );
    }

    #[test]
    fn explicit_output_modes_skip_optional_herdr_lookup() {
        let root = project();
        let p = root.path();
        let pane = serde_json::json!({"pane_id":"w1:p1","workspace_id":"w1","tab_id":"w1:t1","agent":"codex"});
        json(caller(p, pane.clone()).args(["list", "--json"]));
        let output = success(caller(p, pane).args(["--human", "list"]));
        assert!(serde_json::from_slice::<Value>(&output.stdout).is_err());
        assert!(!p.join("calls").exists());
    }

    #[test]
    fn aliases_consuming_literal_mode_flags_still_detect_exact_herdr_agent() {
        let root = project();
        let p = root.path();
        config(p, "[alias]\nq = 'list --query'\n");
        let pane = serde_json::json!({"pane_id":"w1:p1","workspace_id":"w1","tab_id":"w1:t1","agent":"claude"});
        assert_eq!(
            json(caller(p, pane).args(["q", "--human"])),
            serde_json::json!([])
        );
        assert_eq!(
            fs::read_to_string(p.join("calls")).unwrap(),
            "pane current --pane w1:p1\n"
        );
    }
}