mod common;
use common::{hyalo_no_hints, write_md};
use std::fs;
const LINE_INCOMPLETE: usize = 5;
const LINE_COMPLETE: usize = 6;
const LINE_CUSTOM_STATUS: usize = 7;
const LINE_IN_CODE_BLOCK: usize = 10;
const LINE_HEADING: usize = 4;
fn setup_task_file(tmp: &tempfile::TempDir) {
let content = "---\ntitle: Test\n---\n# Tasks\n- [ ] First task\n- [x] Second task\n- [/] Third task\n\n```code\n- [ ] Not a real task\n```\n";
write_md(tmp.path(), "tasks.md", content);
}
fn run_task_read(
tmp: &tempfile::TempDir,
file: &str,
line: usize,
) -> (std::process::ExitStatus, String, String) {
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args(["task", "read", "--file", file, "--line", &line.to_string()]);
let output = cmd.output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status, stdout, stderr)
}
fn run_task_read_json(
tmp: &tempfile::TempDir,
file: &str,
line: usize,
) -> (std::process::ExitStatus, serde_json::Value, String) {
let (status, stdout, stderr) = run_task_read(tmp, file, line);
let json: serde_json::Value = if status.success() {
let envelope: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout: {stdout}\nstderr: {stderr}"));
envelope["results"].clone()
} else {
serde_json::Value::Null
};
(status, json, stderr)
}
fn run_task_toggle(
tmp: &tempfile::TempDir,
file: &str,
line: usize,
) -> (std::process::ExitStatus, serde_json::Value, String) {
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args([
"task",
"toggle",
"--file",
file,
"--line",
&line.to_string(),
]);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let json: serde_json::Value = if output.status.success() {
let envelope: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
let stdout = String::from_utf8_lossy(&output.stdout);
panic!("invalid JSON: {e}\nstdout: {stdout}\nstderr: {stderr}")
});
envelope["results"].clone()
} else {
serde_json::Value::Null
};
(output.status, json, stderr)
}
fn run_task_set_status(
tmp: &tempfile::TempDir,
file: &str,
line: usize,
status_char: &str,
) -> (std::process::ExitStatus, serde_json::Value, String) {
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args([
"task",
"set",
"--file",
file,
"--line",
&line.to_string(),
"--status",
status_char,
]);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let json: serde_json::Value = if output.status.success() {
let envelope: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
let stdout = String::from_utf8_lossy(&output.stdout);
panic!("invalid JSON: {e}\nstdout: {stdout}\nstderr: {stderr}")
});
envelope["results"].clone()
} else {
serde_json::Value::Null
};
(output.status, json, stderr)
}
#[test]
fn task_read_incomplete_task_returns_correct_json() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_read_json(&tmp, "tasks.md", LINE_INCOMPLETE);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["file"], "tasks.md");
assert_eq!(json["line"], LINE_INCOMPLETE);
assert_eq!(json["status"], " ");
assert_eq!(json["text"], "First task");
assert_eq!(json["done"], false);
}
#[test]
fn task_read_complete_task_done_true() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_read_json(&tmp, "tasks.md", LINE_COMPLETE);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], "x");
assert_eq!(json["done"], true);
assert_eq!(json["text"], "Second task");
}
#[test]
fn task_read_custom_status_char() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_read_json(&tmp, "tasks.md", LINE_CUSTOM_STATUS);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], "/");
assert_eq!(json["done"], false);
assert_eq!(json["text"], "Third task");
}
#[test]
fn task_read_non_task_line_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, _stdout, _stderr) = run_task_read(&tmp, "tasks.md", LINE_HEADING);
assert!(!status.success());
assert_eq!(status.code(), Some(1));
}
#[test]
fn task_read_nonexistent_file_exits_1() {
let tmp = tempfile::tempdir().unwrap();
let (status, _stdout, _stderr) = run_task_read(&tmp, "does_not_exist.md", 1);
assert!(!status.success());
assert_eq!(status.code(), Some(1));
}
#[test]
fn task_read_inside_code_block_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, _stdout, _stderr) = run_task_read(&tmp, "tasks.md", LINE_IN_CODE_BLOCK);
assert!(!status.success());
assert_eq!(status.code(), Some(1));
}
#[test]
fn task_toggle_incomplete_becomes_complete() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_toggle(&tmp, "tasks.md", LINE_INCOMPLETE);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], "x");
assert_eq!(json["done"], true);
}
#[test]
fn task_toggle_incomplete_modifies_file_on_disk() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, _json, stderr) = run_task_toggle(&tmp, "tasks.md", LINE_INCOMPLETE);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("tasks.md")).unwrap();
assert!(
content.contains("- [x] First task"),
"expected '- [x] First task' in file, got:\n{content}"
);
}
#[test]
fn task_toggle_complete_becomes_incomplete() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_toggle(&tmp, "tasks.md", LINE_COMPLETE);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], " ");
assert_eq!(json["done"], false);
}
#[test]
fn task_toggle_complete_modifies_file_on_disk() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, _json, stderr) = run_task_toggle(&tmp, "tasks.md", LINE_COMPLETE);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("tasks.md")).unwrap();
assert!(
content.contains("- [ ] Second task"),
"expected '- [ ] Second task' in file after toggle, got:\n{content}"
);
}
#[test]
fn task_toggle_non_task_line_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args([
"task",
"toggle",
"--file",
"tasks.md",
"--line",
&LINE_HEADING.to_string(),
]);
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn task_set_status_slash_on_incomplete_task() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_set_status(&tmp, "tasks.md", LINE_INCOMPLETE, "/");
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], "/");
assert_eq!(json["done"], false);
}
#[test]
fn task_set_status_question_mark_on_complete_task() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_set_status(&tmp, "tasks.md", LINE_COMPLETE, "?");
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], "?");
assert_eq!(json["done"], false);
}
#[test]
fn task_set_status_modifies_file_on_disk() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, _json, stderr) = run_task_set_status(&tmp, "tasks.md", LINE_INCOMPLETE, "?");
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("tasks.md")).unwrap();
assert!(
content.contains("- [?] First task"),
"expected '- [?] First task' in file, got:\n{content}"
);
}
#[test]
fn task_set_status_x_sets_done_true() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, json, stderr) = run_task_set_status(&tmp, "tasks.md", LINE_INCOMPLETE, "x");
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["status"], "x");
assert_eq!(json["done"], true);
}
#[test]
fn task_set_status_non_task_line_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args([
"task",
"set",
"--file",
"tasks.md",
"--line",
&LINE_HEADING.to_string(),
"--status",
"x",
]);
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn task_set_status_multi_char_status_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args([
"task",
"set",
"--file",
"tasks.md",
"--line",
&LINE_INCOMPLETE.to_string(),
"--status",
"ab",
]);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
assert!(
stderr.contains("single character"),
"expected 'single character' in stderr, got: {stderr}"
);
}
#[test]
fn task_set_status_empty_string_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args([
"task",
"set",
"--file",
"tasks.md",
"--line",
&LINE_INCOMPLETE.to_string(),
"--status",
"",
]);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
assert!(
stderr.contains("single character"),
"expected 'single character' in stderr, got: {stderr}"
);
}
fn setup_bulk_file(tmp: &tempfile::TempDir) {
let content = "---\ntitle: Bulk Test\n---\n# Tasks\n- [ ] Task A\n- [ ] Task B\n\n## Acceptance criteria\n- [ ] AC one\n- [ ] AC two\n- [x] AC three\n\n## Other section\n- [ ] Other task\n";
write_md(tmp.path(), "bulk.md", content);
}
fn run_task_cmd(
tmp: &tempfile::TempDir,
args: &[&str],
) -> (std::process::ExitStatus, String, String) {
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args(args);
let output = cmd.output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status, stdout, stderr)
}
fn run_task_cmd_json(
tmp: &tempfile::TempDir,
args: &[&str],
) -> (std::process::ExitStatus, serde_json::Value, String) {
let (status, stdout, stderr) = run_task_cmd(tmp, args);
let json: serde_json::Value = if status.success() {
serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout: {stdout}\nstderr: {stderr}"))
} else {
serde_json::Value::Null
};
(status, json, stderr)
}
#[test]
fn task_toggle_multiple_lines() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&[
"task", "toggle", "--file", "bulk.md", "--line", "5", "--line", "6",
],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 2);
assert_eq!(results[0]["status"], "x");
assert_eq!(results[0]["text"], "Task A");
assert_eq!(results[1]["status"], "x");
assert_eq!(results[1]["text"], "Task B");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [x] Task A"));
assert!(content.contains("- [x] Task B"));
}
#[test]
fn task_read_multiple_lines() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&[
"task", "read", "--file", "bulk.md", "--line", "9", "--line", "10",
],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 2);
assert_eq!(results[0]["text"], "AC one");
assert_eq!(results[1]["text"], "AC two");
}
#[test]
fn task_toggle_comma_separated_lines() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&["task", "toggle", "--file", "bulk.md", "--line", "5,6"],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 2);
assert_eq!(results[0]["status"], "x");
assert_eq!(results[0]["text"], "Task A");
assert_eq!(results[1]["status"], "x");
assert_eq!(results[1]["text"], "Task B");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [x] Task A"));
assert!(content.contains("- [x] Task B"));
}
#[test]
fn task_set_status_multiple_lines() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&[
"task", "set", "--file", "bulk.md", "--line", "5", "--line", "6", "--status", "?",
],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 2);
assert_eq!(results[0]["status"], "?");
assert_eq!(results[1]["status"], "?");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [?] Task A"));
assert!(content.contains("- [?] Task B"));
}
#[test]
fn task_toggle_section() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&[
"task",
"toggle",
"--file",
"bulk.md",
"--section",
"Acceptance criteria",
],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 3, "section has 3 tasks");
assert_eq!(results[0]["status"], "x");
assert_eq!(results[1]["status"], "x");
assert_eq!(results[2]["status"], " ");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [x] AC one"));
assert!(content.contains("- [x] AC two"));
assert!(content.contains("- [ ] AC three"));
assert!(content.contains("- [ ] Other task"));
}
#[test]
fn task_read_section() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&[
"task",
"read",
"--file",
"bulk.md",
"--section",
"Acceptance criteria",
],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 3);
assert_eq!(results[0]["text"], "AC one");
assert_eq!(results[1]["text"], "AC two");
assert_eq!(results[2]["text"], "AC three");
}
#[test]
fn task_set_status_section() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, _json, stderr) = run_task_cmd_json(
&tmp,
&[
"task",
"set",
"--file",
"bulk.md",
"--section",
"Other section",
"--status",
"-",
],
);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [-] Other task"));
}
#[test]
fn task_section_substring_match() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) = run_task_cmd_json(
&tmp,
&[
"task",
"read",
"--file",
"bulk.md",
"--section",
"Acceptance",
],
);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 3);
}
#[test]
fn task_section_no_match_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, _stdout, _stderr) = run_task_cmd(
&tmp,
&[
"task",
"read",
"--file",
"bulk.md",
"--section",
"Nonexistent",
],
);
assert!(!status.success());
}
#[test]
fn task_toggle_all() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) =
run_task_cmd_json(&tmp, &["task", "toggle", "--file", "bulk.md", "--all"]);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 6, "expected 6 tasks in file");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [x] Task A"));
assert!(content.contains("- [x] Task B"));
assert!(content.contains("- [x] AC one"));
assert!(content.contains("- [x] AC two"));
assert!(content.contains("- [ ] AC three"));
assert!(content.contains("- [x] Other task"));
}
#[test]
fn task_read_all() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, json, stderr) =
run_task_cmd_json(&tmp, &["task", "read", "--file", "bulk.md", "--all"]);
assert!(status.success(), "stderr: {stderr}");
let results = json["results"].as_array().expect("expected results array");
assert_eq!(results.len(), 6);
}
#[test]
fn task_set_status_all() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, _json, stderr) = run_task_cmd_json(
&tmp,
&["task", "set", "--file", "bulk.md", "--all", "--status", "x"],
);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("bulk.md")).unwrap();
assert!(content.contains("- [x] Task A"));
assert!(content.contains("- [x] Task B"));
assert!(content.contains("- [x] AC one"));
assert!(content.contains("- [x] AC two"));
assert!(content.contains("- [x] AC three"));
assert!(content.contains("- [x] Other task"));
}
#[test]
fn task_no_selector_exits_2() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, _stdout, _stderr) = run_task_cmd(&tmp, &["task", "toggle", "--file", "bulk.md"]);
assert!(!status.success());
}
#[test]
fn task_conflicting_selectors_exits_2() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, _stdout, _stderr) = run_task_cmd(
&tmp,
&[
"task", "toggle", "--file", "bulk.md", "--line", "5", "--all",
],
);
assert!(!status.success());
}
#[test]
fn task_all_on_empty_file_exits_1() {
let tmp = tempfile::tempdir().unwrap();
write_md(
tmp.path(),
"empty.md",
"---\ntitle: Empty\n---\nNo tasks here.\n",
);
let (status, _stdout, _stderr) =
run_task_cmd(&tmp, &["task", "toggle", "--file", "empty.md", "--all"]);
assert!(!status.success());
}
#[test]
fn task_single_line_returns_flat_object() {
let tmp = tempfile::tempdir().unwrap();
setup_bulk_file(&tmp);
let (status, stdout, stderr) =
run_task_cmd(&tmp, &["task", "read", "--file", "bulk.md", "--line", "5"]);
assert!(status.success(), "stderr: {stderr}");
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(
json.get("results").is_some(),
"single-line result should be in envelope with results"
);
assert!(
!json["results"].is_array(),
"single-line result should be a flat object, not an array"
);
assert_eq!(json["results"]["text"], "Task A");
}
#[test]
fn task_read_line_zero_exits_1() {
let tmp = tempfile::tempdir().unwrap();
setup_task_file(&tmp);
let (status, _stdout, stderr) = run_task_read(&tmp, "tasks.md", 0);
assert!(!status.success(), "expected failure for line 0");
assert_eq!(status.code(), Some(1));
assert!(
stderr.contains("not a task"),
"expected 'not a task' error, got: {stderr}"
);
}
#[test]
fn task_read_json_has_all_required_fields() {
let tmp = tempfile::tempdir().unwrap();
write_md(tmp.path(), "note.md", "- [ ] My task\n");
let (status, json, stderr) = run_task_read_json(&tmp, "note.md", 1);
assert!(status.success(), "stderr: {stderr}");
assert!(json["file"].is_string(), "missing file field");
assert!(json["line"].is_number(), "missing line field");
assert!(json["status"].is_string(), "missing status field");
assert!(json["text"].is_string(), "missing text field");
assert!(json["done"].is_boolean(), "missing done field");
}
#[test]
fn task_toggle_json_has_all_required_fields() {
let tmp = tempfile::tempdir().unwrap();
write_md(tmp.path(), "note.md", "- [ ] My task\n");
let (status, json, stderr) = run_task_toggle(&tmp, "note.md", 1);
assert!(status.success(), "stderr: {stderr}");
assert!(json["file"].is_string(), "missing file field");
assert!(json["line"].is_number(), "missing line field");
assert!(json["status"].is_string(), "missing status field");
assert!(json["text"].is_string(), "missing text field");
assert!(json["done"].is_boolean(), "missing done field");
}
#[test]
fn task_set_status_json_has_all_required_fields() {
let tmp = tempfile::tempdir().unwrap();
write_md(tmp.path(), "note.md", "- [ ] My task\n");
let (status, json, stderr) = run_task_set_status(&tmp, "note.md", 1, "/");
assert!(status.success(), "stderr: {stderr}");
assert!(json["file"].is_string(), "missing file field");
assert!(json["line"].is_number(), "missing line field");
assert!(json["status"].is_string(), "missing status field");
assert!(json["text"].is_string(), "missing text field");
assert!(json["done"].is_boolean(), "missing done field");
}