qqq-cli 0.6.0

Local-first task queue for coding agent sessions
use serde_json::Value;
use std::{
    fs,
    process::{Command, Output},
};
use tempfile::TempDir;

struct Project {
    dir: TempDir,
}
impl Project {
    fn new() -> Self {
        let p = Self {
            dir: TempDir::new().unwrap(),
        };
        p.ok(&["init"]);
        p
    }
    fn config(&self, content: &str) {
        let dir = self.dir.path().join(".config/qqq");
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("config.toml"), content).unwrap();
    }
    fn run(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_qqq"))
            .current_dir(self.dir.path())
            .env("HOME", self.dir.path())
            .env_remove("QQQ_SESSION")
            .env_remove("CODEX_THREAD_ID")
            .env_remove("CODEX_SESSION_ID")
            .env_remove("HERDR_PANE_ID")
            .env_remove("HERDR_ENV")
            .args(args)
            .output()
            .unwrap()
    }
    fn ok(&self, args: &[&str]) -> String {
        let out = self.run(args);
        assert!(
            out.status.success(),
            "{}",
            String::from_utf8_lossy(&out.stderr)
        );
        String::from_utf8(out.stdout).unwrap()
    }
    fn ids(&self, args: &[&str]) -> Vec<i64> {
        self.ok(args)
            .lines()
            .skip(1)
            .map(|row| row.split_whitespace().next().unwrap().parse().unwrap())
            .collect()
    }
    fn tasks(&self) -> Value {
        serde_json::from_str(&self.ok(&["list", "--json"])).unwrap()
    }
    fn fixture(&self) {
        for title in ["Old created", "Second", "Third"] {
            self.ok(&["add", title]);
        }
        for session in ["a", "b", "c"] {
            self.ok(&["next", "--session", session]);
        }
        for (id, session) in [("2", "b"), ("3", "c"), ("1", "a")] {
            self.ok(&["complete", id, "--session", session]);
        }
        self.ok(&["add", "New task"]);
        self.ok(&["next", "--session", "active"]);
        self.ok(&["add", "Unclaimed"]);
        self.ok(&["add", "Hidden-parent child", "--parent", "2"]);
        self.ok(&["add", "Visible-parent child", "--parent", "3"]);
    }
}

#[test]
fn limit_uses_completion_order_keeps_active_tasks_and_preserves_json() {
    let p = Project::new();
    p.fixture();
    let before = p.tasks();
    p.config("[display]\nmax-completed = 2\n");
    assert_eq!(p.ids(&["list"]), vec![1, 3, 7, 4, 5, 6]);
    assert_eq!(p.tasks(), before);
    // Editing older completed task must not make it a newer completion.
    p.ok(&["edit", "2", "--description", "Edited older completion"]);
    assert_eq!(p.ids(&["list"]), vec![1, 3, 7, 4, 5, 6]);
    assert_eq!(p.ids(&["list", "--all"]), vec![1, 2, 6, 3, 7, 4, 5]);
    assert!(p.ok(&["show", "2"]).contains("Edited older completion"));
}

#[test]
fn zero_missing_and_large_limits_have_clear_boundaries() {
    let p = Project::new();
    p.fixture();
    assert_eq!(p.ids(&["list"]), vec![1, 2, 6, 3, 7, 4, 5]);
    p.config("display.max-completed = 0\n");
    let list = p.ok(&["list"]);
    assert_eq!(p.ids(&["list"]), vec![4, 5, 6, 7]);
    assert!(!list.contains("Completed"));
    assert!(!list.contains("└──"));
    p.config("[display]\nmax-completed = 9223372036854775807\n");
    assert_eq!(p.ids(&["list"]), vec![1, 2, 6, 3, 7, 4, 5]);
    p.config("[display]\n");
    assert_eq!(p.ids(&["list"]), vec![1, 2, 6, 3, 7, 4, 5]);
}

#[test]
fn limited_empty_lists_show_no_tasks_to_display() {
    let p = Project::new();
    p.config("[display]\nmax-completed = 0\n");
    assert_eq!(p.ok(&["list"]), "No tasks to display.\n");
    p.ok(&["add", "Task"]);
    p.ok(&["next", "--session", "a"]);
    p.ok(&["complete", "1", "--session", "a"]);
    assert_eq!(p.ok(&["list"]), "No tasks to display.\n");
    assert!(p.ok(&["list", "--all"]).contains("Task"));
}

#[test]
fn aliases_share_display_setting_and_json_stays_full() {
    let p = Project::new();
    p.fixture();
    p.config("[alias]\nls = 'list'\nall = 'list --all'\n[display]\nmax-completed = 1\n");
    assert_eq!(p.ids(&["ls"]), vec![1, 4, 5, 6, 7]);
    assert_eq!(p.ids(&["all"]), vec![1, 2, 6, 3, 7, 4, 5]);
    for command in ["list", "ls"] {
        for flag in ["-a", "--all"] {
            assert_eq!(p.ids(&[command, flag]), vec![1, 2, 6, 3, 7, 4, 5]);
        }
    }
    let tasks: Value = serde_json::from_str(&p.ok(&["ls", "--json"])).unwrap();
    assert_eq!(tasks.as_array().unwrap().len(), 7);
}

#[test]
fn invalid_config_fails_human_list_but_full_views_remain_usable() {
    let p = Project::new();
    p.fixture();
    for content in [
        "[display",
        "[display]\nmax-completed = -1",
        "[display]\nmax-completed = 'two'",
        "[display]\nmax-completed = 1.5",
        "[display]\nmax-completed = true",
    ] {
        p.config(content);
        let out = p.run(&["list"]);
        assert_eq!(out.status.code(), Some(1));
        assert!(out.stdout.is_empty());
        assert!(String::from_utf8_lossy(&out.stderr).contains("config.toml"));
        assert_eq!(p.ids(&["list", "--all"]), vec![1, 2, 6, 3, 7, 4, 5]);
        assert_eq!(p.tasks().as_array().unwrap().len(), 7);
        assert!(p.run(&["--help"]).status.success());
    }
}

#[test]
fn legacy_completed_tasks_without_events_use_newest_id_fallback() {
    let p = Project::new();
    let conn = rusqlite::Connection::open(p.dir.path().join(".qqq/qqq.db")).unwrap();
    conn.execute_batch("INSERT INTO tasks(description,status) VALUES ('First','completed'),('Second','completed'),('Third','completed');").unwrap();
    p.config("[display]\nmax-completed = 1\n");
    assert_eq!(p.ids(&["list"]), vec![3]);
}

#[test]
fn explicit_limit_overrides_config_in_both_output_formats() {
    let p = Project::new();
    p.fixture();
    p.config("[display]\nmax-completed = 0\n");
    assert_eq!(
        p.ids(&["list", "--max-completed", "2"]),
        vec![1, 3, 7, 4, 5, 6]
    );
    let json: Value =
        serde_json::from_str(&p.ok(&["list", "--max-completed", "2", "--json"])).unwrap();
    let ids: Vec<_> = json
        .as_array()
        .unwrap()
        .iter()
        .map(|task| task["id"].as_i64().unwrap())
        .collect();
    assert_eq!(ids, vec![1, 3, 4, 5, 6, 7]);
    assert_eq!(
        p.run(&["list", "--all", "--max-completed", "1"])
            .status
            .code(),
        Some(2)
    );
    p.config("[display");
    assert_eq!(
        p.ids(&["list", "--max-completed", "1"]),
        vec![1, 4, 5, 6, 7]
    );
}

#[test]
fn display_section_is_independent_of_alias_and_herdr_value_types() {
    let p = Project::new();
    p.fixture();
    p.config("[alias]\nx = 42\n[herdr]\nnext-to-new-agent = 'bad'\n[display]\nmax-completed = 0\n");
    assert_eq!(p.ids(&["list"]), vec![4, 5, 6, 7]);
}