use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use assert_cmd::Command;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn repo() -> TempDir {
let tmp = TempDir::new().unwrap();
tix().current_dir(tmp.path()).arg("init").assert().success();
tmp
}
fn stdout(dir: &Path, args: &[&str]) -> String {
let out = tix()
.current_dir(dir)
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
}
fn stderr(dir: &Path, args: &[&str]) -> String {
let out = tix()
.current_dir(dir)
.args(args)
.assert()
.failure()
.get_output()
.stderr
.clone();
String::from_utf8(out).unwrap()
}
fn create_series(dir: &Path, extra: &[&str]) -> String {
let mut args = vec![
"recurring",
"create",
"--title",
"Nightly close",
"--rule",
"daily",
"--anchor",
"2026-01-01",
];
args.extend_from_slice(extra);
let out = stdout(dir, &args);
assert!(out.starts_with("Created series "), "{out}");
list_ids(dir).first().cloned().expect("one series")
}
fn list_ids(dir: &Path) -> Vec<String> {
stdout(dir, &["recurring", "list"])
.lines()
.skip(1)
.filter(|line| !line.trim().is_empty())
.map(|line| line.split('\t').next().unwrap().to_string())
.collect()
}
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
let root = dir.join("docs").join("issues");
let mut out = BTreeMap::new();
let mut stack = vec![root.clone()];
while let Some(current) = stack.pop() {
let Ok(entries) = fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "md") {
let name = path
.strip_prefix(&root)
.unwrap()
.to_string_lossy()
.to_string();
out.insert(name, fs::read(&path).unwrap());
}
}
}
out
}
#[test]
fn a_series_round_trips_create_list_show_and_rename() {
let tmp = repo();
let dir = tmp.path();
let id = create_series(dir, &["--labels", "ops", "--priority", "high"]);
assert!(id.starts_with("s-"), "a generated stable id: {id}");
let list = stdout(dir, &["recurring", "list"]);
assert!(
list.starts_with("id\tname\tstate\tnext_cursor\tskipped"),
"{list}"
);
assert!(list.contains(&id), "{list}");
assert!(list.contains("Nightly close"), "{list}");
let shown = stdout(dir, &["recurring", "show", &id]);
assert!(shown.contains(&format!("id: {id}")), "{shown}");
assert!(shown.contains("state: active"), "{shown}");
assert!(shown.contains("rule: daily"), "{shown}");
assert!(shown.contains("anchor: 2026-01-01"), "{shown}");
assert!(shown.contains("issue: #1"), "{shown}");
let created = stdout(dir, &["list", "--all"]);
assert!(created.contains("Nightly close"), "{created}");
let renamed = stdout(dir, &["recurring", "edit", &id, "--name", "Nightly build"]);
assert!(
renamed.contains(&format!("Updated series {id}")),
"{renamed}"
);
assert!(renamed.contains("name: Nightly build"), "{renamed}");
let list = stdout(dir, &["recurring", "list"]);
assert!(list.contains(&id), "the id survives a rename: {list}");
assert!(list.contains("Nightly build"), "{list}");
}
#[test]
fn an_invalid_rule_is_refused_before_anything_is_written() {
let tmp = repo();
let dir = tmp.path();
let before = snapshot(dir);
let refused = stderr(
dir,
&[
"recurring",
"create",
"--title",
"Broken",
"--rule",
"hourly",
"--anchor",
"2026-01-01",
],
);
assert!(
refused.contains("error[E-tix-series]") && refused.contains("invalid_series_rule"),
"{refused}"
);
let missing_anchor = stderr(
dir,
&[
"recurring",
"create",
"--title",
"No anchor",
"--rule",
"daily",
],
);
assert!(
missing_anchor.contains("invalid_series_rule"),
"the rule's own field-aware diagnostic: {missing_anchor}"
);
assert_eq!(
snapshot(dir),
before,
"a refused rule creates no issue at all"
);
assert!(list_ids(dir).is_empty());
}
#[test]
fn detection_is_read_only_and_says_what_is_due() {
let tmp = repo();
let dir = tmp.path();
create_series(dir, &[]);
let before = snapshot(dir);
let due = stdout(dir, &["recurring", "detect", "--date", "2026-01-05"]);
assert!(
due.starts_with("series_id\toccurrence_key\tdue_date\trule"),
"{due}"
);
assert!(due.contains("@2026-01-01"), "{due}");
let early = stdout(dir, &["recurring", "detect", "--date", "2025-12-31"]);
assert!(
early.contains("No occurrences due across 1 series."),
"{early}"
);
let again = stdout(dir, &["recurring", "detect", "--date", "2026-01-05"]);
assert_eq!(due, again, "repeated detection is deterministic");
assert_eq!(
snapshot(dir),
before,
"detection must not write a single byte"
);
}
#[test]
fn generation_is_confirmed_and_idempotent() {
let tmp = repo();
let dir = tmp.path();
let id = create_series(dir, &[]);
let before = snapshot(dir);
let unconfirmed = stderr(dir, &["recurring", "generate", &id, "--date", "2026-01-01"]);
assert!(unconfirmed.contains("not_confirmed"), "{unconfirmed}");
assert_eq!(
snapshot(dir),
before,
"an unconfirmed generation writes nothing"
);
let created = stdout(
dir,
&[
"recurring",
"generate",
&id,
"--date",
"2026-01-01",
"--yes",
],
);
assert!(
created.starts_with("Created occurrence #2 for "),
"{created}"
);
let view = stdout(dir, &["view", "2"]);
assert!(view.contains("key:") && view.contains("series:"), "{view}");
let listing = stdout(dir, &["list", "--all"]);
assert!(listing.contains("Nightly close (2026-01-01)"), "{listing}");
let after = snapshot(dir);
let repeated = stdout(
dir,
&[
"recurring",
"generate",
&id,
"--date",
"2026-01-01",
"--yes",
],
);
assert!(repeated.starts_with("No-op for "), "{repeated}");
assert!(repeated.contains("already exists"), "{repeated}");
assert_eq!(snapshot(dir), after, "a no-op writes nothing");
}
#[test]
fn lifecycle_actions_need_the_flag_and_record_their_reason() {
let tmp = repo();
let dir = tmp.path();
let id = create_series(dir, &[]);
let unconfirmed = stderr(dir, &["recurring", "pause", &id]);
assert!(unconfirmed.contains("not_confirmed"), "{unconfirmed}");
let paused = stdout(
dir,
&["recurring", "pause", &id, "--yes", "--reason", "freeze"],
);
assert!(paused.contains("is now paused"), "{paused}");
assert!(stdout(dir, &["recurring", "show", &id]).contains("state: paused"));
let again = stdout(dir, &["recurring", "pause", &id, "--yes"]);
assert!(again.contains("no-op"), "{again}");
let resumed = stdout(dir, &["recurring", "resume", &id, "--yes"]);
assert!(resumed.contains("is now active"), "{resumed}");
let skipped = stdout(
dir,
&[
"recurring",
"skip",
&id,
"--date",
"2026-01-02",
"--yes",
"--reason",
"release week",
],
);
assert!(skipped.contains("@2026-01-02"), "{skipped}");
let shown = stdout(dir, &["recurring", "show", &id]);
assert!(shown.contains("skipped: 2026-01-02"), "{shown}");
assert!(shown.contains("next_cursor: 2026-01-02"), "{shown}");
let detection = stdout(dir, &["recurring", "detect", "--date", "2026-01-05"]);
assert!(detection.contains("cursor_already_taken"), "{detection}");
let skipped_again = stdout(
dir,
&[
"recurring",
"generate",
&id,
"--date",
"2026-01-02",
"--yes",
],
);
assert!(
skipped_again.starts_with("No-op for ") && skipped_again.contains("skipped"),
"{skipped_again}"
);
let ended = stdout(dir, &["recurring", "end", &id, "--yes"]);
assert!(ended.contains("is now ended"), "{ended}");
let after_end = stderr(dir, &["recurring", "pause", &id, "--yes"]);
assert!(after_end.contains("series_ended"), "{after_end}");
let missing_date = stderr(dir, &["recurring", "skip", &id, "--yes"]);
assert!(
missing_date.contains("requires") || missing_date.contains("--date"),
"{missing_date}"
);
}
#[test]
fn the_existing_commands_are_unaffected_by_a_series_file() {
let tmp = repo();
let dir = tmp.path();
let id = create_series(dir, &[]);
stdout(
dir,
&[
"recurring",
"generate",
&id,
"--date",
"2026-01-01",
"--yes",
],
);
let lint = tix()
.current_dir(dir)
.arg("lint")
.assert()
.success()
.get_output()
.stdout
.clone();
let lint = String::from_utf8(lint).unwrap();
assert!(
!lint.contains("series"),
"`series:` is a known key, not an unknown one: {lint}"
);
assert!(stdout(dir, &["status"]).contains("Total"));
assert!(stdout(dir, &["list", "--all"]).contains("Nightly close (2026-01-01)"));
assert!(stdout(dir, &["view", "1"]).contains("series:"));
assert!(stdout(dir, &["recurring", "show", "Nightly close"]).contains(&format!("id: {id}")));
let missing = tix()
.current_dir(dir)
.args(["recurring", "show", "s-nope"])
.assert()
.failure();
let _ = missing;
}