tasks-cli-rs 0.2.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::path::Path;
use std::process::{Command, Output};

fn run(home: &Path, args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_tasks"))
        .env("TASKS_CLI_HOME", home)
        .args(args)
        .output()
        .expect("failed to run tasks binary")
}

fn ok(home: &Path, args: &[&str]) -> String {
    let out = run(home, args);
    assert!(
        out.status.success(),
        "command {:?} failed: {}",
        args,
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).to_string()
}

#[test]
fn full_workflow() {
    let home = std::env::temp_dir().join(format!("tasks-e2e-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&home);
    std::fs::create_dir_all(&home).unwrap();
    let lib_path = home.join("repos/personal");

    // library setup
    ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);
    let out = ok(&home, &["lib", "current"]);
    assert!(out.contains("personal"));

    // new
    let out = ok(&home, &[
        "new", "Fix login bug", "--priority", "high", "--tag", "backend", "--due", "2026-08-10",
    ]);
    assert!(out.contains("created task #1"));
    ok(&home, &["new", "Write docs", "--dir", "work"]);

    // list
    let out = ok(&home, &["list"]);
    assert!(out.contains("Fix login bug"));
    assert!(out.contains("Write docs"));

    // start -> set -> done
    ok(&home, &["start", "1"]);
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("in_progress"));
    assert!(out.contains("started:"));

    ok(&home, &["set", "1", "--title", "Fix login timeout bug", "--tag-add", "urgent"]);
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("Fix login timeout bug"));
    assert!(out.contains("urgent"));

    ok(&home, &["done", "1"]);
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("done"));
    assert!(out.contains("completed:"));

    // filters and search
    let out = ok(&home, &["list", "--status", "done"]);
    assert!(out.contains("Fix login timeout bug"));
    assert!(!out.contains("Write docs"));

    let out = ok(&home, &["search", "docs"]);
    assert!(out.contains("Write docs"));

    // tag commands
    ok(&home, &["tag", "add", "2", "documentation"]);
    let out = ok(&home, &["tag", "list"]);
    assert!(out.contains("documentation"));

    // delete
    ok(&home, &["delete", "2", "--force"]);
    let out = ok(&home, &["list"]);
    assert!(!out.contains("Write docs"));

    // unknown id is a clean error
    let out = run(&home, &["show", "999"]);
    assert!(!out.status.success());
    assert!(String::from_utf8_lossy(&out.stderr).contains("not found"));

    std::fs::remove_dir_all(&home).unwrap();
}

#[test]
fn filename_format_and_rename_on_title_change() {
    let home = std::env::temp_dir().join(format!("tasks-e2e-fmt-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&home);
    std::fs::create_dir_all(&home).unwrap();
    std::fs::write(home.join("config.toml"), "filename_format = \"{date}-{slug}-{short_id}\"\n")
        .unwrap();
    let lib_path = home.join("repos/personal");
    ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);

    // new file carries the date prefix
    let out = ok(&home, &["new", "My Task"]);
    let file_line = out.lines().nth(1).unwrap().trim();
    let filename = Path::new(file_line).file_name().unwrap().to_string_lossy().to_string();
    assert!(filename.contains("-my-task-"), "{filename}");
    assert!(filename.chars().take(4).all(|c| c.is_ascii_digit()), "date prefix: {filename}");

    // title change renames the file, only the slug part differs
    let out = ok(&home, &["set", "1", "--title", "Renamed Task"]);
    assert!(out.contains("renamed:"), "{out}");
    let renamed = out
        .lines()
        .find(|l| l.starts_with("renamed:"))
        .unwrap()
        .trim_start_matches("renamed:")
        .trim();
    assert_eq!(renamed, filename.replace("my-task", "renamed-task"));
    assert!(lib_path.join(renamed).exists());
    assert!(!lib_path.join(&filename).exists());

    // task still resolvable and intact after rename
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("Renamed Task"));

    // bad template is rejected cleanly
    std::fs::write(home.join("config.toml"), "filename_format = \"{typo}\"\n").unwrap();
    let out = run(&home, &["new", "Another"]);
    assert!(!out.status.success());
    assert!(String::from_utf8_lossy(&out.stderr).contains("filename_format"));

    std::fs::remove_dir_all(&home).unwrap();
}

#[test]
fn adopt_and_warning_suppression() {
    let home = std::env::temp_dir().join(format!("tasks-e2e-adopt-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&home);
    std::fs::create_dir_all(&home).unwrap();
    let lib_path = home.join("repos/personal");
    ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);

    // plain notes without front matter
    std::fs::write(lib_path.join("2026-06-17-langchain研究.md"), "# LangChain 研究\n\n内容\n").unwrap();
    std::fs::write(lib_path.join("scratch.md"), "just a note\n").unwrap();

    // one-line summary warning instead of per-file noise
    let out = run(&home, &["list"]);
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert_eq!(stderr.matches("warning").count(), 1, "{stderr}");
    assert!(stderr.contains("2 non-task file(s)"), "{stderr}");

    // --quiet suppresses it
    let out = run(&home, &["list", "--quiet"]);
    assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));

    // ignore pattern silences and hides the file
    std::fs::write(
        lib_path.join(".tasks-meta.toml"),
        "next_seq = 1\nignore = [\"scratch.md\"]\n",
    )
    .unwrap();
    let out = run(&home, &["list"]);
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(stderr.contains("1 non-task file(s)"), "{stderr}");

    // dry-run shows the plan without changing anything
    let out = ok(&home, &["adopt", "--dry-run"]);
    assert!(out.contains("would adopt"), "{out}");
    assert!(out.contains("LangChain 研究"), "{out}");
    let raw = std::fs::read_to_string(lib_path.join("2026-06-17-langchain研究.md")).unwrap();
    assert!(!raw.starts_with("---"));

    // adopt --all --rename converts and renames per template
    std::fs::write(home.join("config.toml"), "filename_format = \"{date}-{slug}-{short_id}\"\n")
        .unwrap();
    let out = ok(&home, &["adopt", "--all", "--rename"]);
    assert!(out.contains("adopted 1 file(s)"), "{out}");
    assert!(out.contains("renamed:"), "{out}");

    // adopted task is now listed, warning gone, metadata derived
    let out = run(&home, &["list"]);
    assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("LangChain 研究"), "{stdout}");

    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("created:   2026-06-1"), "date from filename: {out}");
    assert!(out.contains("内容"), "body preserved: {out}");

    // renamed file carries the date prefix from created_at
    let renamed_exists = std::fs::read_dir(&lib_path)
        .unwrap()
        .filter_map(|e| e.ok())
        .any(|e| e.file_name().to_string_lossy().starts_with("2026-06-17-langchain-研究"));
    assert!(renamed_exists);

    // warn_invalid_files = false silences globally (scratch.md still skipped)
    std::fs::write(
        lib_path.join(".tasks-meta.toml"),
        "next_seq = 2\n",
    )
    .unwrap();
    std::fs::write(
        home.join("config.toml"),
        "warn_invalid_files = false\n",
    )
    .unwrap();
    let out = run(&home, &["list"]);
    assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));

    std::fs::remove_dir_all(&home).unwrap();
}

#[test]
fn v02_steps_board_remind_stats() {
    let home = std::env::temp_dir().join(format!("tasks-e2e-v02-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&home);
    std::fs::create_dir_all(&home).unwrap();
    std::fs::write(home.join("config.toml"), "default_priority = \"high\"\n").unwrap();
    let lib_path = home.join("repos/personal");
    ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);

    // default_priority from config is applied
    ok(&home, &["new", "Main work"]);
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("priority:  high"), "{out}");

    // steps: add with dependency validation, done, remove
    ok(&home, &["step", "add", "1", "Design schema"]);
    ok(&home, &["step", "add", "1", "Implement", "--depends-on", "s1"]);
    let out = run(&home, &["step", "add", "1", "Bad", "--depends-on", "s9"]);
    assert!(!out.status.success(), "unknown dep must fail");
    let out = ok(&home, &["step", "done", "1", "s1"]);
    assert!(out.contains("progress: 1/2"), "{out}");
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("steps:     1/2"), "{out}");
    ok(&home, &["step", "remove", "1", "s1"]);
    let out = ok(&home, &["show", "1"]);
    assert!(!out.contains("Design schema"), "{out}");

    // board
    ok(&home, &["new", "Doing thing"]);
    ok(&home, &["start", "2"]);
    let out = ok(&home, &["board"]);
    assert!(out.contains("TODO (1)"), "{out}");
    assert!(out.contains("IN_PROGRESS (1)"), "{out}");
    assert!(out.contains("#2 Doing thing"), "{out}");

    // remind / overdue
    ok(&home, &["new", "Old due", "--due", "2020-01-01"]);
    ok(&home, &["new", "Far future", "--due", "2099-01-01"]);
    let out = ok(&home, &["remind"]);
    assert!(out.contains("OVERDUE"), "{out}");
    assert!(out.contains("Old due"), "{out}");
    assert!(!out.contains("Far future"), "{out}");
    let out = ok(&home, &["remind", "--summary"]);
    assert!(out.contains("1 overdue"), "{out}");
    let out = ok(&home, &["overdue"]);
    assert!(out.contains("Old due"), "{out}");
    assert!(!out.contains("Far future"), "{out}");

    // done tasks are not overdue
    ok(&home, &["done", "3"]);
    let out = ok(&home, &["overdue"]);
    assert!(!out.contains("Old due"), "{out}");

    // stats
    let out = ok(&home, &["stats"]);
    assert!(out.contains("Total tasks:      4"), "{out}");
    assert!(out.contains("done:"), "{out}");
    assert!(out.contains("Completion rate:"), "{out}");

    // completions
    let out = ok(&home, &["completions", "bash"]);
    assert!(out.contains("tasks"), "{out}");
    let out = run(&home, &["completions", "tcsh"]);
    assert!(!out.status.success());

    std::fs::remove_dir_all(&home).unwrap();
}

#[test]
fn templates_create_and_apply() {
    let home = std::env::temp_dir().join(format!("tasks-e2e-tpl-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&home);
    std::fs::create_dir_all(home.join("templates")).unwrap();
    let lib_path = home.join("repos/personal");
    ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);

    // empty state hint
    let out = ok(&home, &["template", "list"]);
    assert!(out.contains("no templates"), "{out}");

    // global template
    std::fs::write(
        home.join("templates/bug.md"),
        "---\npriority: high\ntags: [bug]\nsteps:\n  - id: s1\n    title: 复现\n---\n\n## {{title}}\n\n报于 {{date}}\n",
    )
    .unwrap();
    let out = ok(&home, &["template", "list"]);
    assert!(out.contains("bug"), "{out}");
    assert!(out.contains("global"), "{out}");

    // create from template: defaults applied, variables substituted
    ok(&home, &["new", "登录页崩溃", "--template", "bug"]);
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("priority:  high"), "{out}");
    assert!(out.contains("bug"), "{out}");
    assert!(out.contains("steps:     0/1"), "{out}");
    assert!(out.contains("## 登录页崩溃"), "{out}");
    assert!(!out.contains("{{date}}"), "{out}");

    // CLI args override template
    ok(&home, &["new", "另一个", "--template", "bug", "--priority", "low", "--tag", "cli"]);
    let out = ok(&home, &["show", "2"]);
    assert!(out.contains("priority:  low"), "{out}");
    assert!(out.contains("cli"), "{out}");
    assert!(!out.contains("tags:      bug"), "{out}");

    // library template shadows global
    std::fs::create_dir_all(lib_path.join(".templates")).unwrap();
    std::fs::write(lib_path.join(".templates/bug.md"), "---\npriority: urgent\n---\nlib body\n").unwrap();
    ok(&home, &["new", "第三个", "--template", "bug"]);
    let out = ok(&home, &["show", "3"]);
    assert!(out.contains("priority:  urgent"), "{out}");

    // .templates dir does not pollute task list / warnings
    let out = run(&home, &["list"]);
    assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));

    // show / remove / unknown
    let out = ok(&home, &["template", "show", "bug"]);
    assert!(out.contains("lib body"), "{out}");
    ok(&home, &["template", "remove", "bug"]);
    let out = ok(&home, &["template", "show", "bug"]);
    assert!(out.contains("priority: high"), "global remains after lib removed: {out}");
    let out = run(&home, &["new", "x", "--template", "nope"]);
    assert!(!out.status.success());

    std::fs::remove_dir_all(&home).unwrap();
}

#[cfg(unix)]
#[test]
fn edit_rejects_immutable_changes_and_restores() {
    use std::os::unix::fs::PermissionsExt;

    let home = std::env::temp_dir().join(format!("tasks-e2e-edit-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&home);
    std::fs::create_dir_all(&home).unwrap();
    let lib_path = home.join("repos/personal");
    ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);
    ok(&home, &["new", "Original task"]);

    let make_editor = |name: &str, script: &str| -> String {
        let path = home.join(name);
        std::fs::write(&path, script).unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
        path.to_str().unwrap().to_string()
    };

    // editor that changes the immutable seq field -> rejected, file restored
    let editor = make_editor("edit-seq.sh", "#!/bin/sh\nsed -i 's/^seq: 1$/seq: 42/' \"$1\"\n");
    let out = run(&home, &["edit", "1", "--editor", &editor]);
    assert!(!out.status.success());
    assert!(String::from_utf8_lossy(&out.stderr).contains("'seq'"));
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("#1 "), "seq should be restored to 1");

    // editor that corrupts the front matter -> rejected, file restored
    let editor = make_editor("edit-break.sh", "#!/bin/sh\necho 'garbage' > \"$1\"\n");
    let out = run(&home, &["edit", "1", "--editor", &editor]);
    assert!(!out.status.success());
    let out = ok(&home, &["show", "1"]);
    assert!(out.contains("Original task"));

    // editor that makes a legal change -> accepted
    let editor = make_editor(
        "edit-title.sh",
        "#!/bin/sh\nsed -i 's/title: Original task/title: Renamed task/' \"$1\"\n",
    );
    let out = ok(&home, &["edit", "1", "--editor", &editor]);
    assert!(out.contains("saved #1 Renamed task"));

    std::fs::remove_dir_all(&home).unwrap();
}