qqq-cli 0.9.0

Local-first task queue for coding agent sessions
use serde::Deserialize;
use serde_json::Value;
use std::{path::Path, process::Command};
use tempfile::TempDir;

fn cli(dir: &Path, args: &[&str]) -> String {
    let out = Command::new(env!("CARGO_BIN_EXE_qqq"))
        .args(args)
        .current_dir(dir)
        .env("HOME", dir)
        .env_remove("QQQ_SESSION")
        .env_remove("CODEX_THREAD_ID")
        .env_remove("CODEX_SESSION_ID")
        .env_remove("HERDR_ENV")
        .env_remove("HERDR_PANE_ID")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8(out.stdout).unwrap()
}
fn project() -> TempDir {
    let dir = TempDir::new().unwrap();
    cli(dir.path(), &["init"]);
    dir
}

#[test]
fn show_groups_description_and_aligns_complete_metadata() {
    let dir = project();
    let p = dir.path();
    cli(p, &["add", "Parent"]);
    let body = "Build ็•Œ\n\nDetails\t\u{1b}[31m";
    cli(p, &["add", body, "--parent", "1"]);
    let shown = cli(p, &["show", "-1"]);
    assert!(
        shown.starts_with(
            "#2 ยท New\n\nDescription:\n  Build ็•Œ\n  \n  Details\\t\\u{1b}[31m\n\nMessages: None\n\nDetails:\n"
        ),
        "{shown}"
    );
    let mut columns = Vec::new();
    for label in [
        "Parent:",
        "Created:",
        "Updated:",
        "Harness:",
        "Orchestrator:",
    ] {
        let row = shown
            .lines()
            .find(|row| row.trim_start().starts_with(label))
            .unwrap();
        columns.push(row.len() - row[2 + label.len()..].trim_start().len());
    }
    assert!(
        columns.iter().all(|&column| column == 25),
        "{columns:?}: {shown}"
    );
    assert!(shown.contains("\n\nAssignment:\n"));
    for empty in [
        "Messages: None",
        "Images: None",
        "History: None",
        "Herdr: Not linked",
    ] {
        assert!(shown.contains(empty), "{empty}: {shown}");
    }
    assert!(!shown.contains('\u{1b}'));
    let json: Value = serde_json::from_str(&cli(p, &["show", "-1", "--json"])).unwrap();
    assert_eq!(json["task"]["description"], body);
    assert_eq!(json["task"]["parent_id"], 1);
    assert_eq!(json["task"]["harness_session"], Value::Null);
}

#[test]
fn show_combines_assignment_sessions_and_names_without_changing_json() {
    let dir = project();
    let p = dir.path();
    cli(p, &["add", "Task"]);
    cli(
        p,
        &[
            "next",
            "--local",
            "--session",
            "owner",
            "--harness-name",
            "codex",
            "--harness-session",
            "session-1",
            "--orchestrator-name",
            "herdr",
            "--orchestrator-session",
            "default",
        ],
    );
    let shown = cli(p, &["show", "1"]);
    let rows: Vec<_> = shown
        .split("\n\nAssignment:\n")
        .nth(1)
        .unwrap()
        .split("\n\n")
        .next()
        .unwrap()
        .lines()
        .map(|line| (line[..25].trim(), &line[25..]))
        .collect();
    assert_eq!(
        rows,
        [
            ("Harness:", "session-1 (codex)"),
            ("Orchestrator:", "default (herdr)")
        ],
        "{shown}"
    );
    let json: Value = serde_json::from_str(&cli(p, &["show", "1", "--json"])).unwrap();
    assert_eq!(json["task"]["harness_name"], "codex");
    assert_eq!(json["task"]["harness_session"], "session-1");
    assert_eq!(json["task"]["orchestrator_name"], "herdr");
    assert_eq!(json["task"]["orchestrator_session"], "default");
}

#[test]
fn show_separates_message_headers_from_multiline_bodies() {
    let dir = project();
    let p = dir.path();
    cli(p, &["add", "Task"]);
    cli(
        p,
        &["message", "1", "First\n\nDetails", "--session", "author"],
    );
    cli(p, &["message", "1", "Second"]);
    let shown = cli(p, &["show", "1"]);
    assert!(
        shown.starts_with("#1 ยท New\n\nDescription:\n  Task\n\nMessages: (2)\n  #1 ยท author ยท "),
        "{shown}"
    );
    assert!(
        shown.contains("\n    First\n    \n    Details\n\n  #2 ยท - ยท "),
        "{shown}"
    );
    assert!(shown.contains("\n    Second\n\nDetails:"), "{shown}");
}

#[derive(Deserialize)]
struct TerminalOutput {
    screen: String,
    plain: String,
}
fn terminal(dir: &Path, mode: &str) -> TerminalOutput {
    let output = Command::new("python3")
        .args([
            concat!(env!("CARGO_MANIFEST_DIR"), "/tests/show_pty.py"),
            env!("CARGO_BIN_EXE_qqq"),
            dir.to_str().unwrap(),
            mode,
        ])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).unwrap()
}

#[test]
fn terminal_show_colors_semantics_and_plain_modes_preserve_full_details() {
    let dir = project();
    let p = dir.path();
    std::fs::write(p.join("image.png"), b"\x89PNG\r\n\x1a\nfixture").unwrap();
    cli(
        p,
        &[
            "add",
            &format!("Long ็•Œ body\n{}", "Details ".repeat(50)),
            "--image",
            "image.png",
        ],
    );
    cli(
        p,
        &["message", "1", "Note\n\t\u{1b}[31m", "--session", "author"],
    );
    for (status, color) in [
        ("New", None),
        ("In progress", Some("36")),
        ("Error", Some("31")),
        ("Completed", Some("90")),
    ] {
        match status {
            "In progress" => {
                cli(p, &["next", "--local", "--session", "worker"]);
            }
            "Error" => {
                cli(
                    p,
                    &[
                        "edit",
                        "1",
                        "--set-status",
                        "error",
                        "--reason",
                        "Failed",
                        "--session",
                        "worker",
                    ],
                );
            }
            "Completed" => {
                cli(p, &["edit", "1", "--set-status", "new"]);
                cli(p, &["next", "--local", "--session", "worker"]);
                cli(
                    p,
                    &["edit", "1", "--set-status", "new", "--session", "worker"],
                );
                cli(p, &["next", "--local", "--session", "worker"]);
                cli(p, &["edit", "--complete", "1", "--session", "worker"]);
            }
            _ => {}
        }
        let plain = cli(p, &["show", "1"]);
        let colored = terminal(p, "color");
        assert_eq!(colored.plain, plain);
        assert!(colored.screen.contains("\u{1b}[1;36mDescription:\u{1b}[0m"));
        assert!(colored.screen.contains("\u{1b}[2mCreated:"));
        assert!(
            colored
                .screen
                .contains("\u{1b}[1mImage #1\u{1b}[0m: image.png")
        );
        assert!(
            colored
                .screen
                .contains("\u{1b}[2m(image/png, 15 bytes)\u{1b}[0m")
        );
        assert!(colored.screen.contains("\n    Note\n    \\t\\u{1b}[31m"));
        if let Some(color) = color {
            assert!(
                colored
                    .screen
                    .contains(&format!("\u{1b}[{color}m{status}\u{1b}[0m"))
            );
        } else {
            assert!(colored.screen.starts_with("\u{1b}[1m#1\u{1b}[0m ยท New\n"));
        }
        if status == "Completed" {
            for (action, color) in [
                ("claim", "36"),
                ("release", "33"),
                ("error", "31"),
                ("complete", "90"),
            ] {
                assert!(
                    colored.screen.contains(&format!("\u{1b}[{color}m{action}")),
                    "{action}: {}",
                    colored.screen
                );
            }
            for mode in ["no_color", "dumb"] {
                let output = terminal(p, mode);
                assert!(!output.screen.contains('\u{1b}'));
                assert_eq!(output.screen, plain);
            }
            let output = terminal(p, "json");
            assert!(!output.screen.contains('\u{1b}'));
            let actual: Value = serde_json::from_str(&output.screen).unwrap();
            let expected: Value = serde_json::from_str(&cli(p, &["show", "1", "--json"])).unwrap();
            assert_eq!(actual, expected);
        }
    }
}