qqq-cli 0.12.0

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

use serde_json::Value;
use std::{
    path::Path,
    process::{Command, Output, Stdio},
    thread,
    time::{Duration, Instant},
};
use tempfile::TempDir;

fn command(dir: &Path) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_qqq"));
    command
        .current_dir(dir)
        .arg("--json")
        .env("HOME", dir)
        .env_remove("QQQ_SESSION")
        .env_remove("CODEX_THREAD_ID")
        .env_remove("CODEX_SESSION_ID")
        .env_remove("CLAUDE_CODE_SESSION_ID")
        .env_remove("HERDR_ENV")
        .env_remove("HERDR_PANE_ID");
    command
}
fn run(dir: &Path, args: &[&str]) -> Output {
    command(dir).args(args).output().unwrap()
}
fn ok(dir: &Path, args: &[&str]) -> Value {
    if args.first() == Some(&"next") {
        return next_output::json(run(dir, args));
    }
    let output = run(dir, args);
    assert!(
        output.status.success(),
        "{args:?}: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).unwrap()
}
fn project() -> TempDir {
    let dir = TempDir::new().unwrap();
    ok(dir.path(), &["init"]);
    dir
}
fn ids(tasks: &Value) -> Vec<i64> {
    tasks
        .as_array()
        .unwrap()
        .iter()
        .map(|task| task["id"].as_i64().unwrap())
        .collect()
}

#[test]
fn list_combines_luau_search_status_and_parent_context() {
    let dir = project();
    let p = dir.path();
    ok(p, &["add", "Root"]);
    ok(
        p,
        &[
            "add",
            "Auth details\nsecond line",
            "--parent",
            "1",
            "--priority",
            "5",
        ],
    );
    ok(p, &["add", "Auth low"]);
    let tasks = ok(
        p,
        &[
            "list",
            "--filter",
            "like(task_name, '%details%') and priority > 0",
            "--query",
            "SECOND",
            "--status",
            "new",
        ],
    );
    assert_eq!(ids(&tasks), [1, 2]);
    assert_eq!(tasks[0]["context_only"], true);
    assert!(tasks[1].get("context_only").is_none());
    assert_eq!(
        ok(
            p,
            &[
                "list",
                "--filter",
                "created_time == created_at and updated_time == updated_at"
            ]
        )
        .as_array()
        .unwrap()
        .len(),
        3
    );
    assert!(
        ok(
            p,
            &["list", "--filter", "priority > 0", "--status", "completed"]
        )
        .as_array()
        .unwrap()
        .is_empty()
    );
    let human = Command::new(env!("CARGO_BIN_EXE_qqq"))
        .arg("--human")
        .current_dir(p)
        .env("HOME", p)
        .args(["list", "--filter", "false"])
        .output()
        .unwrap();
    assert!(human.status.success());
    assert_eq!(
        String::from_utf8(human.stdout).unwrap(),
        "No matching tasks.\n"
    );
}

#[test]
fn archive_and_completed_limits_still_bound_context() {
    let dir = project();
    let p = dir.path();
    ok(p, &["add", "Parent"]);
    ok(p, &["next", "--local", "--session", "parent"]);
    ok(p, &["edit", "--complete", "1", "--session", "parent"]);
    ok(p, &["add", "Child", "--parent", "1"]);
    ok(p, &["add", "Archived", "--priority", "9"]);
    ok(p, &["edit", "3", "--archive"]);
    let tasks = ok(p, &["list", "--filter", "id == 2", "--max-completed", "0"]);
    assert_eq!(ids(&tasks), [2]);
    assert!(tasks[0].get("context_only").is_none());
    assert_eq!(
        ids(&ok(p, &["list", "--filter", "archived"])),
        Vec::<i64>::new()
    );
    assert_eq!(
        ids(&ok(
            p,
            &["list", "--filter", "archived", "--include-archived"]
        )),
        [3]
    );
}

#[test]
fn invalid_filter_fails_before_database_open_or_claim_changes() {
    let dir = TempDir::new().unwrap();
    let p = dir.path();
    for subcommand in ["list", "next"] {
        let out = run(p, &[subcommand, "--filter", "os.execute('bad')"]);
        assert!(!out.status.success());
        assert!(String::from_utf8_lossy(&out.stderr).contains("Invalid --filter"));
        assert!(!p.join(".qqq").exists());
    }
    ok(p, &["init"]);
    ok(p, &["add", "Task"]);
    let before = ok(p, &["show", "1"]);
    let out = run(
        p,
        &[
            "next",
            "--local",
            "--session",
            "a",
            "--filter",
            "like(id, '%')",
        ],
    );
    assert!(!out.status.success());
    assert_eq!(ok(p, &["show", "1"]), before);
}

#[test]
fn runtime_filter_error_rolls_back_new_claim_and_owned_task_bypasses_predicate() {
    let dir = project();
    let p = dir.path();
    ok(p, &["add", "Task"]);
    let before = ok(p, &["show", "1"]);
    let filter = "like(description, '%', 'xx')";
    let output = run(
        p,
        &["next", "--local", "--session", "a", "--filter", filter],
    );
    assert!(!output.status.success());
    let failure: Value = serde_json::from_slice(&output.stderr).unwrap();
    assert_eq!(failure["code"], "INVALID_FILTER");
    assert_eq!(failure["details"]["reason"], "evaluation_failed");
    assert_eq!(ok(p, &["show", "1"]), before);
    assert_eq!(ok(p, &["next", "--local", "--session", "a"])["id"], 1);
    assert_eq!(
        ok(
            p,
            &["next", "--local", "--session", "a", "--filter", filter]
        )["id"],
        1
    );
}

#[test]
fn next_filters_only_new_ready_candidates_keeps_owned_task() {
    let dir = project();
    let p = dir.path();
    ok(p, &["add", "Other", "--priority", "100"]);
    ok(p, &["add", "Auth first", "--priority", "5"]);
    ok(p, &["add", "Auth second", "--priority", "5"]);
    ok(
        p,
        &["add", "Auth blocked", "--parent", "1", "--priority", "90"],
    );
    ok(p, &["add", "Auth archived", "--priority", "99"]);
    ok(p, &["edit", "5", "--archive"]);
    let filter = "like(description, 'Auth%')";
    assert_eq!(
        ok(
            p,
            &["next", "--local", "--session", "a", "--filter", filter]
        )["id"],
        2
    );
    assert_eq!(
        ok(
            p,
            &["next", "--local", "--session", "a", "--filter", "false"]
        )["id"],
        2
    );
    assert_eq!(
        ok(
            p,
            &["next", "--local", "--session", "b", "--filter", filter]
        )["id"],
        3
    );
    assert!(
        ok(
            p,
            &["next", "--local", "--session", "c", "--filter", filter]
        )
        .is_null()
    );
    assert_eq!(
        ok(
            p,
            &[
                "next",
                "--local",
                "--session",
                "parent",
                "--filter",
                "id == 1"
            ]
        )["id"],
        1
    );
    ok(p, &["edit", "--complete", "1", "--session", "parent"]);
    assert_eq!(
        ok(
            p,
            &["next", "--local", "--session", "c", "--filter", filter]
        )["id"],
        4
    );
}

#[test]
fn concurrent_filtered_claims_are_distinct_and_leave_unmatched_tasks_new() {
    let dir = project();
    let p = dir.path();
    for description in ["Other", "Match A", "Match B"] {
        ok(p, &["add", description]);
    }
    let children: Vec<_> = ["a", "b", "c"]
        .into_iter()
        .map(|owner| {
            command(p)
                .args([
                    "next",
                    "--local",
                    "--session",
                    owner,
                    "--filter",
                    "like(description, 'Match%')",
                ])
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()
                .unwrap()
        })
        .collect();
    let mut claimed = Vec::new();
    let mut empty = 0;
    for child in children {
        let output = child.wait_with_output().unwrap();
        let task = next_output::json(output);
        if task.is_null() {
            empty += 1;
        } else {
            claimed.push(task["id"].as_i64().unwrap());
        }
    }
    claimed.sort();
    assert_eq!(claimed, [2, 3]);
    assert_eq!(empty, 1);
    assert_eq!(ok(p, &["show", "1"])["task"]["status"], "new");
}

#[test]
fn wait_ignores_unmatched_commits_and_claims_later_match() {
    let dir = project();
    let p = dir.path();
    let mut child = command(p)
        .args([
            "next",
            "--local",
            "--wait",
            "--session",
            "waiter",
            "--filter",
            "priority > 0",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    ok(p, &["add", "Unmatched"]);
    thread::sleep(Duration::from_millis(400));
    assert!(child.try_wait().unwrap().is_none());
    ok(p, &["add", "Matched", "--priority", "3"]);
    let deadline = Instant::now() + Duration::from_secs(5);
    while child.try_wait().unwrap().is_none() {
        if Instant::now() >= deadline {
            child.kill().unwrap();
            child.wait().unwrap();
            panic!("filtered waiter did not claim matching task");
        }
        thread::sleep(Duration::from_millis(25));
    }
    let output = child.wait_with_output().unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        serde_json::from_slice::<Value>(&output.stdout).unwrap()["id"],
        2
    );
    assert_eq!(ok(p, &["show", "1"])["task"]["status"], "new");
}