use assert_cmd::Command;
use predicates::str::contains;
use std::fs;
use tempfile::tempdir;
fn bin() -> Command {
Command::cargo_bin("markdown-org-extract").expect("binary should build")
}
#[test]
fn shows_help_with_usage_section() {
bin()
.arg("--help")
.assert()
.success()
.stdout(contains("Usage:"))
.stdout(contains("--dir"))
.stdout(contains("--format"));
}
#[test]
fn rejects_nonexistent_dir() {
bin()
.args([
"--dir",
"/this/path/should/never/exist_xyz",
"--current-date",
"2025-12-05",
])
.assert()
.failure()
.stderr(contains("directory does not exist"));
}
#[test]
fn examples_directory_emits_json_with_relative_paths() {
let out = bin()
.args([
"--dir",
"examples",
"--format",
"json",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.starts_with("[") || stdout.starts_with("{"));
assert!(
!stdout.contains("/home/"),
"default output must not contain absolute paths: {stdout:.200}"
);
}
#[test]
fn absolute_paths_flag_emits_full_paths() {
let out = bin()
.args([
"--dir",
"examples",
"--absolute-paths",
"--format",
"json",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let needle = format!("examples{}", std::path::MAIN_SEPARATOR);
assert!(
stdout.contains(&needle),
"expected absolute path containing {needle:?} in stdout: {stdout:.200}"
);
}
#[test]
fn output_flag_writes_to_file() {
let dir = tempdir().unwrap();
let target = dir.path().join("out.json");
bin()
.args([
"--dir",
"examples",
"--format",
"json",
"--current-date",
"2025-12-05",
"--output",
])
.arg(&target)
.assert()
.success();
let content = fs::read_to_string(&target).unwrap();
assert!(!content.is_empty());
assert!(content.contains("\"date\""));
}
#[test]
fn output_flag_rejects_symlink() {
let dir = tempdir().unwrap();
let real = dir.path().join("real.json");
let link = dir.path().join("link.json");
fs::write(&real, "existing").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(unix)]
{
bin()
.args([
"--dir",
"examples",
"--format",
"json",
"--current-date",
"2025-12-05",
"--output",
])
.arg(&link)
.assert()
.failure()
.stderr(contains("symlink"));
}
}
#[test]
fn holidays_year_returns_json_array() {
let out = bin().args(["--holidays", "2026"]).output().expect("run");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.trim().starts_with('['));
assert!(stdout.contains("2026-01-01"));
}
#[test]
fn invalid_year_rejected() {
bin().args(["--holidays", "1800"]).assert().failure();
}
#[test]
fn double_star_glob_is_accepted() {
let out = bin()
.args([
"--dir",
"examples",
"--glob",
"**/*.md",
"--format",
"json",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn verbose_and_quiet_are_mutually_exclusive() {
bin()
.args([
"--dir",
"examples",
"-v",
"--quiet",
"--current-date",
"2025-12-05",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn no_color_flag_is_accepted() {
bin()
.args([
"--dir",
"examples",
"--no-color",
"--current-date",
"2025-12-05",
])
.assert()
.success();
}
#[test]
fn rejects_invalid_max_tasks() {
bin()
.args([
"--dir",
"examples",
"--max-tasks",
"0",
"--current-date",
"2025-12-05",
])
.assert()
.failure()
.stderr(contains("--max-tasks"));
}
#[test]
fn max_tasks_one_caps_output() {
let out = bin()
.args([
"--dir",
"examples",
"--format",
"json",
"--tasks",
"--max-tasks",
"1",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert!(
arr.len() <= 1,
"got {} tasks, expected at most 1",
arr.len()
);
}
#[test]
fn holidays_conflicts_with_scan_flags() {
bin()
.args(["--holidays", "2026", "--dir", "examples"])
.assert()
.failure()
.stderr(contains("cannot be used"));
bin()
.args(["--holidays", "2026", "--tasks"])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn tasks_conflicts_with_range_flags() {
bin()
.args([
"--tasks",
"--from",
"2026-01-01",
"--current-date",
"2026-01-15",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
bin()
.args([
"--tasks",
"--to",
"2026-01-31",
"--current-date",
"2026-01-15",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn rejects_malformed_glob() {
bin()
.args([
"--dir",
"examples",
"--glob",
"{md,",
"--current-date",
"2025-12-05",
])
.assert()
.failure()
.stderr(contains("invalid pattern"));
}
#[test]
fn agenda_tasks_mode_produces_flat_list() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"tasks",
"--format",
"json",
"--max-tasks",
"3",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("top-level array");
if let Some(first) = arr.first() {
let obj = first.as_object().expect("task object");
assert!(
obj.contains_key("file") && obj.contains_key("line"),
"expected flat-task shape, got: {first}"
);
assert!(
!obj.contains_key("date"),
"got day-shaped object instead of flat task: {first}"
);
}
}
#[test]
fn unknown_locale_is_hard_error_even_under_quiet() {
let out = bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--locale",
"ru,xx",
"--quiet",
])
.output()
.expect("run");
assert!(
!out.status.success(),
"expected failure, got success; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.status.code(),
Some(2),
"expected exit code 2 for usage error, got: {:?}, stderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("unknown locale"),
"expected 'unknown locale' wording, got: {stderr}"
);
assert!(
stderr.contains("xx"),
"expected offending value 'xx', got: {stderr}"
);
}
#[test]
fn known_locales_do_not_warn() {
let out = bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--locale",
"ru,en",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stderr.is_empty(),
"expected no warnings for ru,en, got: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn validator_error_messages_match_clap_lowercase_style() {
let out = bin()
.args(["--dir", "examples", "--current-date", "abc"])
.output()
.expect("run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("invalid value 'abc'"),
"expected clap prefix, got: {stderr}"
);
assert!(
!stderr.contains("Invalid date"),
"validator must not start with `Invalid date`, got: {stderr}"
);
assert!(
stderr.contains("use YYYY-MM-DD format"),
"expected lowercase hint, got: {stderr}"
);
}
#[test]
fn color_flag_accepts_auto_always_never() {
for v in ["auto", "always", "never"] {
bin()
.args([
"--dir",
"examples",
"--color",
v,
"--max-tasks",
"1",
"--tasks",
"--format",
"json",
])
.assert()
.success();
}
}
#[test]
fn color_flag_rejects_unknown_value() {
bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--color",
"purple",
])
.assert()
.failure()
.stderr(contains("invalid value"));
}
#[test]
fn agenda_conflicts_with_tasks_flag() {
bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--agenda",
"week",
"--tasks",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn verbose_conflicts_with_quiet() {
bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--verbose",
"--quiet",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"-v",
"-q",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn color_conflicts_with_no_color() {
bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--color",
"always",
"--no-color",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn help_mentions_format_md_alias() {
let short = bin().arg("-h").output().expect("run");
let short_out = String::from_utf8_lossy(&short.stdout);
assert!(
short_out.contains("`md`"),
"expected `md` alias in -h, got: {short_out}"
);
let long = bin().arg("--help").output().expect("run");
let long_out = String::from_utf8_lossy(&long.stdout);
assert!(
long_out.contains("`md`"),
"expected `md` alias in --help, got: {long_out}"
);
}
#[test]
fn output_dash_writes_to_stdout_and_creates_no_file() {
let dir = tempdir().unwrap();
let out = bin()
.current_dir(dir.path())
.args([
"--dir",
concat!(env!("CARGO_MANIFEST_DIR"), "/examples"),
"--format",
"json",
"--tasks",
"--output",
"-",
"--max-tasks",
"1",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let _parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout must be valid JSON");
assert!(
!dir.path().join("-").exists(),
"literal file `-` must not be created"
);
}
#[test]
fn verbose_emits_info_summary_on_stderr() {
let out = bin()
.args(["--dir", "examples", "--current-date", "2025-12-05", "-v"])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("scan finished"),
"expected info summary on stderr at -v, got: {stderr}"
);
}
#[test]
fn quiet_suppresses_all_diagnostics_on_stderr() {
let out = bin()
.args([
"--dir",
"examples",
"--current-date",
"2025-12-05",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stderr.is_empty(),
"expected empty stderr with --quiet, got: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn help_no_color_mentions_env_var_equivalence() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("NO_COLOR"), "missing NO_COLOR mention");
assert!(
stdout.contains("same effect"),
"expected 'same effect' wording, got: {stdout}"
);
}
#[test]
fn help_verbose_documents_the_trace_ceiling() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("-vvv` is the maximum") || stdout.contains("-vvv is the maximum"),
"expected the --verbose help to document the trace ceiling, got: {stdout}"
);
}
#[test]
fn help_groups_arguments_into_named_sections() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
for heading in [
"Input:",
"Output:",
"Agenda:",
"Limits:",
"Diagnostics:",
"Actions:",
] {
assert!(
stdout.contains(heading),
"expected `{heading}` section in --help, got: {stdout}"
);
}
}
#[test]
fn help_long_about_includes_runnable_examples() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Examples:"),
"expected `Examples:` block in long --help, got: {stdout}"
);
for needle in [
"markdown-org-extract --dir ~/notes --agenda day",
"markdown-org-extract --holidays 2026",
"markdown-org-extract --completions bash",
] {
assert!(
stdout.contains(needle),
"expected example `{needle}` in long --help, got: {stdout}"
);
}
}
#[test]
fn short_help_omits_examples_block() {
let out = bin().arg("-h").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("Examples:"),
"short `-h` must not include the Examples block, got: {stdout}"
);
}
#[test]
fn help_long_about_includes_exit_status_section() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Exit status:"),
"expected an `Exit status:` block in --help, got: {stdout}"
);
for code in ["130", "74"] {
assert!(
stdout.contains(code),
"expected exit code `{code}` in the --help Exit status block, got: {stdout}"
);
}
}
#[test]
fn help_long_about_includes_environment_section() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Environment:"),
"expected an `Environment:` block in --help, got: {stdout}"
);
assert!(
stdout.contains("RUST_LOG"),
"expected RUST_LOG in the --help Environment block, got: {stdout}"
);
}
#[test]
fn short_about_mentions_json_default() {
let out = bin().arg("-h").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("JSON by default"),
"expected the short -h about to mention JSON as the default, got: {stdout}"
);
}
#[test]
fn completions_help_uses_user_local_path() {
let out = bin().arg("--help").output().expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("/etc/bash_completion.d"),
"the --completions help must not steer users to a sudo-only system path, got: {stdout}"
);
}
#[test]
fn completions_emit_elvish_and_powershell() {
for shell in ["elvish", "powershell"] {
let out = bin().args(["--completions", shell]).output().expect("run");
assert!(
out.status.success(),
"--completions {shell} must succeed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.len() > 200,
"--completions {shell} should emit a non-trivial script, got {} bytes",
out.stdout.len()
);
}
}
#[test]
fn version_flag_prints_semver() {
let out = bin().arg("--version").output().expect("run");
assert!(
out.status.success(),
"--version must succeed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let expected = format!("markdown-org-extract {}", env!("CARGO_PKG_VERSION"));
assert_eq!(
stdout.trim(),
expected,
"--version output must be `{expected}`, got: {stdout}"
);
}
#[test]
fn rejects_inverted_from_to_range() {
bin()
.args([
"--dir",
"examples",
"--agenda",
"week",
"--from",
"2025-12-10",
"--to",
"2025-12-01",
"--current-date",
"2025-12-05",
])
.assert()
.failure()
.stderr(contains("after end date"));
}
#[test]
fn debug_log_uses_unified_file_key_not_path() {
let out = bin()
.args(["--dir", "examples", "--current-date", "2025-12-05", "-vv"])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("file="),
"expected `file=` from the unified file key, got stderr: {stderr}"
);
assert!(
!stderr.contains("path="),
"the file span/events must use `file=`, never a stray `path=`; stderr: {stderr}"
);
}
#[test]
fn run_span_wraps_scan_finished_at_info() {
let out = bin()
.args(["--dir", "examples", "--current-date", "2025-12-05", "-v"])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let scan_line = stderr
.lines()
.find(|l| l.contains("scan finished"))
.unwrap_or_else(|| panic!("no `scan finished` line at -v; stderr: {stderr}"));
assert!(
scan_line.contains("run{dir="),
"the `scan finished` line must inherit the root `run{{dir=...}}` span; line was: {scan_line}"
);
}
#[test]
fn parse_repeater_rejection_uses_static_event_name() {
let dir = tempdir().unwrap();
let path = dir.path().join("repeater.md");
fs::write(
&path,
"### TODO repeater probe\n`SCHEDULED: <2024-12-09 Mon +0d>`\n",
)
.unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--current-date",
"2024-12-09",
"-vvv",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("parse_repeater_rejected"),
"expected the static event name `parse_repeater_rejected` at -vvv; stderr: {stderr}"
);
assert!(
!stderr.contains("parse_repeater: rejected"),
"the colon-style message must be gone; stderr: {stderr}"
);
}
#[test]
fn exit_code_2_for_invalid_directory() {
let out = bin()
.args([
"--dir",
"/this/path/should/never/exist_xyz_exitcode",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert_eq!(
out.status.code(),
Some(2),
"invalid --dir is a usage error, must exit 2 (got {:?}); stderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn exit_code_2_for_invalid_output_parent() {
let out = bin()
.args([
"--dir",
"examples",
"--output",
"/this/parent/should/never/exist/out.json",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert_eq!(
out.status.code(),
Some(2),
"invalid --output (missing parent) is a usage error, must exit 2 (got {:?}); stderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn exit_code_74_for_io_when_output_is_a_directory() {
let tmp = tempdir().expect("tmpdir");
let out_path = tmp.path().join("collision-dir");
fs::create_dir(&out_path).expect("create collision dir");
let out = bin()
.args([
"--dir",
"examples",
"--output",
out_path.to_str().unwrap(),
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert_eq!(
out.status.code(),
Some(74),
"writing to a path that is a directory is an IO error, must exit 74 (got {:?}); stderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let path_str = out_path.to_string_lossy();
assert!(
stderr.contains(&*path_str),
"expected the failing path '{path_str}' in stderr, got: {stderr}"
);
}
fn day_count_in_json(stdout: &str) -> usize {
let parsed: serde_json::Value =
serde_json::from_str(stdout).expect("stdout must be valid JSON");
parsed.as_array().expect("top-level array").len()
}
fn day_dates_in_json(stdout: &str) -> Vec<String> {
let parsed: serde_json::Value =
serde_json::from_str(stdout).expect("stdout must be valid JSON");
parsed
.as_array()
.expect("top-level array")
.iter()
.map(|day| {
day.get("date")
.and_then(serde_json::Value::as_str)
.expect("every day carries a date")
.to_string()
})
.collect()
}
#[test]
fn scheduled_cell_carries_the_occurrence_after_it() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"day",
"--current-date",
"2025-12-08",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let parsed: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("valid JSON");
let day = parsed
.as_array()
.and_then(|days| days.first())
.expect("one day");
let scheduled: Vec<&serde_json::Value> = ["scheduled_timed", "scheduled_no_time"]
.iter()
.filter_map(|bucket| day.get(*bucket))
.filter_map(serde_json::Value::as_array)
.flatten()
.collect();
let after_for = |repeater: &str| -> Option<String> {
scheduled
.iter()
.find(|task| task.get("timestamp_repeater").and_then(|r| r.as_str()) == Some(repeater))
.and_then(|task| task.get("timestamp_next_after"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
assert_eq!(after_for("+1w").as_deref(), Some("2025-12-15"));
assert_eq!(after_for("+1d").as_deref(), Some("2025-12-09"));
}
#[test]
fn agenda_month_grid_covers_the_weeks_the_month_touches() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"month-grid",
"--date",
"2026-08-12",
"--current-date",
"2026-08-12",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates.len(), 42, "six rows of seven");
assert_eq!(dates.first().map(String::as_str), Some("2026-07-27"));
assert_eq!(dates.last().map(String::as_str), Some("2026-09-06"));
}
#[test]
fn agenda_month_grid_follows_week_start() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"month-grid",
"--week-start",
"sunday",
"--date",
"2026-08-12",
"--current-date",
"2026-08-12",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates.first().map(String::as_str), Some("2026-07-26"));
assert_eq!(dates.last().map(String::as_str), Some("2026-09-05"));
}
#[test]
fn agenda_month_grid_grows_an_explicit_range_to_whole_weeks() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"month-grid",
"--from",
"2026-08-05",
"--to",
"2026-08-11",
"--current-date",
"2026-08-12",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates.len(), 14, "two rows of seven");
assert_eq!(dates.first().map(String::as_str), Some("2026-08-03"));
assert_eq!(dates.last().map(String::as_str), Some("2026-08-16"));
}
#[test]
fn agenda_month_grid_rejects_an_anchored_week_start() {
bin()
.args([
"--dir",
"examples",
"--agenda",
"month-grid",
"--week-start",
"today",
"--current-date",
"2026-08-12",
])
.assert()
.failure()
.stderr(contains("month-grid"));
}
#[test]
fn agenda_week_start_shifts_the_week() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"week",
"--week-start",
"sunday",
"--current-date",
"2026-08-19",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates.first().map(String::as_str), Some("2026-08-16"));
assert_eq!(dates.last().map(String::as_str), Some("2026-08-22"));
}
#[test]
fn agenda_week_start_rejects_a_name_that_is_not_a_weekday() {
bin()
.args([
"--dir",
"examples",
"--agenda",
"week",
"--week-start",
"payday",
"--current-date",
"2026-08-19",
])
.assert()
.code(2)
.stderr(contains("week-start"))
.stderr(contains("possible values"))
.stderr(contains("monday"))
.stderr(contains("today"));
}
#[test]
fn agenda_week_start_takes_a_three_letter_name() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"week",
"--week-start",
"sun",
"--current-date",
"2026-08-19",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates.first().map(String::as_str), Some("2026-08-16"));
assert_eq!(dates.last().map(String::as_str), Some("2026-08-22"));
}
#[test]
fn agenda_week_start_ignores_case() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"week",
"--week-start",
"SUNDAY",
"--current-date",
"2026-08-19",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates.first().map(String::as_str), Some("2026-08-16"));
}
#[test]
fn help_mentions_week_start_abbreviations() {
let long = bin().arg("--help").output().expect("run");
let long_out = String::from_utf8_lossy(&long.stdout);
assert!(
long_out.contains("`mon`"),
"expected the `mon` abbreviation in --help, got: {long_out}"
);
}
#[test]
fn agenda_day_with_from_to_emits_multi_day() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"day",
"--from",
"2025-12-01",
"--to",
"2025-12-07",
"--current-date",
"2025-12-05",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
day_count_in_json(&stdout),
7,
"expected 7 day-agendas for [2025-12-01..2025-12-07]; got {stdout:.200}"
);
}
#[test]
fn agenda_week_from_only_fills_to_from_current_date() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"week",
"--from",
"2025-12-01",
"--current-date",
"2025-12-05",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(day_count_in_json(&String::from_utf8_lossy(&out.stdout)), 5);
}
#[test]
fn agenda_month_to_only_fills_from_from_current_date() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"month",
"--to",
"2025-12-10",
"--current-date",
"2025-12-05",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(day_count_in_json(&String::from_utf8_lossy(&out.stdout)), 6);
}
#[test]
fn agenda_day_from_after_current_date_fails() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"day",
"--from",
"2026-01-15",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert!(!out.status.success(), "expected failure");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("after end date"),
"expected DateRange diagnostic; got: {stderr}"
);
}
#[test]
fn agenda_tasks_rejects_date_argument() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"tasks",
"--date",
"2025-12-05",
])
.output()
.expect("run");
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("tasks mode does not accept date arguments"),
"expected ADR-0009 tasks-mode rejection; got: {stderr}"
);
}
#[test]
fn completions_emit_per_shell_script() {
for shell in ["bash", "zsh", "fish"] {
let out = bin().args(["--completions", shell]).output().expect("run");
assert!(
out.status.success(),
"completions for {shell} must succeed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("markdown-org-extract"),
"completion script for {shell} must mention the binary name; got: {stdout:.200}"
);
assert!(
stdout.len() > 200,
"completion script for {shell} looks empty ({} bytes)",
stdout.len()
);
}
}
#[test]
fn completions_conflicts_with_scan_flags() {
bin()
.args(["--completions", "bash", "--dir", "examples"])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn completions_rejects_unknown_shell() {
bin()
.args(["--completions", "tcsh"])
.assert()
.failure()
.stderr(contains("invalid value"));
}
#[test]
fn multi_segment_glob_matches_with_relative_dir() {
let tmp = tempdir().expect("tmp");
let workspace = tmp.path().join("ws");
let sub = workspace.join("sub");
fs::create_dir_all(&sub).expect("mkdir sub");
fs::write(sub.join("foo.md"), "### TODO Foo task\n").expect("write foo.md");
fs::write(sub.join("bar.md"), "### TODO Bar task\n").expect("write bar.md");
fs::write(
workspace.join("top.md"),
"### TODO Top task should not be matched\n",
)
.expect("write top.md");
let out = bin()
.current_dir(tmp.path())
.args([
"--dir", "ws", "--glob", "sub/*.md", "--tasks", "--format", "json", "--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"scan must succeed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert_eq!(
arr.len(),
2,
"expected exactly 2 matches (foo.md, bar.md); got {arr:?}"
);
let headings: Vec<&str> = arr
.iter()
.filter_map(|t| t.get("heading").and_then(|h| h.as_str()))
.collect();
assert!(
headings.iter().any(|h| h.contains("Foo")),
"expected Foo task; headings: {headings:?}"
);
assert!(
headings.iter().any(|h| h.contains("Bar")),
"expected Bar task; headings: {headings:?}"
);
assert!(
!headings.iter().any(|h| h.contains("Top")),
"Top task must not match `sub/*.md`; headings: {headings:?}"
);
}
#[cfg(unix)]
#[test]
fn output_write_to_readonly_parent_exits_74_with_path_in_stderr() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tmpdir");
let ro_dir = tmp.path().join("ro");
fs::create_dir(&ro_dir).expect("mkdir ro");
let out_path = ro_dir.join("out.json");
let mut perms = fs::metadata(&ro_dir).expect("metadata").permissions();
perms.set_mode(0o555);
fs::set_permissions(&ro_dir, perms).expect("chmod 555");
let out = bin()
.args([
"--dir",
"examples",
"--output",
out_path.to_str().unwrap(),
"--current-date",
"2025-12-05",
"--quiet",
])
.output()
.expect("run");
let mut perms = fs::metadata(&ro_dir).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&ro_dir, perms).expect("chmod restore");
assert_eq!(
out.status.code(),
Some(74),
"write into read-only parent must exit 74 (EX_IOERR); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let path_str = out_path.to_string_lossy();
assert!(
stderr.contains(&*path_str),
"expected the failing path '{path_str}' in stderr, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn output_write_to_readonly_file_exits_74_with_path_in_stderr() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().expect("tmpdir");
let out_path = tmp.path().join("locked.json");
fs::write(&out_path, b"placeholder").expect("write placeholder");
let mut perms = fs::metadata(&out_path).expect("metadata").permissions();
perms.set_mode(0o444);
fs::set_permissions(&out_path, perms).expect("chmod 444");
let out = bin()
.args([
"--dir",
"examples",
"--output",
out_path.to_str().unwrap(),
"--current-date",
"2025-12-05",
"--quiet",
])
.output()
.expect("run");
let mut perms = fs::metadata(&out_path).expect("metadata").permissions();
perms.set_mode(0o644);
fs::set_permissions(&out_path, perms).expect("chmod restore");
assert_eq!(
out.status.code(),
Some(74),
"overwrite of read-only file must exit 74 (EX_IOERR); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let path_str = out_path.to_string_lossy();
assert!(
stderr.contains(&*path_str),
"expected the failing path '{path_str}' in stderr, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn walker_continues_after_permission_denied_subdir() {
use std::os::unix::fs::PermissionsExt;
let root = tempdir().expect("tmp");
fs::write(
root.path().join("ok.md"),
"# Notes\n\n### TODO First\n`SCHEDULED: <2025-12-05 Fri>`\n",
)
.expect("write ok.md");
let blocked = root.path().join("blocked");
fs::create_dir(&blocked).expect("mkdir blocked");
fs::write(
blocked.join("hidden.md"),
"# Hidden\n### TODO Hidden task\n",
)
.expect("write hidden.md");
let mut perms = fs::metadata(&blocked).expect("metadata").permissions();
perms.set_mode(0o000);
fs::set_permissions(&blocked, perms).expect("chmod 0");
let out = bin()
.args([
"--dir",
root.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"-v",
])
.output()
.expect("run");
let mut perms = fs::metadata(&blocked).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&blocked, perms).expect("chmod restore");
assert!(
out.status.success(),
"scan must succeed despite walker error; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("First"),
"readable file's task must be in output; stdout: {stdout:.500}"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("walk_errors") || stderr.contains("walker entry failed"),
"summary or per-error warning must mention the walker error; stderr: {stderr}"
);
}
#[test]
fn holidays_conflicts_with_agenda_window_arguments() {
bin()
.args(["--holidays", "2026", "--week-start", "sunday"])
.assert()
.code(2)
.stderr(contains("cannot be used with"));
bin()
.args(["--holidays", "2026", "--current-date", "2026-08-19"])
.assert()
.code(2)
.stderr(contains("cannot be used with"));
}
#[test]
fn agenda_tasks_rejects_week_start_argument() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"tasks",
"--week-start",
"sunday",
])
.output()
.expect("run");
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("tasks mode does not accept date arguments"),
"expected the tasks-mode rejection; got: {stderr}"
);
}
#[test]
fn tasks_flag_conflicts_with_week_start() {
bin()
.args(["--dir", "examples", "--tasks", "--week-start", "sunday"])
.assert()
.code(2)
.stderr(contains("cannot be used with"));
}
#[test]
fn agenda_day_accepts_week_start_without_changing_the_window() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"day",
"--week-start",
"sunday",
"--current-date",
"2026-08-19",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let dates = day_dates_in_json(&String::from_utf8_lossy(&out.stdout));
assert_eq!(dates, vec!["2026-08-19".to_string()]);
}
#[test]
fn agenda_tasks_rejects_current_date_argument() {
let out = bin()
.args([
"--dir",
"examples",
"--agenda",
"tasks",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("tasks mode does not accept date arguments"),
"expected ADR-0009 tasks-mode rejection; got: {stderr}"
);
}
#[test]
fn json_snapshot_tasks_mode_minimal_fixture() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n",
)
.expect("write notes.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"file\": \"notes.md\",
\"line\": 3,
\"heading\": \"Pin me\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"SCHEDULED: <2026-05-21 Thu>\",
\"timestamp_type\": \"SCHEDULED\",
\"timestamp_active\": true,
\"timestamp_date\": \"2026-05-21\"
}
]
";
assert_eq!(
stdout, expected,
"JSON tasks snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_agenda_day_minimal_fixture() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n",
)
.expect("write notes.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--agenda",
"day",
"--current-date",
"2026-05-21",
"--tz",
"UTC",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"date\": \"2026-05-21\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [
{
\"file\": \"notes.md\",
\"line\": 3,
\"heading\": \"Pin me\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"SCHEDULED: <2026-05-21 Thu>\",
\"timestamp_type\": \"SCHEDULED\",
\"timestamp_active\": true,
\"timestamp_date\": \"2026-05-21\"
}
],
\"upcoming\": []
}
]
";
assert_eq!(
stdout, expected,
"JSON agenda-day snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_agenda_day_repeating_task_carries_timestamp_next() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO Pin me\n`SCHEDULED: <2026-01-31 Sat ++1m>`\n",
)
.expect("write notes.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--agenda",
"day",
"--current-date",
"2026-05-21",
"--tz",
"UTC",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"date\": \"2026-05-21\",
\"overdue\": [
{
\"file\": \"notes.md\",
\"line\": 3,
\"heading\": \"Pin me\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"SCHEDULED: <2026-04-30 Thu ++1m>\",
\"timestamp_type\": \"SCHEDULED\",
\"timestamp_active\": true,
\"timestamp_date\": \"2026-04-30\",
\"timestamp_repeater\": \"++1m\",
\"timestamp_next\": \"2026-05-31\",
\"days_offset\": -21
}
],
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
}
]
";
assert_eq!(
stdout, expected,
"JSON agenda-day repeating snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn timestamp_next_is_the_same_in_every_cell_of_a_week_payload() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO Weekly sync\n`SCHEDULED: <2026-07-21 Tue ++7d>`\n",
)
.expect("write notes.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--agenda",
"week",
"--current-date",
"2026-07-22",
"--tz",
"UTC",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let mut seen: Vec<String> = Vec::new();
for day in parsed.as_array().expect("array of days") {
for bucket in [
"overdue",
"scheduled_timed",
"scheduled_no_time",
"upcoming",
] {
for item in day[bucket].as_array().into_iter().flatten() {
seen.push(
item["timestamp_next"]
.as_str()
.unwrap_or("<missing>")
.to_string(),
);
}
}
}
assert!(!seen.is_empty(), "week payload must contain the task");
assert!(
seen.iter().all(|v| v == "2026-07-28"),
"every cell must carry 2026-07-28; got {seen:?} in:\n{stdout}"
);
}
#[test]
fn timestamp_next_is_absent_in_tasks_mode() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO Weekly sync\n`SCHEDULED: <2026-07-21 Tue ++7d>`\n",
)
.expect("write notes.md");
let out = bin()
.args(["--dir", tmp.path().to_str().unwrap(), "--tasks", "--quiet"])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
for task in parsed.as_array().expect("array of tasks") {
assert!(
task.get("timestamp_next").is_none(),
"tasks mode must not carry timestamp_next; got:\n{stdout}"
);
}
}
#[test]
fn timestamp_next_is_absent_for_a_task_without_a_repeater() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO One-off\n`SCHEDULED: <2026-05-21 Thu>`\n",
)
.expect("write notes.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--agenda",
"day",
"--current-date",
"2026-05-21",
"--tz",
"UTC",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
assert!(
!stdout.contains("timestamp_next"),
"a non-repeating task must not carry the field; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_tasks_mode_clock_entry() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("clock.md"),
"# Notes\n\n### TODO Clocked task\n`SCHEDULED: <2026-05-21 Thu>`\n\
`CLOCK: [2026-05-21 Thu 10:00]--[2026-05-21 Thu 11:30] => 1:30`\n",
)
.expect("write clock.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"file\": \"clock.md\",
\"line\": 3,
\"heading\": \"Clocked task\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"SCHEDULED: <2026-05-21 Thu>\",
\"timestamp_type\": \"SCHEDULED\",
\"timestamp_active\": true,
\"timestamp_date\": \"2026-05-21\",
\"clocks\": [
{
\"start\": \"2026-05-21 Thu 10:00\",
\"end\": \"2026-05-21 Thu 11:30\",
\"duration\": \"1:30\"
}
],
\"total_clock_time\": \"1:30\"
}
]
";
assert_eq!(
stdout, expected,
"JSON CLOCK snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_tasks_mode_inactive_timestamp() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("inactive.md"),
"# Notes\n\n### TODO Inactive stamp\n`[2026-05-21 Thu]`\n",
)
.expect("write inactive.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"file\": \"inactive.md\",
\"line\": 3,
\"heading\": \"Inactive stamp\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"[2026-05-21 Thu]\",
\"timestamp_type\": \"PLAIN\",
\"timestamp_active\": false,
\"timestamp_date\": \"2026-05-21\"
}
]
";
assert_eq!(
stdout, expected,
"JSON inactive-timestamp snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_tasks_mode_repeater_and_warning_preserved() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("rep.md"),
"# Notes\n\n### TODO Repeating with warning\n`DEADLINE: <2026-05-21 Thu +1m -3d>`\n",
)
.expect("write rep.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"file\": \"rep.md\",
\"line\": 3,
\"heading\": \"Repeating with warning\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"DEADLINE: <2026-05-21 Thu +1m -3d>\",
\"timestamp_type\": \"DEADLINE\",
\"timestamp_active\": true,
\"timestamp_date\": \"2026-05-21\",
\"timestamp_repeater\": \"+1m\"
}
]
";
assert_eq!(
stdout, expected,
"JSON repeater/warning snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_agenda_week_envelope() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("wk.md"),
"# Notes\n\n### TODO Week task\n`SCHEDULED: <2026-05-21 Thu>`\n",
)
.expect("write wk.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--agenda",
"week",
"--current-date",
"2026-05-21",
"--tz",
"UTC",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let expected = "\
[
{
\"date\": \"2026-05-18\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
},
{
\"date\": \"2026-05-19\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
},
{
\"date\": \"2026-05-20\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
},
{
\"date\": \"2026-05-21\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [
{
\"file\": \"wk.md\",
\"line\": 3,
\"heading\": \"Week task\",
\"content\": \"\",
\"task_type\": \"TODO\",
\"timestamp\": \"SCHEDULED: <2026-05-21 Thu>\",
\"timestamp_type\": \"SCHEDULED\",
\"timestamp_active\": true,
\"timestamp_date\": \"2026-05-21\"
}
],
\"upcoming\": []
},
{
\"date\": \"2026-05-22\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
},
{
\"date\": \"2026-05-23\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
},
{
\"date\": \"2026-05-24\",
\"scheduled_timed\": [],
\"scheduled_no_time\": [],
\"upcoming\": []
}
]
";
assert_eq!(
stdout, expected,
"JSON agenda-week snapshot must be byte-exact; got:\n{stdout}"
);
}
#[test]
fn json_snapshot_agenda_month_envelope_shape() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("mo.md"),
"# Notes\n\n### TODO Month task\n`SCHEDULED: <2026-05-21 Thu>`\n",
)
.expect("write mo.md");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--agenda",
"month",
"--current-date",
"2026-05-21",
"--tz",
"UTC",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8");
let days: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("valid JSON array");
assert_eq!(days.len(), 31, "May 2026 has 31 day buckets");
assert_eq!(days[0]["date"], "2026-05-01", "first bucket is the 1st");
assert_eq!(days[30]["date"], "2026-05-31", "last bucket is the 31st");
for (i, day) in days.iter().enumerate() {
for key in ["date", "scheduled_timed", "scheduled_no_time", "upcoming"] {
assert!(
day.get(key).is_some(),
"day {i} is missing the `{key}` envelope key: {day}"
);
}
}
let on_21 = &days[20];
assert_eq!(on_21["date"], "2026-05-21");
assert_eq!(
on_21["scheduled_no_time"][0]["heading"], "Month task",
"the task must sit on the 21st: {on_21}"
);
let total_tasks: usize = days
.iter()
.map(|d| d["scheduled_no_time"].as_array().map_or(0, |a| a.len()))
.sum();
assert_eq!(total_tasks, 1, "the task must appear on exactly one day");
}
fn fixture_with_one_task() -> tempfile::TempDir {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"# Notes\n\n### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n",
)
.expect("write notes.md");
tmp
}
fn run_with_format(tmp: &std::path::Path, format: &str) -> Vec<u8> {
let out = bin()
.args([
"--dir",
tmp.to_str().unwrap(),
"--tasks",
"--format",
format,
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
#[test]
fn stdout_json_ends_with_newline() {
let tmp = fixture_with_one_task();
let bytes = run_with_format(tmp.path(), "json");
assert_eq!(
bytes.last().copied(),
Some(b'\n'),
"JSON stdout must end with a trailing newline; got tail: {:?}",
String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(8)..])
);
}
#[test]
fn stdout_markdown_ends_with_newline() {
let tmp = fixture_with_one_task();
let bytes = run_with_format(tmp.path(), "markdown");
assert_eq!(
bytes.last().copied(),
Some(b'\n'),
"Markdown stdout must end with a trailing newline; got tail: {:?}",
String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(8)..])
);
}
#[test]
fn stdout_html_ends_with_newline() {
let tmp = fixture_with_one_task();
let bytes = run_with_format(tmp.path(), "html");
assert_eq!(
bytes.last().copied(),
Some(b'\n'),
"HTML stdout must end with a trailing newline; got tail: {:?}",
String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(8)..])
);
}
#[test]
fn output_file_ends_with_newline_for_each_format() {
let tmp = fixture_with_one_task();
for format in ["json", "markdown", "html"] {
let out_path = tmp.path().join(format!("out.{format}"));
let result = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--tasks",
"--format",
format,
"--output",
out_path.to_str().unwrap(),
"--quiet",
])
.output()
.expect("run");
assert!(
result.status.success(),
"format {format} failed to write: {}",
String::from_utf8_lossy(&result.stderr)
);
let body = fs::read(&out_path).expect("read written file");
assert_eq!(
body.last().copied(),
Some(b'\n'),
"{} file output must end with a trailing newline; got tail: {:?}",
format,
String::from_utf8_lossy(&body[body.len().saturating_sub(8)..])
);
}
}
#[cfg(unix)]
#[test]
fn broken_pipe_exits_silently_without_diagnostic() {
use std::path::PathBuf;
use std::process::{Command as StdCommand, Stdio};
let tmp = tempdir().expect("tmpdir");
let block = "### TODO Task {{n}}\n`SCHEDULED: <2026-05-21 Thu>`\nContent line.\n\n";
for i in 0..10 {
let mut body = String::from("# Notes\n\n");
for j in 0..100 {
body.push_str(&block.replace("{{n}}", &format!("{i}_{j}")));
}
fs::write(tmp.path().join(format!("notes_{i:03}.md")), body).expect("write fixture file");
}
let bin_path: PathBuf = assert_cmd::cargo::cargo_bin("markdown-org-extract");
let mut child = StdCommand::new(bin_path)
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn binary");
drop(child.stdout.take());
let output = child.wait_with_output().expect("wait");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"binary must exit 0 on broken pipe; got status {:?}, stderr: {}",
output.status,
stderr
);
assert!(
!stderr.contains("Broken pipe"),
"stderr must not surface the broken-pipe error; got: {stderr}"
);
assert!(
!stderr.contains("error:"),
"stderr must not carry any 'error:' diagnostic for a broken pipe; got: {stderr}"
);
}
#[test]
fn deadline_warning_cookie_overrides_default_window() {
let tmp = tempdir().expect("tmpdir");
fs::write(
tmp.path().join("notes.md"),
"### TODO [#A] Cookie task\n`DEADLINE: <2025-12-10 Wed -3d>`\n",
)
.expect("write fixture");
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--current-date",
"2025-12-05",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"scan must succeed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let parsed: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("valid JSON");
let upcoming_at_5 = parsed
.as_array()
.and_then(|days| days.first())
.and_then(|d| d.get("upcoming"))
.and_then(|u| u.as_array())
.map(|a| a.len())
.unwrap_or(0);
assert_eq!(
upcoming_at_5, 0,
"DEADLINE with -3d must be silent 5 days out; full output: {parsed}"
);
let out = bin()
.args([
"--dir",
tmp.path().to_str().unwrap(),
"--current-date",
"2025-12-08",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(out.status.success());
let parsed: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("valid JSON");
let upcoming_at_8 = parsed
.as_array()
.and_then(|days| days.first())
.and_then(|d| d.get("upcoming"))
.and_then(|u| u.as_array())
.map(|a| a.len())
.unwrap_or(0);
assert_eq!(
upcoming_at_8, 1,
"DEADLINE with -3d must surface in upcoming 2 days out; full output: {parsed}"
);
}
#[test]
fn verbose_saturation_warns_on_vvvv_and_beyond() {
let out = bin()
.args(["--dir", "examples", "--current-date", "2025-12-05", "-vvvv"])
.output()
.expect("run");
assert!(
out.status.success(),
"expected success on -vvvv; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("saturated") || stderr.contains("--verbose"),
"expected verbose saturation message in stderr; got:\n{stderr}"
);
}
#[test]
fn verbose_at_trace_threshold_does_not_warn() {
let out = bin()
.args(["--dir", "examples", "--current-date", "2025-12-05", "-vvv"])
.output()
.expect("run");
assert!(
out.status.success(),
"expected success on -vvv; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("saturated"),
"expected no saturation message at -vvv; got:\n{stderr}"
);
}
#[test]
fn holidays_stdout_ends_with_newline() {
let out = bin()
.args(["--holidays", "2026", "--quiet"])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.stdout.last().copied(),
Some(b'\n'),
"--holidays JSON must end with a trailing newline; got tail: {:?}",
String::from_utf8_lossy(&out.stdout[out.stdout.len().saturating_sub(8)..])
);
}
#[test]
fn print_summary_aggregates_failed_paths_into_single_warn() {
let dir = tempdir().unwrap();
for n in 0..2 {
let path = dir.path().join(format!("bad{n}.md"));
let mut bytes = b"# TODO test\n".to_vec();
bytes.push(0xFF);
bytes.extend_from_slice(b"\n");
fs::write(&path, &bytes).unwrap();
}
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--format",
"json",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
let stderr = String::from_utf8_lossy(&out.stderr);
let summary_lines: Vec<&str> = stderr
.lines()
.filter(|l| l.contains("processing summary"))
.collect();
assert_eq!(
summary_lines.len(),
1,
"exactly one 'processing summary' warn line expected; stderr was:\n{stderr}"
);
assert!(
!stderr.contains("failed paths (up to first"),
"the per-list header line must be folded into the summary; stderr:\n{stderr}"
);
let standalone_path_lines = stderr.matches("\"failed path\"").count()
+ stderr
.lines()
.filter(|l| l.ends_with("failed path"))
.count();
assert_eq!(
standalone_path_lines, 0,
"no per-path 'failed path' records should remain; stderr:\n{stderr}"
);
assert!(
summary_lines[0].contains("bad0.md") || summary_lines[0].contains("bad1.md"),
"the aggregated summary must surface the failed paths; stderr:\n{stderr}"
);
}
#[test]
fn per_file_failure_reason_is_logged_at_debug() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.md");
let mut bytes = b"# TODO test\n".to_vec();
bytes.push(0xFF);
bytes.extend_from_slice(b"\n");
fs::write(&path, &bytes).unwrap();
let verbose = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"-vv",
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
let verbose_stderr = String::from_utf8_lossy(&verbose.stderr);
assert!(
verbose_stderr.contains("file is not valid UTF-8; skipping"),
"at -vv the per-file failure reason must be logged; stderr was:\n{verbose_stderr}"
);
assert!(
verbose_stderr.contains("bad.md"),
"the per-file debug record must carry the path; stderr was:\n{verbose_stderr}"
);
let quiet = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--current-date",
"2025-12-05",
])
.output()
.expect("run");
let quiet_stderr = String::from_utf8_lossy(&quiet.stderr);
assert!(
!quiet_stderr.contains("file is not valid UTF-8; skipping"),
"at default verbosity the per-file debug record must be silent; stderr was:\n{quiet_stderr}"
);
}
#[test]
fn utf8_bom_prefix_does_not_swallow_first_heading() {
let dir = tempdir().unwrap();
let path = dir.path().join("bom.md");
let body = "# TODO BOM-prefixed heading\n\n`SCHEDULED: <2025-12-05 Fri>`\n";
let mut content = Vec::with_capacity(3 + body.len());
content.extend_from_slice(b"\xEF\xBB\xBF");
content.extend_from_slice(body.as_bytes());
fs::write(&path, &content).unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--format",
"json",
"--current-date",
"2025-12-05",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(
stdout.contains("BOM-prefixed heading"),
"BOM-prefixed first heading must still be extracted; stdout: {stdout}"
);
assert!(
!stdout.contains('\u{FEFF}'),
"BOM must not leak into the output text; stdout: {stdout}"
);
assert!(
stdout.contains("\"task_type\""),
"task_type must survive BOM strip; stdout: {stdout}"
);
assert!(
stdout.contains("\"TODO\""),
"TODO marker must be parsed past the BOM; stdout: {stdout}"
);
}
#[test]
fn rust_log_env_overrides_verbose_flag() {
let baseline = bin()
.args([
"--dir",
"examples",
"--format",
"json",
"--current-date",
"2025-12-05",
"-vv",
])
.env_remove("RUST_LOG")
.output()
.expect("baseline run");
assert!(
baseline.status.success(),
"baseline stderr: {}",
String::from_utf8_lossy(&baseline.stderr)
);
let baseline_err = String::from_utf8_lossy(&baseline.stderr);
assert!(
baseline_err.contains("scan finished"),
"baseline -vv must emit info-level 'scan finished'; stderr: {baseline_err}"
);
let muted = bin()
.args([
"--dir",
"examples",
"--format",
"json",
"--current-date",
"2025-12-05",
"-vv",
])
.env("RUST_LOG", "error")
.output()
.expect("muted run");
assert!(
muted.status.success(),
"muted stderr: {}",
String::from_utf8_lossy(&muted.stderr)
);
let muted_err = String::from_utf8_lossy(&muted.stderr);
assert!(
!muted_err.contains("scan finished"),
"RUST_LOG=error must mute the -vv info line; stderr: {muted_err}"
);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn non_utf8_path_is_processed_and_warned() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = tempdir().unwrap();
let fname = OsString::from_vec(b"bad\xFFname.md".to_vec());
let path = dir.path().join(&fname);
fs::write(
&path,
"### TODO non utf8 path task\n`SCHEDULED: <2024-12-09 Mon>`\n",
)
.unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--current-date",
"2024-12-09",
])
.output()
.expect("run over a non-UTF-8 named file");
assert!(
out.status.success(),
"scan must succeed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("non utf8 path task"),
"the task from the non-UTF-8 named file must still be emitted; stdout: {stdout}"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not valid UTF-8"),
"a non-UTF-8 path must trigger a warning; stderr: {stderr}"
);
}
#[test]
fn tasks_json_includes_properties_from_org_properties_block() {
let dir = tempdir().unwrap();
let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\nID: 11111111-2222-3333-4444-555555555555\n```\n\nBody.\n";
fs::write(dir.path().join("t.md"), content).unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let task = &parsed.as_array().expect("array of tasks")[0];
assert_eq!(
task["properties"]["GCAL_EVENT_ID"], "abc123/primary",
"properties.GCAL_EVENT_ID must be in the JSON: {stdout}"
);
assert_eq!(
task["properties"]["ID"], "11111111-2222-3333-4444-555555555555",
"properties.ID must be in the JSON: {stdout}"
);
}
#[test]
fn tasks_json_omits_properties_when_absent() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("t.md"),
"### TODO No props\n`SCHEDULED: <2026-06-01 Mon>`\n",
)
.unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(
!stdout.contains("properties"),
"absent properties must not appear in JSON: {stdout}"
);
}
#[test]
fn tasks_json_emits_fields_required_by_calendar_sync() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("t.md"),
"### TODO Sync me\n`SCHEDULED: <2026-06-01 Mon 10:00-11:00>`\n```org-properties\nID: 11111111-2222-3333-4444-555555555555\n```\n\nBody.\n",
)
.unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let task = &parsed.as_array().expect("array of tasks")[0];
assert_eq!(
task["task_type"], "TODO",
"task_type must be emitted: {stdout}"
);
assert_eq!(
task["timestamp_active"], true,
"timestamp_active must be emitted for active SCHEDULED: {stdout}"
);
assert_eq!(
task["timestamp_date"], "2026-06-01",
"timestamp_date must be emitted: {stdout}"
);
assert_eq!(
task["timestamp_time"], "10:00",
"timestamp_time must be emitted for a timed task: {stdout}"
);
assert_eq!(
task["timestamp_end_time"], "11:00",
"timestamp_end_time must be emitted for a time range: {stdout}"
);
assert_eq!(
task["properties"]["ID"], "11111111-2222-3333-4444-555555555555",
"properties.ID must be emitted (sync matching key): {stdout}"
);
}
const TODO_AND_DONE_FIXTURE: &str = "\
### TODO Keep me
`SCHEDULED: <2026-06-01 Mon>`
```org-properties
ID: aaaaaaaa-1111-2222-3333-444444444444
```
### DONE Finished
`SCHEDULED: <2026-06-02 Tue>`
```org-properties
ID: bbbbbbbb-5555-6666-7777-888888888888
```
";
#[test]
fn tasks_flat_list_excludes_done_by_default() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("t.md"), TODO_AND_DONE_FIXTURE).unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
let types: Vec<&str> = arr.iter().filter_map(|t| t["task_type"].as_str()).collect();
assert!(
types.contains(&"TODO"),
"TODO task must be present: {stdout}"
);
assert!(
!types.contains(&"DONE"),
"DONE task must be absent from --tasks by default: {stdout}"
);
}
#[test]
fn tasks_include_done_surfaces_done_with_properties() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("t.md"), TODO_AND_DONE_FIXTURE).unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--tasks-include-done",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
let done = arr
.iter()
.find(|t| t["task_type"] == "DONE")
.unwrap_or_else(|| panic!("DONE task must be present with the flag: {stdout}"));
assert_eq!(
done["properties"]["ID"], "bbbbbbbb-5555-6666-7777-888888888888",
"DONE task must carry its org-properties: {stdout}"
);
assert!(
arr.iter().any(|t| t["task_type"] == "TODO"),
"TODO task must still be present alongside DONE: {stdout}"
);
}
#[test]
fn tasks_include_cancelled_surfaces_cancelled() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("t.md"),
"### TODO Keep me\n\n### CANCELLED Drop me\n\n### DONE Finished\n",
)
.unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--tasks-include-cancelled",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
assert!(
arr.iter().any(|t| t["task_type"] == "CANCELLED"),
"CANCELLED task must be present with the flag: {stdout}"
);
assert!(
arr.iter().any(|t| t["task_type"] == "TODO"),
"TODO task must still be present: {stdout}"
);
assert!(
!arr.iter().any(|t| t["task_type"] == "DONE"),
"DONE must stay absent without --tasks-include-done: {stdout}"
);
}
#[test]
fn tasks_include_done_and_cancelled_surfaces_both() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("t.md"),
"### TODO Keep me\n\n### CANCELLED Drop me\n\n### DONE Finished\n",
)
.unwrap();
let out = bin()
.args([
"--dir",
dir.path().to_str().unwrap(),
"--tasks",
"--tasks-include-done",
"--tasks-include-cancelled",
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
assert!(
arr.iter().any(|t| t["task_type"] == "TODO"),
"TODO must be present: {stdout}"
);
assert!(
arr.iter().any(|t| t["task_type"] == "DONE"),
"DONE must be present with --tasks-include-done: {stdout}"
);
assert!(
arr.iter().any(|t| t["task_type"] == "CANCELLED"),
"CANCELLED must be present with --tasks-include-cancelled: {stdout}"
);
}
fn vault_with(name: &str, body: &str) -> tempfile::TempDir {
let dir = tempdir().expect("tmp");
fs::write(dir.path().join(name), body).expect("write note");
dir
}
#[test]
fn several_dirs_are_scanned_into_one_task_list() {
let work = vault_with("notes.md", "# TODO Renew the certificate\n");
let home = vault_with("notes.md", "# TODO Book the tickets\n");
let out = bin()
.args([
"--dir",
work.path().to_str().unwrap(),
"--dir",
home.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
let headings: Vec<&str> = arr
.iter()
.filter_map(|t| t.get("heading").and_then(|h| h.as_str()))
.collect();
assert_eq!(
headings,
vec!["Renew the certificate", "Book the tickets"],
"both roots must be in, in the order they were given: {stdout}"
);
}
#[test]
fn several_dirs_name_the_root_of_every_task() {
let work = vault_with("notes.md", "# TODO Renew the certificate\n");
let home = vault_with("notes.md", "# TODO Book the tickets\n");
let out = bin()
.args([
"--dir",
work.path().to_str().unwrap(),
"--dir",
home.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
let roots: Vec<&str> = arr
.iter()
.filter_map(|t| t.get("root").and_then(|r| r.as_str()))
.collect();
assert_eq!(roots.len(), 2, "every task carries its root: {stdout}");
assert_ne!(roots[0], roots[1], "the roots differ: {stdout}");
assert!(
arr.iter().all(|t| t["file"].as_str() == Some("notes.md")),
"the path stays relative to its own root: {stdout}"
);
}
#[test]
fn one_dir_emits_no_root_field() {
let vault = vault_with("notes.md", "# TODO Renew the certificate\n");
let out = bin()
.args([
"--dir",
vault.path().to_str().unwrap(),
"--tasks",
"--format",
"json",
"--quiet",
])
.output()
.expect("run");
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let arr = parsed.as_array().expect("array of tasks");
assert!(
arr.iter().all(|t| t.get("root").is_none()),
"a single root is not named on the tasks: {stdout}"
);
}
#[test]
fn a_missing_dir_among_several_fails_the_run() {
let vault = vault_with("notes.md", "# TODO Renew the certificate\n");
bin()
.args([
"--dir",
vault.path().to_str().unwrap(),
"--dir",
"/this/path/should/never/exist_xyz",
"--tasks",
"--format",
"json",
])
.assert()
.failure()
.stderr(contains("directory does not exist"));
}