tasks-cli-rs 0.1.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();
}

#[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();
}