torudo 0.17.0

A terminal-based todo.txt viewer and manager with TUI interface
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const fn bin() -> &'static str {
    env!("CARGO_BIN_EXE_torudo")
}

fn fresh_dir(name: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(name);
    fs::remove_dir_all(&dir).ok();
    fs::create_dir_all(&dir).unwrap();
    dir
}

fn run(dir: &Path, args: &[&str]) -> std::process::Output {
    let mut full = vec!["--todotxt-dir", dir.to_str().unwrap()];
    full.extend_from_slice(args);
    Command::new(bin())
        .args(&full)
        .output()
        .expect("failed to run torudo")
}

fn stdout_of(output: &std::process::Output) -> String {
    assert!(
        output.status.success(),
        "non-zero exit: stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).to_string()
}

#[test]
fn search_spans_every_mode_file() {
    let dir = fresh_dir("torudo_it_search_all_modes");
    fs::write(dir.join("inbox.txt"), "Buy milk id:aaa\n").unwrap();
    fs::write(dir.join("someday.txt"), "Buy a boat id:zzz\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "buy", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 2);
    assert_eq!(hits[0]["id"], "aaa");
    assert_eq!(hits[0]["mode"], "inbox");
    assert_eq!(hits[1]["id"], "zzz");
    assert_eq!(hits[1]["mode"], "someday");
}

#[test]
fn search_ignores_case() {
    let dir = fresh_dir("torudo_it_search_case");
    fs::write(dir.join("todo.txt"), "Fix FLAKY test id:aaa\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "flaky", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 1);
    assert_eq!(hits[0]["matched"], "title");
}

#[test]
fn search_reaches_into_the_detail_md_file() {
    let dir = fresh_dir("torudo_it_search_md");
    fs::write(dir.join("ref.txt"), "Onboarding notes id:aaa\n").unwrap();
    fs::create_dir_all(dir.join("todos")).unwrap();
    fs::write(
        dir.join("todos/aaa.md"),
        "# Notes\n\nask about VPN access\n",
    )
    .unwrap();

    let out = stdout_of(&run(&dir, &["search", "vpn", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 1);
    assert_eq!(hits[0]["id"], "aaa");
    assert_eq!(hits[0]["matched"], "md");
}

#[test]
fn search_reports_both_when_the_title_and_the_md_match() {
    let dir = fresh_dir("torudo_it_search_both");
    fs::write(dir.join("todo.txt"), "Fix flaky test id:aaa\n").unwrap();
    fs::create_dir_all(dir.join("todos")).unwrap();
    fs::write(dir.join("todos/aaa.md"), "the flaky one is spec/foo\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "flaky", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits[0]["matched"], "both");
}

#[test]
fn search_matches_a_project_tag() {
    let dir = fresh_dir("torudo_it_search_project_tag");
    fs::write(
        dir.join("todo.txt"),
        "Fix the flaky spec +keihi @office time:short id:aaa\n",
    )
    .unwrap();

    let out = stdout_of(&run(&dir, &["search", "keihi", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 1);
    assert_eq!(hits[0]["id"], "aaa");
    assert_eq!(hits[0]["matched"], "title");
}

#[test]
fn search_matches_a_context_tag() {
    let dir = fresh_dir("torudo_it_search_context_tag");
    fs::write(
        dir.join("todo.txt"),
        "Fix the flaky spec +keihi @office time:short id:aaa\n",
    )
    .unwrap();

    let out = stdout_of(&run(&dir, &["search", "office", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 1);
    assert_eq!(hits[0]["id"], "aaa");
}

#[test]
fn search_never_matches_a_key_value_tag() {
    let dir = fresh_dir("torudo_it_search_key_value");
    fs::write(
        dir.join("todo.txt"),
        "Fix the flaky spec +keihi @office time:short id:aaa\n",
    )
    .unwrap();

    // `time:short` and `id:aaa` are metadata, not something the user searches for.
    let out = stdout_of(&run(&dir, &["search", "short", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits, serde_json::json!([]));
}

#[test]
fn search_never_looks_at_done_txt() {
    let dir = fresh_dir("torudo_it_search_done");
    fs::write(dir.join("done.txt"), "x 2026-08-15 Buy milk id:done-1\n").unwrap();
    fs::write(dir.join("inbox.txt"), "Buy milk id:aaa\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "milk", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 1);
    assert_eq!(hits[0]["id"], "aaa");
}

#[test]
fn search_with_a_mode_narrows_the_sweep() {
    let dir = fresh_dir("torudo_it_search_mode");
    fs::write(dir.join("inbox.txt"), "Buy milk id:aaa\n").unwrap();
    fs::write(dir.join("ref.txt"), "Buy milk elsewhere id:bbb\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "milk", "--json", "--mode", "ref"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits.as_array().unwrap().len(), 1);
    assert_eq!(hits[0]["id"], "bbb");
    assert_eq!(hits[0]["mode"], "ref");
}

#[test]
fn search_without_json_puts_the_id_first_on_every_line() {
    let dir = fresh_dir("torudo_it_search_plain");
    fs::write(dir.join("inbox.txt"), "Buy milk id:aaa-111\n").unwrap();
    fs::write(dir.join("todo.txt"), "Buy a boat\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "buy"]));

    assert_eq!(
        out,
        "aaa-111 [inbox] Buy milk (title)\n- [todo] Buy a boat (title)\n"
    );
}

#[test]
fn search_with_no_hits_prints_an_empty_json_array_and_exits_zero() {
    let dir = fresh_dir("torudo_it_search_empty");
    fs::write(dir.join("inbox.txt"), "Buy milk id:aaa\n").unwrap();

    let out = stdout_of(&run(&dir, &["search", "nothing-here", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits, serde_json::json!([]));
}

#[test]
fn search_succeeds_when_no_mode_file_exists_yet() {
    let dir = fresh_dir("torudo_it_search_no_files");

    let out = stdout_of(&run(&dir, &["search", "milk", "--json"]));
    let hits: serde_json::Value = serde_json::from_str(&out).unwrap();

    assert_eq!(hits, serde_json::json!([]));
}

#[test]
fn search_with_an_empty_query_fails() {
    let dir = fresh_dir("torudo_it_search_blank");
    fs::write(dir.join("inbox.txt"), "Buy milk id:aaa\n").unwrap();

    let output = run(&dir, &["search", ""]);

    assert!(!output.status.success());
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("query must not be empty"),
        "stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
}