use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(name: &str) -> TestDir {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"xtodo-test-{}-{}-{}",
std::process::id(),
name,
unique
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create temp dir");
TestDir { path }
}
fn write(&self, name: &str, content: &str) -> PathBuf {
let path = self.path.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create fixture directory");
}
fs::write(&path, content).expect("write fixture");
path
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.path.join(name)).expect("read fixture")
}
fn run(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_xtodo"))
.args(args)
.current_dir(&self.path)
.env("XTODO_TODAY", "2024-03-10")
.env_remove("XTODO_UTC_OFFSET")
.output()
.expect("run xtodo")
}
fn run_with_input(&self, args: &[&str], input: &str) -> Output {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(env!("CARGO_BIN_EXE_xtodo"))
.args(args)
.current_dir(&self.path)
.env("XTODO_TODAY", "2024-03-10")
.env_remove("XTODO_UTC_OFFSET")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn xtodo");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(input.as_bytes())
.expect("write answers");
child.wait_with_output().expect("wait for xtodo")
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
const SAMPLE: &str = "\
Work
[ ] !! Fix the parser -> 2024-03-01 #bug
[ ] Write tests
with more detail
[@] Review the pull request #review
[x] Old finished task
Personal
[ ] Buy milk #shopping
[?] Refactor the renderer?
[ ] Ship it -> 2024-12-24 #release
";
#[test]
fn show_lists_tasks_with_ids_and_sections() {
let dir = TestDir::new("show");
dir.write("todo.xit", SAMPLE);
let output = dir.run(&["show", "--no-due"]);
let text = stdout(&output);
assert!(text.contains("▌ Work"), "{}", text);
assert!(text.contains("#001 [ ] !! Fix the parser -> 2024-03-01 #bug"));
assert!(text.contains("with more detail"));
let checked = text.find("Old finished task").unwrap();
let ongoing = text.find("Review the pull request").unwrap();
assert!(checked > ongoing);
}
#[test]
fn show_filters_and_sorting() {
let dir = TestDir::new("filters");
dir.write("todo.xit", SAMPLE);
let text = stdout(&dir.run(&["show", "--active", "--no-due"]));
assert!(!text.contains("Old finished task"));
let text = stdout(&dir.run(&["show", "-t", "bug", "--no-due"]));
assert!(text.contains("Fix the parser"));
assert!(!text.contains("Buy milk"));
let text = stdout(&dir.run(&["show", "--overdue", "--no-due"]));
assert!(text.contains("Fix the parser"));
assert!(!text.contains("Ship it"));
let text = stdout(&dir.run(&["show", "--due-by", "2024-06-01", "--no-due"]));
assert!(text.contains("Fix the parser"));
assert!(!text.contains("Ship it"));
let text = stdout(&dir.run(&["show", "--sort", "priority", "--order", "desc", "--no-due"]));
let high = text.find("Fix the parser").unwrap();
let plain = text.find("Write tests").unwrap();
assert!(high < plain, "{}", text);
let text = stdout(&dir.run(&["show", "--sort", "due_date", "--no-due"]));
let early = text.find("Fix the parser").unwrap();
let late = text.find("Ship it").unwrap();
let undated = text.find("Write tests").unwrap();
assert!(early < late && late < undated, "{}", text);
let text = stdout(&dir.run(&["show", "--count"]));
assert_eq!(text.trim(), "7 tasks found.");
}
#[test]
fn show_json_is_machine_readable() {
let dir = TestDir::new("json");
dir.write("todo.xit", SAMPLE);
let text = stdout(&dir.run(&["--json", "show"]));
assert!(text.contains("\"status\": \"open\""));
assert!(text.contains("\"date\": \"2024-03-01\""));
assert!(text.contains("\"days_left\": -9"));
assert!(text.contains("\"name\": \"bug\""));
}
#[test]
fn add_preserves_the_rest_of_the_file() {
let dir = TestDir::new("add");
dir.write(
"todo.xit",
"# a comment that must survive\n\nWork\n[ ] existing\n",
);
let output = dir.run(&["add", "new task -> 2024-05-01 #new", "-p", "2"]);
assert!(output.status.success(), "{}", stderr(&output));
let text = dir.read("todo.xit");
assert!(text.starts_with("# a comment that must survive\n\nWork\n[ ] existing\n"));
assert!(text.contains("[ ] !! new task -> 2024-05-01 #new\n"));
assert!(stdout(&output).contains("added task #002"));
}
#[test]
fn add_resolves_date_expressions_and_tags() {
let dir = TestDir::new("add-dates");
dir.write("todo.xit", "");
let output = dir.run(&["add", "pay rent", "--due", "tomorrow", "-t", "home"]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(dir
.read("todo.xit")
.contains("[ ] pay rent #home -> 2024-03-11\n"));
}
#[test]
fn add_resolves_inline_date_expressions() {
let dir = TestDir::new("add-inline");
dir.write("todo.xit", "");
assert!(dir
.run(&["add", "write docs -> tomorrow #docs"])
.status
.success());
assert_eq!(dir.read("todo.xit"), "[ ] write docs #docs -> 2024-03-11\n");
assert!(dir.run(&["add", "spec style -> 2024-12"]).status.success());
assert_eq!(
dir.read("todo.xit"),
"[ ] write docs #docs -> 2024-03-11\n[ ] spec style -> 2024-12\n"
);
}
#[test]
fn mark_changes_only_the_checkbox() {
let dir = TestDir::new("mark");
dir.write("todo.xit", "[ ] keep the spacing\n[ ] second\n");
let output = dir.run(&["mark", "1", "--done"]);
assert!(output.status.success());
assert_eq!(
dir.read("todo.xit"),
"[x] keep the spacing\n[ ] second\n"
);
let output = dir.run(&["mark", "2", "--ongoing"]);
assert!(output.status.success());
assert!(dir.read("todo.xit").contains("[@] second"));
}
#[test]
fn rm_marks_obsolete_and_force_deletes() {
let dir = TestDir::new("rm");
dir.write("todo.xit", "Work\n[ ] one\n[ ] two\n");
let output = dir.run(&["rm", "1"]);
assert!(output.status.success());
assert_eq!(dir.read("todo.xit"), "Work\n[~] one\n[ ] two\n");
assert!(stdout(&output).contains("--force"));
let output = dir.run(&["rm", "1", "--force"]);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todo.xit"), "Work\n[ ] two\n");
}
#[test]
fn tags_and_priority_can_be_changed() {
let dir = TestDir::new("tags");
dir.write("todo.xit", "[ ] task #old\n");
assert!(dir.run(&["untag", "1", "-t", "old"]).status.success());
assert_eq!(dir.read("todo.xit"), "[ ] task\n");
assert!(dir
.run(&["tag", "1", "-t", "urgent", "-t", "when=next week"])
.status
.success());
assert_eq!(
dir.read("todo.xit"),
"[ ] task #urgent #when=\"next week\"\n"
);
assert!(dir.run(&["prio", "1", "-p", "3"]).status.success());
assert_eq!(
dir.read("todo.xit"),
"[ ] !!! task #urgent #when=\"next week\"\n"
);
assert!(dir.run(&["prio", "1", "-p", "0"]).status.success());
assert_eq!(
dir.read("todo.xit"),
"[ ] task #urgent #when=\"next week\"\n"
);
}
#[test]
fn reschedule_replaces_the_due_date() {
let dir = TestDir::new("reschedule");
dir.write("todo.xit", "[ ] task -> 2024-01-01 and more text\n");
assert!(dir
.run(&["reschedule", "1", "-n", "tomorrow"])
.status
.success());
assert_eq!(
dir.read("todo.xit"),
"[ ] task -> 2024-03-11 and more text\n"
);
assert!(dir.run(&["reschedule", "1", "-n", "none"]).status.success());
assert_eq!(dir.read("todo.xit"), "[ ] task and more text\n");
}
#[test]
fn edit_replaces_the_description() {
let dir = TestDir::new("edit");
dir.write("todo.xit", "Work\n[ ] !! old text\n old continuation\n");
let output = dir.run(&["edit", "1", "brand new text"]);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todo.xit"), "Work\n[ ] !! brand new text\n");
}
#[test]
fn move_between_files() {
let dir = TestDir::new("move");
dir.write("todo.xit", "Work\n[ ] one\n[ ] two\n");
dir.write("archive.xit", "");
let output = dir.run(&["move", "2", "--target-file", "archive.xit", "-g", "Later"]);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todo.xit"), "Work\n[ ] one\n");
assert_eq!(dir.read("archive.xit"), "Later\n[ ] two\n");
}
#[test]
fn recur_creates_follow_up_tasks() {
let dir = TestDir::new("recur");
dir.write("todo.xit", "[ ] weekly review -> 2024-03-01\n");
let output = dir.run(&["recur", "1", "--interval", "1w", "-c", "2"]);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(
dir.read("todo.xit"),
"[ ] weekly review -> 2024-03-01\n[ ] weekly review -> 2024-03-08\n[ ] weekly review -> 2024-03-15\n"
);
}
#[test]
fn validate_reports_problems_and_exits_non_zero() {
let dir = TestDir::new("validate");
dir.write(
"todo.xit",
"Title\n[a] bad checkbox\n[ ]nospace\n[ ] bad date -> 2022-13-45\n[ ] fine\n",
);
let output = dir.run(&["validate"]);
let text = stdout(&output);
assert!(text.contains("todo.xit:2: error"));
assert!(text.contains("todo.xit:3: error"));
assert!(text.contains("not a valid date"));
assert_eq!(output.status.code(), Some(1));
let dir = TestDir::new("validate-ok");
dir.write("todo.xit", "Work\n[ ] fine -> 2024-01-01 #tag\n");
let output = dir.run(&["validate"]);
assert_eq!(output.status.code(), Some(0), "{}", stdout(&output));
}
#[test]
fn format_normalises_and_check_reports() {
let dir = TestDir::new("format");
dir.write("todo.xit", "[x] messy \n continuation \n");
let output = dir.run(&["format", "--check"]);
assert_eq!(output.status.code(), Some(1));
assert!(dir.run(&["format"]).status.success());
assert_eq!(dir.read("todo.xit"), "[x] messy\n continuation\n");
assert_eq!(dir.run(&["format", "--check"]).status.code(), Some(0));
}
#[test]
fn multiple_files_are_numbered_globally() {
let dir = TestDir::new("multi");
dir.write("a.xit", "[ ] first\n");
dir.write("b.xit", "[ ] second\n");
let text = stdout(&dir.run(&["show", "--no-due"]));
assert!(text.contains("#001 [ ] first"), "{}", text);
assert!(text.contains("#002 [ ] second"), "{}", text);
assert!(dir.run(&["mark", "2", "--done"]).status.success());
assert_eq!(dir.read("a.xit"), "[ ] first\n");
assert_eq!(dir.read("b.xit"), "[x] second\n");
}
#[test]
fn unknown_lines_survive_every_mutation() {
let dir = TestDir::new("preserve");
dir.write(
"todo.xit",
"# heading comment\n\nWork\n[ ] one\n[ ] two\n\nsome trailing prose\n",
);
assert!(dir.run(&["mark", "1", "--done"]).status.success());
assert!(dir.run(&["tag", "2", "-t", "x"]).status.success());
let text = dir.read("todo.xit");
assert_eq!(
text,
"# heading comment\n\nWork\n[x] one\n[ ] two #x\n\nsome trailing prose\n"
);
}
#[test]
fn dry_run_does_not_write() {
let dir = TestDir::new("dry-run");
dir.write("todo.xit", "[ ] one\n");
let output = dir.run(&["--dry-run", "mark", "1", "--done"]);
assert!(output.status.success());
assert!(stdout(&output).contains("dry run"));
assert_eq!(dir.read("todo.xit"), "[ ] one\n");
}
#[test]
fn new_creates_a_file() {
let dir = TestDir::new("new");
let output = dir.run(&["new", "tasks.xit", "--template"]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(dir.read("tasks.xit").starts_with("TODO\n"));
assert!(!dir.run(&["new", "tasks.xit"]).status.success());
}
#[test]
fn unknown_command_and_bad_numbers_fail_cleanly() {
let dir = TestDir::new("errors");
dir.write("todo.xit", "[ ] one\n");
let output = dir.run(&["frobnicate"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("unknown command"));
let output = dir.run(&["mark", "99", "--done"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("99"));
let output = dir.run(&["mark", "1"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("status"));
}
#[test]
fn default_location_prefers_the_todos_directory() {
let dir = TestDir::new("todos-default");
dir.write("todo.xit", "[ ] root task\n");
dir.write("todos/todo.xit", "[ ] todos task\n");
let text = stdout(&dir.run(&["show", "--no-due"]));
assert!(text.contains("todos task"), "{}", text);
assert!(!text.contains("root task"), "{}", text);
let text = stdout(&dir.run(&["-f", "todo.xit", "show", "--no-due"]));
assert!(text.contains("root task"), "{}", text);
assert!(!text.contains("todos task"), "{}", text);
}
#[test]
fn default_location_falls_back_to_the_working_directory() {
let dir = TestDir::new("todos-fallback");
dir.write("todo.xit", "[ ] root task\n");
let text = stdout(&dir.run(&["show", "--no-due"]));
assert!(text.contains("root task"), "{}", text);
dir.write("todos/.keep", "");
let text = stdout(&dir.run(&["show", "--no-due"]));
assert!(text.contains("root task"), "{}", text);
}
#[test]
fn add_and_new_create_the_default_file_under_todos() {
let dir = TestDir::new("todos-add");
let output = dir.run(&["add", "fresh task"]);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todos/todo.xit"), "[ ] fresh task\n");
let text = stdout(&dir.run(&["show", "--no-due"]));
assert!(text.contains("fresh task"), "{}", text);
let other = TestDir::new("todos-new");
assert!(other.run(&["new"]).status.success());
assert!(other.path.join("todos/todo.xit").exists());
}
#[test]
fn directory_option_directory_does_not_swallow_a_date() {
let dir = TestDir::new("todos-guard");
let output = dir.run(&["-d", "tomorrow", "add", "write the report"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("--due"), "{}", stderr(&output));
assert!(!dir.path.join("tomorrow").exists());
let output = dir.run(&["add", "write the report", "--due", "tomorrow"]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(
dir.read("todos/todo.xit").contains("-> 2024-03-11"),
"{}",
dir.read("todos/todo.xit")
);
}
#[test]
fn markdown_files_are_scanned() {
let dir = TestDir::new("markdown");
dir.write("notes.md", "# Notes\n\n[ ] md task #md\n");
let text = stdout(&dir.run(&["show", "--no-due"]));
assert!(text.contains("md task #md"), "{}", text);
assert!(text.contains("Notes"));
}
#[test]
fn setting_a_value_that_is_already_set_says_nothing_changed() {
let dir = TestDir::new("noop");
dir.write("todo.xit", "[ ] task -> 2024-03-13\n");
let output = dir.run(&["reschedule", "1", "-n", "3d"]);
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("nothing changed for #001"), "{}", text);
assert!(text.contains("already due on 2024-03-13"), "{}", text);
assert_eq!(dir.read("todo.xit"), "[ ] task -> 2024-03-13\n");
let output = dir.run(&["mark", "1", "--open"]);
assert!(stdout(&output).contains("nothing changed for #001"));
let output = dir.run(&["prio", "1", "-p", "0"]);
assert!(stdout(&output).contains("nothing changed for #001"));
}
#[test]
fn mutations_echo_the_resulting_line() {
let dir = TestDir::new("echo");
dir.write("todo.xit", "Work\n[ ] task\n");
let output = dir.run(&["reschedule", "1", "-n", "2024-12-24"]);
let text = stdout(&output);
assert!(
text.contains("ok: #001 due date set to 2024-12-24"),
"{}",
text
);
assert!(text.contains("[ ] task -> 2024-12-24"), "{}", text);
let output = dir.run(&["mark", "1", "--done"]);
let text = stdout(&output);
assert!(text.contains("ok: #001 marked as checked"), "{}", text);
assert!(text.contains("[x] task -> 2024-12-24"), "{}", text);
let output = dir.run(&["--json", "mark", "1", "--done"]);
let text = stdout(&output);
assert!(text.contains("\"changed\": []"), "{}", text);
assert!(text.contains("\"unchanged\""), "{}", text);
}
#[test]
fn finished_tasks_are_moved_to_the_end_with_a_hint() {
let dir = TestDir::new("finished");
dir.write("todo.xit", "Work\n[ ] first\n[x] second -> 2024-01-01\n");
let output = dir.run(&["show"]);
let text = stdout(&output);
let first = text.find("first").unwrap();
let second = text.find("second").unwrap();
assert!(first < second, "{}", text);
assert!(
text.contains("1 finished task(s) are listed at the end (--active hides them)"),
"{}",
text
);
assert!(!text.contains("overdue"), "{}", text);
let text = stdout(&dir.run(&["show", "--active"]));
assert!(!text.contains("second"), "{}", text);
}
#[test]
fn interactive_mark_asks_for_status_and_tasks() {
let dir = TestDir::new("interactive-mark");
dir.write("todo.xit", "[ ] one\n[ ] two\n[ ] three\n");
let output = dir.run_with_input(&["--no-color", "mark", "-i"], "2\n1,3\n");
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stderr(&output).contains("New status?"),
"{}",
stderr(&output)
);
assert_eq!(dir.read("todo.xit"), "[x] one\n[ ] two\n[x] three\n");
}
#[test]
fn interactive_rm_can_delete_permanently() {
let dir = TestDir::new("interactive-rm");
dir.write("todo.xit", "Work\n[ ] one\n[ ] two\n");
let output = dir.run_with_input(&["--no-color", "rm", "-i"], "2\n2\n");
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todo.xit"), "Work\n[ ] one\n");
}
#[test]
fn interactive_reschedule_asks_for_date_and_tasks() {
let dir = TestDir::new("interactive-reschedule");
dir.write("todo.xit", "[ ] one\n[ ] two\n");
let output = dir.run_with_input(&["--no-color", "reschedule", "-i"], "next week\n1\n");
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todo.xit"), "[ ] one -> 2024-03-17\n[ ] two\n");
}
#[test]
fn interactive_show_asks_for_sections_and_tasks() {
let dir = TestDir::new("interactive-show");
dir.write(
"todo.xit",
"Work\n[ ] work item\n\nPersonal\n[ ] private item\n",
);
let output = dir.run_with_input(&["--no-color", "show", "-i", "--no-due"], "all\n2\n");
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("private item"), "{}", text);
assert!(!text.contains("work item"), "{}", text);
}
#[test]
fn interactive_mode_rejects_a_bad_menu_choice() {
let dir = TestDir::new("interactive-bad");
dir.write("todo.xit", "[ ] one\n");
let output = dir.run_with_input(&["--no-color", "mark", "-i"], "9\n");
assert!(!output.status.success());
assert!(
stderr(&output).contains("out of range"),
"{}",
stderr(&output)
);
}
#[test]
fn interactive_mode_needs_input() {
let dir = TestDir::new("interactive-eof");
dir.write("todo.xit", "[ ] one\n");
let output = dir.run_with_input(&["--no-color", "mark", "-i"], "");
assert!(!output.status.success());
assert!(
stderr(&output).contains("no input left"),
"{}",
stderr(&output)
);
}
#[test]
fn pomodoro_links_and_completes_tasks() {
let dir = TestDir::new("pomodoro");
dir.write("todo.xit", "[ ] focus task\n[ ] other task\n");
let output = dir.run_with_input(
&[
"--no-color",
"pomodoro",
"0.02",
"0",
"-c",
"1",
"-t",
"1",
"--complete",
"--no-sound",
],
"",
);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(dir.read("todo.xit"), "[x] focus task\n[ ] other task\n");
let text = stdout(&output);
assert!(text.contains("work session 1 of 1"), "{}", text);
assert!(text.contains("1 work session(s)"), "{}", text);
}
#[test]
fn pomodoro_quits_on_q_and_validates_arguments() {
let dir = TestDir::new("pomodoro-quit");
dir.write("todo.xit", "[ ] one\n");
let output = dir.run_with_input(&["--no-color", "pomodoro", "5", "1", "--no-sound"], "q");
assert!(output.status.success(), "{}", stderr(&output));
assert!(stdout(&output).contains("stopped"), "{}", stdout(&output));
let output = dir.run(&["pomodoro", "5", "1", "-c", "0"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("cycles"));
let output = dir.run(&["pomodoro", "0", "1"]);
assert!(!output.status.success());
assert!(stderr(&output).contains("greater than zero"));
}
#[test]
fn recur_flag_is_interval_not_interactive() {
let dir = TestDir::new("recur-flag");
dir.write("todo.xit", "[ ] weekly -> 2024-03-01\n");
let output = dir.run(&["recur", "1", "-I", "1w", "-c", "1"]);
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(
dir.read("todo.xit"),
"[ ] weekly -> 2024-03-01\n[ ] weekly -> 2024-03-08\n"
);
}
#[test]
fn reschedule_does_not_change_the_status() {
let dir = TestDir::new("status-kept");
dir.write("todo.xit", "[x] done task -> 2024-01-01\n");
assert!(dir
.run(&["reschedule", "1", "-n", "2024-12-24"])
.status
.success());
assert_eq!(dir.read("todo.xit"), "[x] done task -> 2024-12-24\n");
assert!(dir.run(&["mark", "1", "--open"]).status.success());
assert_eq!(dir.read("todo.xit"), "[ ] done task -> 2024-12-24\n");
}