use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use time::{Date, Month};
use tix::config::Config;
use tix::model::HistoryEventKind;
use tix::recurrence::generate::{GenerationRequest, generate};
use tix::recurrence::lifecycle::{
LifecycleAction, LifecycleDiagnosticKind, LifecycleOutcome, LifecycleRequest, apply,
};
use tix::recurrence::rule::DateKey;
use tix::recurrence::series::{KeyState, OccurrenceKey, SeriesId, SeriesIndex, SeriesLifecycle};
use tix::storage::Store;
fn repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
fs::write(
tmp.path().join("tix.toml"),
format!(
"issues_dir = {issues:?}\n\
[kanban.default]\n\
columns = [\"todo\", \"in progress\", \"done\"]\n\
done_column = \"done\"\n\
[layout]\n\
move_on_change = false\n",
issues = dir.to_string_lossy(),
),
)
.unwrap();
(tmp, dir)
}
fn write_issue(dir: &Path, name: &str, frontmatter: &str) {
fs::write(
dir.join(name),
format!("---\n{frontmatter}---\n\nBody of the series.\n"),
)
.unwrap();
}
fn definition(number: u32, id: &str, extra: &str) -> String {
format!(
"number: {number}\ntitle: \"Nightly close\"\nstatus: \"todo\"\nlabels: [\"ops\"]\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
series:\n id: {id}\n name: Nightly\n rule:\n kind: daily\n \
anchor: 2026-01-01\n{extra}"
)
}
fn load(tmp: &TempDir) -> (Config, Store) {
let config = Config::load(tmp.path()).unwrap();
let store = Store::open(&config).unwrap();
(config, store)
}
fn index(store: &Store) -> SeriesIndex {
SeriesIndex::build(&store.list().unwrap())
}
fn key(id: &str, day: u8) -> OccurrenceKey {
let date = DateKey::from_date(
Date::from_calendar_date(2026, Month::January, day).expect("a real date"),
);
let series_id = SeriesId::parse(id).expect("a valid id");
OccurrenceKey::new(&series_id, &date)
}
fn request(id: &str, action: LifecycleAction, confirmed: bool) -> LifecycleRequest {
let request = LifecycleRequest::new(SeriesId::parse(id).unwrap(), action, "alice");
if confirmed {
request.confirm()
} else {
request
}
}
fn definition_of(store: &Store) -> tix::model::Issue {
store
.list()
.unwrap()
.into_iter()
.find(|issue| issue.number == 1)
.expect("the definition reads back")
}
fn state_of(store: &Store) -> (Option<SeriesLifecycle>, Vec<String>, Option<String>) {
let definition = definition_of(store);
let reference = definition.series.expect("a series table");
(reference.state, reference.skipped, reference.next_cursor)
}
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
fs::read_dir(dir)
.unwrap()
.filter_map(|entry| {
let entry = entry.unwrap();
let path = entry.path();
path.is_file().then(|| {
(
entry.file_name().to_string_lossy().to_string(),
fs::read(path).unwrap(),
)
})
})
.collect()
}
fn lifecycle_events(store: &Store) -> usize {
definition_of(store)
.history
.events()
.iter()
.filter(|event| event.kind == HistoryEventKind::TransitionAccepted)
.count()
}
#[test]
fn pause_and_resume_transition_the_state_and_append_one_event_each() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (_, store) = load(&tmp);
assert_eq!(state_of(&store).0, None, "active is the unset default");
let paused = apply(&store, &request("s-nightly", LifecycleAction::Pause, true)).unwrap();
assert!(paused.is_applied(), "{paused:?}");
assert_eq!(paused.as_str(), "applied");
assert_eq!(state_of(&store).0, Some(SeriesLifecycle::Paused));
assert_eq!(lifecycle_events(&store), 1, "one event per applied action");
let detail = match &paused {
LifecycleOutcome::Applied { detail, .. } => detail.clone(),
other => panic!("expected applied, got {other:?}"),
};
assert!(detail.contains("state active -> paused"), "{detail}");
let blocked = generate(
&Config::load(tmp.path()).unwrap(),
&store,
&index(&store),
&GenerationRequest::new(
SeriesId::parse("s-nightly").unwrap(),
key("s-nightly", 1),
"alice",
)
.confirm(),
)
.unwrap();
assert!(blocked.is_rejected(), "{blocked:?}");
assert!(blocked.render().contains("series_not_active"));
let resumed = apply(&store, &request("s-nightly", LifecycleAction::Resume, true)).unwrap();
assert!(resumed.is_applied(), "{resumed:?}");
assert_eq!(state_of(&store).0, Some(SeriesLifecycle::Active));
assert_eq!(lifecycle_events(&store), 2);
match &resumed {
LifecycleOutcome::Applied { detail, .. } => {
assert!(detail.contains("state paused -> active"), "{detail}")
}
other => panic!("expected applied, got {other:?}"),
}
}
#[test]
fn a_repeated_action_is_a_no_op_with_no_second_event() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (_, store) = load(&tmp);
let first = apply(&store, &request("s-nightly", LifecycleAction::Pause, true)).unwrap();
assert!(first.is_applied());
let after_first = snapshot(&dir);
let second = apply(&store, &request("s-nightly", LifecycleAction::Pause, true)).unwrap();
assert!(second.is_noop(), "{second:?}");
assert!(second.render().contains("already paused"));
assert_eq!(snapshot(&dir), after_first, "a no-op writes nothing");
assert_eq!(lifecycle_events(&store), 1, "and appends no event");
let resume_twice = apply(&store, &request("s-nightly", LifecycleAction::Resume, true)).unwrap();
assert!(resume_twice.is_applied(), "paused -> active");
let now_active = snapshot(&dir);
let again = apply(&store, &request("s-nightly", LifecycleAction::Resume, true)).unwrap();
assert!(again.is_noop(), "{again:?}");
assert_eq!(snapshot(&dir), now_active);
}
#[test]
fn end_is_terminal_and_ending_twice_is_a_no_op() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (_, store) = load(&tmp);
let ended = apply(&store, &request("s-nightly", LifecycleAction::End, true)).unwrap();
assert!(ended.is_applied(), "{ended:?}");
assert_eq!(state_of(&store).0, Some(SeriesLifecycle::Ended));
let after_end = snapshot(&dir);
let again = apply(&store, &request("s-nightly", LifecycleAction::End, true)).unwrap();
assert!(again.is_noop(), "{again:?}");
assert!(again.render().contains("already ended"));
assert_eq!(snapshot(&dir), after_end);
for action in [
LifecycleAction::Pause,
LifecycleAction::Resume,
LifecycleAction::Skip,
LifecycleAction::Defer,
LifecycleAction::Ignore,
] {
let mut request = request("s-nightly", action, true);
if action.needs_key() {
request = request.about(key("s-nightly", 2));
}
let refused = apply(&store, &request).unwrap();
assert!(refused.is_rejected(), "{}: {refused:?}", action.as_str());
let rendered = refused.render();
assert!(
rendered.contains(LifecycleDiagnosticKind::EndedSeries.as_str()),
"{}: {rendered}",
action.as_str()
);
}
assert_eq!(snapshot(&dir), after_end, "no refusal wrote anything");
assert_eq!(lifecycle_events(&store), 1, "and none appended an event");
}
#[test]
fn skip_records_the_date_so_generation_never_recreates_it() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (config, store) = load(&tmp);
let skipped = apply(
&store,
&request("s-nightly", LifecycleAction::Skip, true)
.about(key("s-nightly", 2))
.because("release week"),
)
.unwrap();
assert!(skipped.is_applied(), "{skipped:?}");
let (state, skipped_dates, cursor) = state_of(&store);
assert!(
state.is_none() || state == Some(SeriesLifecycle::Active),
"skip changes no state: `None` is the stored active default, {state:?}"
);
assert_eq!(skipped_dates, vec!["2026-01-02".to_string()]);
assert_eq!(cursor.as_deref(), Some("2026-01-02"));
match &skipped {
LifecycleOutcome::Applied { detail, .. } => {
assert!(
detail.contains("occurrence `s-nightly@2026-01-02`"),
"{detail}"
);
assert!(detail.contains(": release week"), "{detail}")
}
other => panic!("expected applied, got {other:?}"),
}
let reloaded = index(&store);
assert_eq!(reloaded.key_state(&key("s-nightly", 2)), KeyState::Skipped);
let refused = generate(
&config,
&store,
&reloaded,
&GenerationRequest::new(
SeriesId::parse("s-nightly").unwrap(),
key("s-nightly", 2),
"alice",
)
.confirm(),
)
.unwrap();
assert!(refused.is_noop(), "{refused:?}");
assert!(refused.render().contains("skipped"), "{}", refused.render());
let again = apply(
&store,
&request("s-nightly", LifecycleAction::Skip, true).about(key("s-nightly", 2)),
)
.unwrap();
assert!(again.is_noop(), "{again:?}");
assert!(again.render().contains("already recorded as skipped"));
assert_eq!(lifecycle_events(&store), 1);
}
#[test]
fn defer_moves_the_cursor_and_leaves_the_date_generatable() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (config, store) = load(&tmp);
let deferred = apply(
&store,
&request("s-nightly", LifecycleAction::Defer, true)
.about(key("s-nightly", 3))
.because("waiting on the vendor"),
)
.unwrap();
assert!(deferred.is_applied(), "{deferred:?}");
let (_, skipped_dates, cursor) = state_of(&store);
assert!(
skipped_dates.is_empty(),
"defer does not record the date as skipped"
);
assert_eq!(cursor.as_deref(), Some("2026-01-03"));
let again = apply(
&store,
&request("s-nightly", LifecycleAction::Defer, true).about(key("s-nightly", 3)),
)
.unwrap();
assert!(again.is_noop(), "{again:?}");
assert!(
again.render().contains("already past"),
"{}",
again.render()
);
assert_eq!(lifecycle_events(&store), 1, "the no-op appended nothing");
let reloaded = index(&store);
assert_eq!(reloaded.key_state(&key("s-nightly", 3)), KeyState::Free);
let created = generate(
&config,
&store,
&reloaded,
&GenerationRequest::new(
SeriesId::parse("s-nightly").unwrap(),
key("s-nightly", 3),
"alice",
)
.confirm(),
)
.unwrap();
assert!(created.is_created(), "{created:?}");
}
#[test]
fn ignore_records_the_date_like_skip_and_names_its_own_decision() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (_, store) = load(&tmp);
let ignored = apply(
&store,
&request("s-nightly", LifecycleAction::Ignore, true).about(key("s-nightly", 4)),
)
.unwrap();
assert!(ignored.is_applied(), "{ignored:?}");
let (_, skipped_dates, cursor) = state_of(&store);
assert_eq!(
skipped_dates,
vec!["2026-01-04".to_string()],
"ignore has the same schedule effect as skip: one way to say a date is not happening"
);
assert_eq!(cursor.as_deref(), Some("2026-01-04"));
match &ignored {
LifecycleOutcome::Applied { detail, .. } => {
assert!(detail.contains("ignore"), "{detail}");
assert!(!detail.contains("skip"), "{detail}");
}
other => panic!("expected applied, got {other:?}"),
}
}
#[test]
fn unconfirmed_missing_key_and_mismatched_key_are_rejected_inertly() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
write_issue(&dir, "2026-01-01-other.md", &definition(2, "s-other", ""));
let (_, store) = load(&tmp);
let before = snapshot(&dir);
let unconfirmed = apply(&store, &request("s-nightly", LifecycleAction::Pause, false)).unwrap();
assert!(unconfirmed.is_rejected(), "{unconfirmed:?}");
assert!(unconfirmed.render().contains("not_confirmed"));
let unknown = apply(&store, &request("s-nope", LifecycleAction::Pause, true)).unwrap();
assert!(unknown.is_rejected(), "{unknown:?}");
assert!(unknown.render().contains("unknown_series"));
let missing_key = apply(&store, &request("s-nightly", LifecycleAction::Skip, true)).unwrap();
assert!(missing_key.is_rejected(), "{missing_key:?}");
assert!(missing_key.render().contains("missing_occurrence_key"));
let mismatched = apply(
&store,
&request("s-nightly", LifecycleAction::Skip, true).about(key("s-other", 5)),
)
.unwrap();
assert!(mismatched.is_rejected(), "{mismatched:?}");
assert!(
mismatched
.render()
.contains("occurrence_key_series_mismatch")
);
assert_eq!(
snapshot(&dir),
before,
"every refusal leaves the files byte-identical"
);
assert_eq!(lifecycle_events(&store), 0, "and appends no event");
}
#[test]
fn an_occurrence_that_exists_cannot_be_skipped_or_deferred() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (config, store) = load(&tmp);
let created = generate(
&config,
&store,
&index(&store),
&GenerationRequest::new(
SeriesId::parse("s-nightly").unwrap(),
key("s-nightly", 1),
"alice",
)
.confirm(),
)
.unwrap();
assert!(created.is_created(), "{created:?}");
let after_create = snapshot(&dir);
for action in [
LifecycleAction::Skip,
LifecycleAction::Defer,
LifecycleAction::Ignore,
] {
let refused = apply(
&store,
&request("s-nightly", action, true).about(key("s-nightly", 1)),
)
.unwrap();
assert!(refused.is_rejected(), "{}: {refused:?}", action.as_str());
assert!(
refused.render().contains("occurrence_already_generated"),
"{}: {}",
action.as_str(),
refused.render()
);
}
assert_eq!(
snapshot(&dir),
after_create,
"an existing occurrence is untouched by all three"
);
}
#[test]
fn a_definition_that_lost_its_series_table_is_reported_rather_than_guessed() {
let (tmp, dir) = repo();
write_issue(
&dir,
"2026-01-01-nightly.md",
&definition(1, "s-nightly", ""),
);
let (_, store) = load(&tmp);
write_issue(
&dir,
"2026-01-01-nightly.md",
"number: 1\ntitle: \"Nightly close\"\nstatus: \"todo\"\ncreated: \"2026-01-01\"\n\
updated: \"2026-01-01\"\n",
);
let outcome = apply(&store, &request("s-nightly", LifecycleAction::Pause, true)).unwrap();
assert!(outcome.is_rejected(), "{outcome:?}");
assert!(
outcome.render().contains("unknown_series"),
"{}",
outcome.render()
);
}