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");
ok(&home, &["lib", "add", "personal", lib_path.to_str().unwrap()]);
let out = ok(&home, &["lib", "current"]);
assert!(out.contains("personal"));
let out = ok(&home, &[
"new", "Fix login bug", "--priority", "high", "--tag", "backend", "--due", "2026-08-10",
]);
assert!(out.contains("created task #1"));
let out = ok(&home, &["add", "Write docs", "--dir", "work"]);
assert!(out.contains("created task #2"), "{out}");
let out = ok(&home, &["list"]);
assert!(out.contains("Fix login bug"));
assert!(out.contains("Write docs"));
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:"));
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"));
ok(&home, &["tag", "add", "2", "documentation"]);
let out = ok(&home, &["tag", "list"]);
assert!(out.contains("documentation"));
ok(&home, &["delete", "2", "--force"]);
let out = ok(&home, &["list"]);
assert!(!out.contains("Write docs"));
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()]);
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}");
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());
let out = ok(&home, &["show", "1"]);
assert!(out.contains("Renamed Task"));
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()]);
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();
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}");
let out = run(&home, &["list", "--quiet"]);
assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));
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}");
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("---"));
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}");
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}");
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);
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()]);
ok(&home, &["new", "Main work"]);
let out = ok(&home, &["show", "1"]);
assert!(out.contains("priority: high"), "{out}");
ok(&home, &["step", "add", "1", "Design schema"]);
ok(&home, &["step", "add", "1", "Implement"]);
let out = ok(&home, &["step", "done", "1", "s1"]);
assert!(out.contains("progress: 1/2"), "{out}");
let out = ok(&home, &["step", "done", "1", "s2"]);
assert!(out.contains("progress: 2/2"), "{out}");
let out = ok(&home, &["show", "1"]);
assert!(out.contains("steps: 2/2"), "{out}");
ok(&home, &["step", "remove", "1", "s1"]);
let out = ok(&home, &["show", "1"]);
assert!(!out.contains("Design schema"), "{out}");
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}");
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}");
ok(&home, &["done", "3"]);
let out = ok(&home, &["overdue"]);
assert!(!out.contains("Old due"), "{out}");
let out = ok(&home, &["stats"]);
assert!(out.contains("Total tasks: 4"), "{out}");
assert!(out.contains("done:"), "{out}");
assert!(out.contains("Completion rate:"), "{out}");
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()]);
let out = ok(&home, &["template", "list"]);
assert!(out.contains("no templates"), "{out}");
std::fs::write(
home.join("templates/bug.md"),
"---\npriority: high\ntags: [bug]\n---\n\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}");
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}");
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}");
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}");
let out = run(&home, &["list"]);
assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));
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()
};
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");
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"));
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();
}
#[test]
fn fix_names_renames_to_template() {
let home = std::env::temp_dir().join(format!("tasks-e2e-fixnames-{}", 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()]);
ok(&home, &["new", "My Task"]);
let entries: Vec<_> = std::fs::read_dir(&lib_path)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.collect();
assert_eq!(entries.len(), 1);
let original = entries[0].path();
let renamed = lib_path.join("old-name.md");
std::fs::rename(&original, &renamed).unwrap();
let out = ok(&home, &["fix-names", "--dry-run"]);
assert!(out.contains("would rename"), "{out}");
assert!(renamed.exists());
let out = ok(&home, &["fix-names"]);
assert!(out.contains("renamed 1 file(s)"), "{out}");
assert!(!renamed.exists());
let entries: Vec<_> = std::fs::read_dir(&lib_path)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.collect();
let name = entries[0].file_name().to_string_lossy().to_string();
assert!(name.contains("my-task"), "{name}");
assert!(name.chars().take(4).all(|c| c.is_ascii_digit()), "date prefix: {name}");
let out = ok(&home, &["fix-names"]);
assert!(out.contains("already match"), "{out}");
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn batch_status_with_filters() {
let home = std::env::temp_dir().join(format!("tasks-e2e-batch-{}", 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", "Backend A", "--tag", "backend"]);
ok(&home, &["new", "Backend B", "--tag", "backend"]);
ok(&home, &["new", "Frontend C", "--tag", "frontend"]);
ok(&home, &["new", "Project task", "--dir", "work", "--tag", "backend"]);
let out = run(&home, &["batch", "done", "--force"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("without filters"));
let out = ok(&home, &["batch", "done", "--tag", "backend", "--dry-run"]);
assert!(out.contains("Backend A"), "{out}");
assert!(out.contains("3 task(s) would be set to done"), "{out}");
let out = ok(&home, &["list", "--status", "done"]);
assert!(out.contains("no tasks"), "{out}");
let out = ok(&home, &["batch", "done", "--tag", "backend", "--force"]);
assert!(out.contains("3 task(s) -> done"), "{out}");
let out = ok(&home, &["list", "--status", "done"]);
assert!(out.contains("Backend A") && out.contains("Backend B"), "{out}");
assert!(!out.contains("Frontend C"), "{out}");
let out = ok(&home, &["batch", "todo", "--dir", "work", "--force"]);
assert!(out.contains("1 task(s) -> todo"), "{out}");
let out = ok(&home, &["batch", "in_progress", "--status", "todo", "--tag", "frontend", "--force"]);
assert!(out.contains("1 task(s) -> in_progress"), "{out}");
let out = run(&home, &["batch", "bogus", "--tag", "backend"]);
assert!(!out.status.success());
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn archive_moves_tasks_to_month_folders() {
let home = std::env::temp_dir().join(format!("tasks-e2e-archive-{}", 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", "Finished A"]);
ok(&home, &["new", "Finished B"]);
ok(&home, &["new", "Still open"]);
ok(&home, &["new", "Tagged old", "--tag", "legacy"]);
ok(&home, &["done", "1"]);
ok(&home, &["cancel", "2"]);
let out = ok(&home, &["archive", "--dry-run"]);
assert!(out.contains("would archive"), "{out}");
assert!(out.contains("2 task(s) would be archived"), "{out}");
assert!(!lib_path.join("archive").exists(), "dry-run must not create dirs");
let out = ok(&home, &["archive", "--force"]);
assert!(out.contains("archived 2 task(s)"), "{out}");
let month = chrono::Local::now().format("%Y-%m").to_string();
let month_dir = lib_path.join("archive").join(&month);
assert!(month_dir.exists(), "expected {}", month_dir.display());
let archived: Vec<_> = std::fs::read_dir(&month_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.collect();
assert_eq!(archived.len(), 2);
let out = run(&home, &["list"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("Still open"), "{stdout}");
assert!(!stdout.contains("Finished A"), "{stdout}");
assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));
let scope = format!("archive/{month}");
let out = ok(&home, &["list", "--dir", &scope]);
assert!(out.contains("Finished A"), "{out}");
let out = ok(&home, &["archive", "--tag", "legacy", "--force"]);
assert!(out.contains("archived 1 task(s)"), "{out}");
let out = ok(&home, &["list"]);
assert!(!out.contains("Tagged old"), "{out}");
std::fs::create_dir_all(lib_path.join("proj-a/archive/2026-01")).unwrap();
std::fs::write(
lib_path.join("proj-a/archive/2026-01/nested.md"),
"---\nid: 00000000-0000-0000-0000-0000000000aa\nseq: 99\ntitle: Nested archived\ncreated_at: 2026-01-01T00:00:00Z\n---\n",
)
.unwrap();
let out = run(&home, &["list"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(!stdout.contains("Nested archived"), "{stdout}");
assert!(!String::from_utf8_lossy(&out.stderr).contains("warning"));
let out = ok(&home, &["list", "--dir", "proj-a/archive/2026-01"]);
assert!(out.contains("Nested archived"), "{out}");
let out = ok(&home, &["archive", "--force"]);
assert!(out.contains("no tasks to archive"), "{out}");
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn three_block_document_layout() {
let home = std::env::temp_dir().join(format!("tasks-e2e-blocks-{}", 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", "Layout task"]);
ok(&home, &["log", "1", "## 详细说明", "--quiet"]);
ok(&home, &["log", "1", "一些正文内容", "--quiet"]);
ok(&home, &["step", "add", "1", "第一步"]);
ok(&home, &["step", "add", "1", "第二步"]);
let file = std::fs::read_dir(&lib_path)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.path().extension().is_some_and(|x| x == "md"))
.unwrap()
.path();
let content = std::fs::read_to_string(&file).unwrap();
let body = content.split("---\n").nth(2).unwrap();
let todo_pos = body.find("- [ ] 第一步").unwrap();
let content_pos = body.find("## 详细说明").unwrap();
assert!(todo_pos < content_pos, "TODO block must precede content:\n{body}");
assert!(body.find("- [ ] 第二步").unwrap() < content_pos, "{body}");
ok(&home, &["log", "1", "--quiet", "--", "- [ ] 这只是正文里的文本"]);
let out = ok(&home, &["show", "1"]);
assert!(out.contains("steps: 0/2"), "{out}");
let out = ok(&home, &["step", "done", "1", "s2"]);
assert!(out.contains("progress: 1/2"), "{out}");
let out = run(&home, &["step", "done", "1", "s3"]);
assert!(!out.status.success(), "content checkbox must not be s3");
let out = ok(&home, &["step", "get", "1"]);
assert!(out.contains("s1 [ ] 第一步"), "{out}");
assert!(out.contains("s2 [x] 第二步"), "{out}");
assert!(out.contains("progress: 1/2"), "{out}");
let out = ok(&home, &["step", "list", "1"]);
assert!(out.contains("s1 [ ] 第一步"), "{out}");
std::fs::remove_dir_all(&home).unwrap();
}
fn repl(home: &Path, script: &str) -> Output {
use std::io::Write;
let mut child = Command::new(env!("CARGO_BIN_EXE_tasks"))
.env("TASKS_CLI_HOME", home)
.arg("repl")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn repl");
child.stdin.as_mut().unwrap().write_all(script.as_bytes()).unwrap();
child.wait_with_output().unwrap()
}
#[test]
fn repl_workbench() {
let home = std::env::temp_dir().join(format!("tasks-e2e-repl-{}", 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()]);
let out = repl(
&home,
"/add 重构存储层 -D 抽出接口层\n/use 1\n/start\n/step add 设计接口\n/step done s1\n/quit\n",
);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("=== 进行中 ==="), "{stdout}");
assert!(stdout.contains("* #1 [medium] 重构存储层"), "selection marker: {stdout}");
assert!(stdout.contains("抽出接口层"), "{stdout}");
assert!(stdout.contains("s1 [x] 设计接口"), "{stdout}");
assert!(stdout.contains("tasks[#1]>"), "prompt shows context: {stdout}");
assert!(stdout.contains("uuid:"), "/use shows the task: {stdout}");
assert!(stdout.contains("file:"), "/use shows the task: {stdout}");
let out = ok(&home, &["show", "1"]);
assert!(out.contains("status: in_progress"), "{out}");
assert!(out.contains("steps: 1/1"), "{out}");
let out = repl(&home, "/help\nnot-a-slash\n/nonsense\n/unuse\n/quit\n");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stdout.contains("/use <id>"), "{stdout}");
assert!(stderr.contains("must start with '/'"), "{stderr}");
assert!(stderr.contains("unrecognized subcommand"), "{stderr}");
assert!(
!stderr.contains("invalid task file"),
"clap errors must not be wrapped: {stderr}"
);
let out = repl(&home, "/list\n/quit\n");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("tasks> /list"), "{stdout}");
assert!(!stdout.contains("tasks> tasks>"), "duplicated prompt: {stdout}");
let dashboards = stdout.matches("=== 进行中 ===").count();
assert_eq!(dashboards, 1, "only the startup dashboard: {stdout}");
let out = repl(&home, "/repl\n");
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("already in the workbench"));
ok(&home, &["new", "Pending one"]);
let out = repl(&home, "/quit\n");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("todo 1"), "{stdout}");
assert!(stdout.contains("=== 待办 Top"), "{stdout}");
assert!(stdout.contains("Pending one"), "{stdout}");
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn description_field() {
let home = std::env::temp_dir().join(format!("tasks-e2e-desc-{}", 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",
"重构存储层",
"--description",
"现有实现把文件 IO 和业务逻辑耦合在一起,需要抽出接口层以便未来替换后端。",
]);
let out = ok(&home, &["show", "1"]);
assert!(out.contains("desc: 现有实现把文件 IO"), "{out}");
let file = std::fs::read_dir(&lib_path)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.path().extension().is_some_and(|x| x == "md"))
.unwrap()
.file_name()
.to_string_lossy()
.to_string();
assert!(file.contains("重构存储层"), "{file}");
assert!(!file.contains("耦合"), "description must not leak into filename: {file}");
let out = ok(&home, &["search", "抽出接口层"]);
assert!(out.contains("重构存储层"), "{out}");
ok(&home, &["set", "1", "--description", "新的简介"]);
let out = ok(&home, &["show", "1"]);
assert!(out.contains("desc: 新的简介"), "{out}");
ok(&home, &["set", "1", "--description", "none"]);
let out = ok(&home, &["show", "1"]);
assert!(!out.contains("desc:"), "{out}");
ok(&home, &["new", "无简介任务"]);
let content = std::fs::read_to_string(
std::fs::read_dir(&lib_path)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.find(|p| p.to_string_lossy().contains("无简介任务"))
.unwrap(),
)
.unwrap();
assert!(!content.contains("description:"), "{content}");
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn log_rewrite_strike() {
let home = std::env::temp_dir().join(format!("tasks-e2e-body-{}", 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", "Test task"]);
let out = ok(&home, &["log", "1", "## Notes"]);
assert!(out.contains("## Notes"), "{out}");
let out = ok(&home, &["log", "1", "first note", "--after", "Notes"]);
assert!(out.contains("## Notes\nfirst note"), "{out}");
let out = run(&home, &["log", "1", "x", "--after", "nonexistent"]);
assert!(!out.status.success());
let out = ok(&home, &["rewrite", "1", "first note", "updated note"]);
assert!(out.contains("updated note"), "{out}");
assert!(!out.contains("first note"), "{out}");
ok(&home, &["log", "1", "dup line A"]);
ok(&home, &["note", "1", "dup line B"]);
let out = run(&home, &["rewrite", "1", "dup line", "x"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("2 lines"));
let out = ok(&home, &["strike", "1", "dup line A"]);
assert!(!out.contains("dup line A"), "{out}");
ok(&home, &["strike", "1", "dup line B"]);
let out = run(&home, &["strike", "1", "nonexistent"]);
assert!(!out.status.success());
let out = ok(&home, &["log", "1", "silent line", "--quiet"]);
assert!(out.is_empty(), "{out}");
let out = Command::new(env!("CARGO_BIN_EXE_tasks"))
.env("TASKS_CLI_HOME", &home)
.args(["log", "1", "--stdin", "--quiet"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.as_mut().unwrap().write_all(b"from stdin\n").unwrap();
child.wait_with_output()
})
.unwrap();
assert!(out.status.success());
let out = ok(&home, &["show", "1"]);
assert!(out.contains("from stdin"), "{out}");
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn migration_from_yaml_steps() {
let home = std::env::temp_dir().join(format!("tasks-e2e-migrate-{}", 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()]);
std::fs::write(
lib_path.join("old-task-abc12345.md"),
"---\nid: abc12345-0000-0000-0000-000000000000\nseq: 1\ntitle: Legacy task\ncreated_at: 2026-01-01T00:00:00Z\nsteps:\n - id: s1\n title: Design\n status: done\n - id: s2\n title: Implement\n status: todo\n---\n\nOriginal body.\n",
)
.unwrap();
let out = ok(&home, &["show", "1"]);
assert!(out.contains("steps: 1/2"), "{out}");
assert!(out.contains("- [x] Design"), "{out}");
assert!(out.contains("- [ ] Implement"), "{out}");
let out = ok(&home, &["step", "done", "1", "s2"]);
assert!(out.contains("progress: 2/2"), "{out}");
let content = std::fs::read_to_string(lib_path.join("old-task-abc12345.md")).unwrap();
assert!(!content.contains("steps:"), "YAML steps removed: {content}");
assert!(content.contains("- [x] Design"));
assert!(content.contains("- [x] Implement"));
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn validation_friendly_errors() {
let home = std::env::temp_dir().join(format!("tasks-e2e-valid-{}", 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()]);
std::fs::write(
lib_path.join("bad-status.md"),
"---\nid: 00000000-0000-0000-0000-000000000001\nseq: 1\ntitle: T\nstatus: bogus\ncreated_at: 2026-01-01T00:00:00Z\n---\n",
)
.unwrap();
std::fs::write(
lib_path.join("empty-title.md"),
"---\nid: 00000000-0000-0000-0000-000000000002\nseq: 2\ntitle: \" \"\ncreated_at: 2026-01-01T00:00:00Z\n---\n",
)
.unwrap();
let out = run(&home, &["list"]);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("2 non-task file(s)"), "{stderr}");
assert!(stderr.contains("tasks adopt"), "{stderr}");
let out = ok(&home, &["adopt", "--dry-run"]);
assert!(out.contains("would adopt"), "{out}");
std::fs::remove_dir_all(&home).unwrap();
}
fn rule_file(lib: &Path) -> std::path::PathBuf {
std::fs::read_dir(lib.join(".recurring"))
.expect("missing .recurring dir")
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|x| x == "md"))
.expect("no rule file")
}
fn rewind_next_run(path: &Path, to: chrono::NaiveDate) {
let content = std::fs::read_to_string(path).unwrap();
let rewritten: String = content
.lines()
.map(|line| {
if line.starts_with("next_run:") {
format!("next_run: {to}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, format!("{rewritten}\n")).unwrap();
}
#[test]
fn recurring_rules_generate_tasks_lazily() {
let home = std::env::temp_dir().join(format!("tasks-e2e-recur-{}", 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()]);
let today = chrono::Local::now().date_naive();
let out = ok(&home, &["recur", "add", "每日站会", "--rule", "daily", "--tag", "daily"]);
assert!(out.contains("created rule"), "{out}");
assert!(out.contains(&format!("next: {today}")), "{out}");
let out = ok(&home, &["recur", "list"]);
assert!(out.contains("daily") && out.contains("每日站会"), "{out}");
let out = run(&home, &["list"]);
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("generated 1 recurring task(s)"), "{stderr}");
assert!(String::from_utf8_lossy(&out.stdout).contains("每日站会"));
let out = ok(&home, &["show", "1"]);
assert!(out.contains(&format!("occurs: {today}")), "{out}");
assert!(out.contains("daily"), "{out}");
let out = run(&home, &["list"]);
assert!(!String::from_utf8_lossy(&out.stderr).contains("generated"));
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("no rules are due"), "{out}");
let path = rule_file(&lib_path);
rewind_next_run(&path, today - chrono::Duration::days(10));
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("created task #2"), "{out}");
assert!(out.contains(&format!("for {today}")), "{out}");
assert!(out.contains("generated 1 task(s)"), "{out}");
rewind_next_run(&path, today);
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("created task #3"), "{out}");
ok(&home, &["recur", "pause", "每日"]);
rewind_next_run(&path, today);
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("no rules are due"), "{out}");
let out = ok(&home, &["recur", "list"]);
assert!(out.contains("paused"), "{out}");
ok(&home, &["recur", "resume", "每日"]);
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("created task #4"), "{out}");
let out = ok(&home, &["recur", "show", "每日"]);
assert!(out.contains("generated: 4"), "{out}");
assert!(out.contains("rule: daily"), "{out}");
ok(&home, &["recur", "remove", "每日", "--force"]);
let out = ok(&home, &["recur", "list"]);
assert!(out.contains("no rules"), "{out}");
let out = ok(&home, &["list"]);
assert!(out.contains("每日站会"), "{out}");
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn recurring_rule_bounds_and_bad_syntax() {
let home = std::env::temp_dir().join(format!("tasks-e2e-recur-bounds-{}", 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()]);
let today = chrono::Local::now().date_naive();
let out = run(&home, &["recur", "add", "x", "--rule", "0 9 * * 1"]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("invalid recurrence rule"), "{stderr}");
assert!(stderr.contains("weekly:mon,thu"), "{stderr}");
let out = run(&home, &[
"recur", "add", "y", "--rule", "daily", "--start", "2026-08-01", "--until", "2026-07-01",
]);
assert!(!out.status.success());
ok(&home, &["recur", "add", "打卡", "--rule", "daily", "--max-count", "1"]);
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("created task #1"), "{out}");
let path = rule_file(&lib_path);
rewind_next_run(&path, today);
let out = ok(&home, &["recur", "tick"]);
assert!(out.contains("no rules are due"), "{out}");
let out = ok(&home, &["recur", "list"]);
assert!(out.contains("finished") && out.contains("1/1"), "{out}");
ok(&home, &["recur", "add", "周报", "--rule", "weekly:mon", "--start", "2026-08-01"]);
let out = ok(&home, &["recur", "show", "周报"]);
assert!(out.contains("next: 2026-08-03"), "{out}");
assert!(out.contains("start: 2026-08-01"), "{out}");
let out = run(&home, &["list"]);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(!stderr.contains("non-task file"), "{stderr}");
assert!(!String::from_utf8_lossy(&out.stdout).contains("周报"), "rules are not tasks");
std::fs::remove_dir_all(&home).unwrap();
}