qqq-cli 0.12.0

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

struct UserConfig {
    home: TempDir,
}
impl UserConfig {
    fn new() -> Self {
        Self {
            home: TempDir::new().unwrap(),
        }
    }
    fn path(&self) -> PathBuf {
        self.home.path().join(".config/qqq/config.toml")
    }
    fn write(&self, text: &str) {
        fs::create_dir_all(self.path().parent().unwrap()).unwrap();
        fs::write(self.path(), text).unwrap();
    }
    fn command(&self) -> Command {
        let mut c = Command::new(env!("CARGO_BIN_EXE_qqq"));
        c.current_dir(self.home.path())
            .env("HOME", self.home.path())
            .env_remove("QQQ_SESSION")
            .env_remove("CODEX_THREAD_ID")
            .env_remove("CODEX_SESSION_ID")
            .env_remove("CLAUDE_CODE_SESSION_ID")
            .env_remove("HERDR_PANE_ID")
            .env_remove("HERDR_ENV");
        c
    }
    fn run(&self, args: &[&str]) -> Output {
        self.command().args(args).output().unwrap()
    }
    fn ok(&self, args: &[&str]) -> Value {
        let out = self.command().arg("--json").args(args).output().unwrap();
        assert!(
            out.status.success(),
            "{args:?}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        serde_json::from_slice(&out.stdout).unwrap()
    }
}

#[test]
fn herdr_initial_prompt_preserves_literal_strings_and_unsets() {
    let p = UserConfig::new();
    for prompt in [
        "true",
        "123",
        "['task']",
        "\"quoted\"",
        "/goal Work\nNext line 界",
        "",
    ] {
        assert_eq!(
            p.ok(&["config", "herdr.initial-prompt", prompt]),
            json!({"key":"herdr.initial-prompt","value":prompt})
        );
        assert_eq!(p.ok(&["config", "--get", "herdr.initial-prompt"]), prompt);
        assert_eq!(p.ok(&["config", "--list"])["herdr.initial-prompt"], prompt);
    }
    p.ok(&["config", "--unset", "herdr.initial-prompt"]);
    assert!(
        !p.run(&["config", "--get", "herdr.initial-prompt"])
            .status
            .success()
    );
    assert!(!p.home.path().join(".qqq/qqq.db").exists());
}

#[test]
fn invalid_stored_herdr_prompt_rejects_edits_until_repaired() {
    let p = UserConfig::new();
    p.write("[herdr]\ninitial-prompt = 123\n");
    let before = fs::read(p.path()).unwrap();
    assert!(!p.run(&["config", "alias.ls", "list"]).status.success());
    assert_eq!(fs::read(p.path()).unwrap(), before);
    assert_eq!(p.ok(&["config", "--get", "herdr.initial-prompt"]), 123);
    p.ok(&["config", "herdr.initial-prompt", "true"]);
    assert_eq!(p.ok(&["config", "--get", "herdr.initial-prompt"]), "true");
}

#[test]
fn config_list_get_set_unset_work_without_project_database() {
    let p = UserConfig::new();
    assert_eq!(p.ok(&["config", "--list"]), json!({}));
    assert!(!p.path().exists());
    assert_eq!(
        p.ok(&["config", "alias.ls", "list --watch"]),
        json!({"key":"alias.ls","value":"list --watch"})
    );
    assert_eq!(p.ok(&["config", "--get", "alias.ls"]), "list --watch");
    assert_eq!(p.ok(&["config", "alias.ls"]), "list --watch");
    assert_eq!(p.ok(&["config", "other.enabled", "true"])["value"], true);
    assert_eq!(
        p.ok(&["config", "--list"]),
        json!({"alias.ls":"list --watch","other.enabled":true})
    );
    assert_eq!(
        p.ok(&["config", "--unset", "alias.ls"]),
        json!({"key":"alias.ls","value":null})
    );
    assert!(!p.run(&["config", "--get", "alias.ls"]).status.success());
    assert!(!p.home.path().join(".qqq/qqq.db").exists());
}

#[test]
fn config_preserves_comments_unknown_values_and_alias_strings() {
    let p = UserConfig::new();
    p.write("# Keep heading\n[alias] # Keep table comment\nls = 'list' # Keep inline comment\n[other]\nnumber = 7\nnames = ['a', 'b']\n# Keep tail\n");
    p.ok(&["config", "alias.ls", "show"]);
    let content = fs::read_to_string(p.path()).unwrap();
    for text in [
        "# Keep heading",
        "# Keep table comment",
        "# Keep inline comment",
        "number = 7",
        "names = ['a', 'b']",
        "# Keep tail",
    ] {
        assert!(content.contains(text), "{text}: {content}");
    }
    assert_eq!(p.ok(&["config", "--get", "other.number"]), 7);
    p.ok(&["config", "alias.number", "123"]);
    assert_eq!(p.ok(&["config", "alias.number"]), "123");
    p.ok(&["config", "alias.flag", "true"]);
    assert_eq!(p.ok(&["config", "alias.flag"]), "true");
    p.ok(&["config", "other.flag", "false"]);
    assert_eq!(p.ok(&["config", "other.flag"]), false);
    p.ok(&["config", "other.names", "[1, 2]"]);
    assert_eq!(p.ok(&["config", "other.names"]), json!([1, 2]));
    p.ok(&["config", "alias.\"with.dot\"", "list"]);
    assert_eq!(p.ok(&["config", "alias.\"with.dot\""]), "list");
    assert_eq!(p.ok(&["config", "--list"])["alias.\"with.dot\""], "list");
}

#[test]
fn invalid_edits_leave_config_unchanged_and_reads_do_not_create_files() {
    let p = UserConfig::new();
    for args in [
        ["config", "--get", "missing"],
        ["config", "--unset", "missing"],
    ] {
        assert!(!p.run(&args).status.success());
        assert!(!p.path().exists());
    }
    p.write("[tui]\nafter_save_new = 'open_saved'\n[other]\nleaf = 'value'\n");
    let before = fs::read(p.path()).unwrap();
    for args in [
        vec!["config", "tui.after_save_new", "invalid"],
        vec!["config", "other.leaf.child", "invalid"],
        vec!["config", "alias..broken", "list"],
    ] {
        let out = p.run(&args);
        assert_eq!(
            out.status.code(),
            Some(1),
            "{args:?}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        assert!(out.stdout.is_empty());
        assert_eq!(fs::read(p.path()).unwrap(), before);
    }
    p.write("not valid toml[");
    let before = fs::read(p.path()).unwrap();
    assert!(!p.run(&["config", "alias.ls", "list"]).status.success());
    assert_eq!(fs::read(p.path()).unwrap(), before);
}

#[test]
fn human_output_and_argument_conflicts_are_clear() {
    let p = UserConfig::new();
    assert_eq!(
        String::from_utf8(p.run(&["config", "--list"]).stdout).unwrap(),
        "No config values set.\n"
    );
    let out = p.run(&["config", "alias.ls", "list"]);
    assert!(out.status.success());
    assert_eq!(String::from_utf8(out.stdout).unwrap(), "Set alias.ls.\n");
    assert_eq!(
        String::from_utf8(p.run(&["config", "alias.ls"]).stdout).unwrap(),
        "list\n"
    );
    assert_eq!(
        String::from_utf8(p.run(&["config", "--list"]).stdout).unwrap(),
        "alias.ls=list\n"
    );
    assert_eq!(
        String::from_utf8(p.run(&["config", "--unset", "alias.ls"]).stdout).unwrap(),
        "Unset alias.ls.\n"
    );
    for args in [
        vec!["config"],
        vec!["config", "--list", "--get", "alias.ls"],
        vec!["config", "--unset", "alias.ls", "other.key"],
    ] {
        assert_eq!(p.run(&args).status.code(), Some(2));
    }
    let out = p
        .command()
        .env_remove("HOME")
        .args(["config", "--list"])
        .output()
        .unwrap();
    assert_eq!(out.status.code(), Some(1));
}

#[test]
fn configured_alias_and_legacy_herdr_setting_do_not_enable_dispatch() {
    let p = UserConfig::new();
    p.ok(&["config", "alias.ls", "list"]);
    p.ok(&["init"]);
    p.ok(&["add", "Task"]);
    assert_eq!(p.ok(&["ls"])[0]["description"], "Task");
    p.write("[alias]\nls = 'list'\n[herdr]\nnext-to-new-agent = 'wrong'\n");
    p.ok(&["config", "alias.ls", "list"]);
    assert_eq!(p.ok(&["ls"])[0]["description"], "Task");
    assert_eq!(
        p.ok(&["next", "--session", "worker"])["harness_session"],
        "worker"
    );
}

#[test]
fn concurrent_writers_keep_each_independent_setting() {
    let p = UserConfig::new();
    let children: Vec<_> = (0..10)
        .map(|i| {
            p.command()
                .args(["config", &format!("alias.a{i}"), "list"])
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()
                .unwrap()
        })
        .collect();
    for child in children {
        let out = child.wait_with_output().unwrap();
        assert!(
            out.status.success(),
            "{}",
            String::from_utf8_lossy(&out.stderr)
        );
    }
    let values = p.ok(&["config", "--list"]);
    assert_eq!(values.as_object().unwrap().len(), 10);
    for i in 0..10 {
        assert_eq!(values[format!("alias.a{i}")], "list");
    }
}

#[test]
fn config_edits_inline_tables_and_preserves_unknown_values() {
    let p = UserConfig::new();
    p.write("other = { kept = 7 } # retain\n");
    p.ok(&["config", "other.nested.leaf", "42"]);
    assert_eq!(
        p.ok(&["config", "other"]),
        json!({"kept":7,"nested":{"leaf":42}})
    );
    p.ok(&["config", "--unset", "other.nested.leaf"]);
    assert_eq!(p.ok(&["config", "other.kept"]), 7);
    assert!(fs::read_to_string(p.path()).unwrap().contains("# retain"));
    p.ok(&["config", "alias", "{ ls = 'list' }"]);
    p.ok(&["config", "alias.ls", "show"]);
    assert_eq!(p.ok(&["config", "alias.ls"]), "show");
}

#[test]
fn config_human_output_escapes_controls_and_json_keeps_original() {
    let p = UserConfig::new();
    let value = "list\n\u{1b}[31m";
    p.ok(&["config", "alias.ls", value]);
    assert_eq!(p.ok(&["config", "alias.ls"]), value);
    assert_eq!(
        String::from_utf8(p.run(&["config", "alias.ls"]).stdout).unwrap(),
        "list\\n\\u{1b}[31m\n"
    );
    let out = p.run(&["config", "--list"]);
    assert!(!out.stdout.contains(&27));
}

#[test]
fn config_validates_display_setting_and_applies_it_to_human_lists() {
    let p = UserConfig::new();
    p.ok(&["config", "display.max-completed", "0"]);
    let before = fs::read(p.path()).unwrap();
    for value in ["-1", "false", "1.5", "'oops'", "9223372036854775808"] {
        let out = p.run(&["config", "display.max-completed", "--", value]);
        assert_eq!(
            out.status.code(),
            Some(1),
            "{value}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        assert_eq!(fs::read(p.path()).unwrap(), before);
    }
    p.ok(&["init"]);
    p.ok(&["add", "Completed task"]);
    p.ok(&["next", "--session", "worker", "--local"]);
    p.ok(&["edit", "--complete", "1", "--session", "worker"]);
    assert_eq!(
        String::from_utf8(p.run(&["list"]).stdout).unwrap(),
        "No tasks to display.\n"
    );
    assert_eq!(p.ok(&["list"]).as_array().unwrap().len(), 1);
    p.ok(&["config", "--unset", "display.max-completed"]);
    assert!(
        String::from_utf8(p.run(&["list"]).stdout)
            .unwrap()
            .contains("Completed task")
    );
}

#[test]
fn config_validates_tui_after_save_new_and_preserves_invalid_edits() {
    let p = UserConfig::new();
    for value in ["open_saved", "open_new"] {
        assert_eq!(
            p.ok(&["config", "tui.after_save_new", value]),
            json!({"key":"tui.after_save_new","value":value})
        );
        assert_eq!(p.ok(&["config", "--get", "tui.after_save_new"]), value);
    }
    let before = fs::read(p.path()).unwrap();
    for value in ["other", "false", "1", "['open_new']", "'OPEN_NEW'"] {
        let out = p.run(&["config", "tui.after_save_new", value]);
        assert_eq!(out.status.code(), Some(1), "{value}");
        assert!(out.stdout.is_empty());
        assert_eq!(fs::read(p.path()).unwrap(), before, "{value}");
    }
    p.ok(&["config", "--unset", "tui.after_save_new"]);
    assert!(
        !p.run(&["config", "--get", "tui.after_save_new"])
            .status
            .success()
    );
    assert!(!p.home.path().join(".qqq/qqq.db").exists());
}

#[test]
fn invalid_tui_config_fails_before_terminal_entry_and_keeps_other_sections_independent() {
    let p = UserConfig::new();
    p.ok(&["init"]);
    p.write("[tui]\nafter_save_new = 'unknown'\n[alias]\nls = 'list'\n");
    assert_eq!(p.ok(&["config", "--get", "tui.after_save_new"]), "unknown");
    assert_eq!(p.ok(&["ls"]), json!([]));
    assert_eq!(p.ok(&["list"]), json!([]));
    let out = p.run(&["tui"]);
    assert_eq!(out.status.code(), Some(1));
    assert!(out.stdout.is_empty());
    let error = String::from_utf8(out.stderr).unwrap();
    assert!(
        error.contains("open_saved") && error.contains("open_new"),
        "{error}"
    );
    assert!(!error.contains('\u{1b}'));
}

#[cfg(unix)]
#[test]
fn config_updates_preserve_symlink_and_target_permissions() {
    use std::os::unix::{fs::PermissionsExt, fs::symlink};
    let p = UserConfig::new();
    let target = p.home.path().join("dotfile.toml");
    fs::write(&target, "[alias]\nls='list'\n").unwrap();
    fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
    fs::create_dir_all(p.path().parent().unwrap()).unwrap();
    symlink(&target, p.path()).unwrap();
    p.ok(&["config", "alias.ls", "show"]);
    assert!(
        fs::symlink_metadata(p.path())
            .unwrap()
            .file_type()
            .is_symlink()
    );
    assert_eq!(
        fs::metadata(&target).unwrap().permissions().mode() & 0o777,
        0o600
    );
    assert!(fs::read_to_string(&target).unwrap().contains("show"));
    assert_eq!(p.ok(&["config", "alias.ls"]), "show");
    assert!(!Path::new(p.home.path()).join(".qqq/qqq.db").exists());
}