use crate::error::render_diagnostics;
use crate::model::HistoryEventKind;
use crate::recurrence::rule::DateKey;
use crate::recurrence::series::{KeyState, OccurrenceKey, SeriesId, SeriesIndex, SeriesLifecycle};
use crate::storage::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleAction {
Pause,
Resume,
End,
Skip,
Defer,
Ignore,
}
impl LifecycleAction {
pub const ALL: [Self; 6] = [
Self::Pause,
Self::Resume,
Self::End,
Self::Skip,
Self::Defer,
Self::Ignore,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Pause => "pause",
Self::Resume => "resume",
Self::End => "end",
Self::Skip => "skip",
Self::Defer => "defer",
Self::Ignore => "ignore",
}
}
pub const fn needs_key(self) -> bool {
matches!(self, Self::Skip | Self::Defer | Self::Ignore)
}
pub const fn changes_state(self) -> bool {
matches!(self, Self::Pause | Self::Resume | Self::End)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleRequest {
pub series_id: SeriesId,
pub action: LifecycleAction,
pub occurrence_key: Option<OccurrenceKey>,
pub actor: String,
pub reason: Option<String>,
pub confirmed: bool,
}
impl LifecycleRequest {
pub fn new(series_id: SeriesId, action: LifecycleAction, actor: impl Into<String>) -> Self {
Self {
series_id,
action,
occurrence_key: None,
actor: actor.into(),
reason: None,
confirmed: false,
}
}
pub fn about(mut self, key: OccurrenceKey) -> Self {
self.occurrence_key = Some(key);
self
}
pub fn because(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn confirm(mut self) -> Self {
self.confirmed = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleDiagnosticKind {
NotConfirmed,
UnknownSeries,
EndedSeries,
MissingKey,
KeySeriesMismatch,
AlreadyGenerated,
DefinitionUnreadable,
Refused,
}
impl LifecycleDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfirmed => "not_confirmed",
Self::UnknownSeries => "unknown_series",
Self::EndedSeries => "series_ended",
Self::MissingKey => "missing_occurrence_key",
Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
Self::AlreadyGenerated => "occurrence_already_generated",
Self::DefinitionUnreadable => "definition_unreadable",
Self::Refused => "refused",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleDiagnostic {
pub kind: LifecycleDiagnosticKind,
pub message: String,
}
impl LifecycleDiagnostic {
fn new(kind: LifecycleDiagnosticKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn render(&self) -> String {
render_diagnostics(
"E-tix-series",
std::iter::once((self.kind.as_str(), self.message.as_str())),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleOutcome {
Applied {
series_id: SeriesId,
action: LifecycleAction,
state: SeriesLifecycle,
key: Option<OccurrenceKey>,
detail: String,
},
NoOp {
series_id: SeriesId,
action: LifecycleAction,
message: String,
},
Rejected {
diagnostics: Vec<LifecycleDiagnostic>,
},
}
impl LifecycleOutcome {
pub fn is_applied(&self) -> bool {
matches!(self, Self::Applied { .. })
}
pub fn is_noop(&self) -> bool {
matches!(self, Self::NoOp { .. })
}
pub fn is_rejected(&self) -> bool {
matches!(self, Self::Rejected { .. })
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Applied { .. } => "applied",
Self::NoOp { .. } => "no-op",
Self::Rejected { .. } => "rejected",
}
}
pub fn render(&self) -> String {
match self {
Self::Applied { action, detail, .. } => format!("{}: {detail}", action.as_str()),
Self::NoOp {
action, message, ..
} => {
format!("{}: no-op: {message}", action.as_str())
}
Self::Rejected { diagnostics } => {
let first = diagnostics
.first()
.map(LifecycleDiagnostic::render)
.unwrap_or_else(|| "rejected".to_string());
if diagnostics.len() > 1 {
format!("{first} (and {} more)", diagnostics.len() - 1)
} else {
first
}
}
}
}
}
#[allow(clippy::too_many_lines)]
pub fn apply(store: &Store, request: &LifecycleRequest) -> crate::error::Result<LifecycleOutcome> {
if !request.confirmed {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::NotConfirmed,
"a lifecycle action must be confirmed: no refresh, timer or detection \
can apply one",
)));
}
let issues = store.list()?;
let index = SeriesIndex::build(&issues);
let Some(series) = index.series_by_id(&request.series_id) else {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::UnknownSeries,
format!("no series `{}` is defined", request.series_id),
)));
};
let action = request.action;
if series.state == SeriesLifecycle::Ended && action != LifecycleAction::End {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::EndedSeries,
format!(
"series `{}` has ended; `end` is terminal, and only `resume` on a \
non-ended series or a new series can generate again",
series.id
),
)));
}
let mut date: Option<DateKey> = None;
if action.needs_key() {
let Some(key) = request.occurrence_key.as_ref() else {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::MissingKey,
format!(
"`{}` is about one occurrence and needs `--date` (the key \
`<series-id>@<YYYY-MM-DD>`)",
action.as_str()
),
)));
};
let (key_series, key_date) = match OccurrenceKey::parse(key.as_str()) {
Ok(parts) => parts,
Err(error) => {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::MissingKey,
error.first().message.clone(),
)));
}
};
if key_series != request.series_id {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::KeySeriesMismatch,
format!(
"key `{key}` names series `{key_series}` but the request names `{}`",
request.series_id
),
)));
}
if index.key_state(key) == KeyState::Generated {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::AlreadyGenerated,
format!(
"occurrence `{key}` already exists ({}); skipping, deferring or \
ignoring it would say something untrue about a file that is there",
index
.occurrence(key)
.map(|record| record.filename.clone())
.unwrap_or_else(|| "in the index".to_string())
),
)));
}
date = Some(key_date);
}
match action {
LifecycleAction::Pause if series.state == SeriesLifecycle::Paused => {
return Ok(noop(
series.id.clone(),
action,
"the series is already paused",
));
}
LifecycleAction::Resume if series.state == SeriesLifecycle::Active => {
return Ok(noop(
series.id.clone(),
action,
"the series is already active",
));
}
LifecycleAction::End if series.state == SeriesLifecycle::Ended => {
return Ok(noop(
series.id.clone(),
action,
"the series has already ended",
));
}
LifecycleAction::Skip | LifecycleAction::Ignore => {
if let Some(date) = date.as_ref()
&& series.is_skipped(date)
{
return Ok(noop(
series.id.clone(),
action,
&format!("`{date}` is already recorded as skipped"),
));
}
}
LifecycleAction::Defer => {
if let Some(date) = date.as_ref()
&& series.next_cursor.as_ref() == Some(date)
{
return Ok(noop(
series.id.clone(),
action,
&format!("the cursor is already past `{date}`"),
));
}
if let Some(date) = date.as_ref()
&& series.is_skipped(date)
{
return Ok(noop(
series.id.clone(),
action,
&format!("`{date}` is already recorded as skipped"),
));
}
}
_ => {}
}
let Some(definition) = issues
.iter()
.find(|issue| issue.number == series.issue_number)
else {
return Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::DefinitionUnreadable,
format!(
"series `{}` is defined by issue #{} ({}) but that issue could not be read",
series.id, series.issue_number, series.filename
),
)));
};
let before = series.state;
let after = match action {
LifecycleAction::Pause => SeriesLifecycle::Paused,
LifecycleAction::Resume => SeriesLifecycle::Active,
LifecycleAction::End => SeriesLifecycle::Ended,
LifecycleAction::Skip | LifecycleAction::Defer | LifecycleAction::Ignore => before,
};
let detail = lifecycle_detail(&series.id, action, date.as_ref(), before, after, request);
let written = store.transaction(|| {
let mut definition = definition.clone();
let reference = definition.series.as_mut().ok_or_else(|| {
crate::error::TixError::InvalidInput(format!(
"issue #{} no longer carries a series table",
definition.number
))
})?;
match action {
LifecycleAction::Pause | LifecycleAction::Resume | LifecycleAction::End => {
reference.state = Some(after);
}
LifecycleAction::Skip | LifecycleAction::Ignore => {
let date = date.expect("a keyed action carries its date");
if !reference.skipped.iter().any(|raw| raw == date.as_str()) {
reference.skipped.push(date.as_str().to_string());
}
reference.next_cursor = Some(date.as_str().to_string());
}
LifecycleAction::Defer => {
let date = date.expect("a keyed action carries its date");
reference.next_cursor = Some(date.as_str().to_string());
}
}
definition.history.append_now(
&request.actor,
HistoryEventKind::TransitionAccepted,
&detail,
);
store.update(&definition.filename, &definition)?;
Ok(())
});
match written {
Ok(()) => Ok(LifecycleOutcome::Applied {
series_id: series.id.clone(),
action,
state: after,
key: request.occurrence_key.clone(),
detail,
}),
Err(error) => Ok(rejected(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::Refused,
format!("the store refused the lifecycle write: {error}"),
))),
}
}
fn lifecycle_detail(
series: &SeriesId,
action: LifecycleAction,
date: Option<&DateKey>,
before: SeriesLifecycle,
after: SeriesLifecycle,
request: &LifecycleRequest,
) -> String {
let mut detail = format!("series `{series}` {}", action.as_str());
if let Some(date) = date {
detail.push_str(&format!(" occurrence `{series}@{date}`"));
}
if action.changes_state() && before != after {
detail.push_str(&format!(
" (state {} -> {})",
before.as_str(),
after.as_str()
));
}
if let Some(reason) = request.reason.as_deref() {
let reason = reason.trim();
if !reason.is_empty() {
detail.push_str(&format!(": {reason}"));
}
}
detail
}
fn rejected(diagnostic: LifecycleDiagnostic) -> LifecycleOutcome {
LifecycleOutcome::Rejected {
diagnostics: vec![diagnostic],
}
}
fn noop(series_id: SeriesId, action: LifecycleAction, message: &str) -> LifecycleOutcome {
LifecycleOutcome::NoOp {
series_id,
action,
message: message.to_string(),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn series_id() -> SeriesId {
SeriesId::parse("s-abc").expect("a valid id")
}
#[test]
fn every_action_has_one_stable_spelling() {
let spellings: Vec<&str> = LifecycleAction::ALL.iter().map(|a| a.as_str()).collect();
assert_eq!(
spellings,
vec!["pause", "resume", "end", "skip", "defer", "ignore"]
);
assert_eq!(LifecycleAction::ALL.len(), 6, "the six actions");
}
#[test]
fn only_the_occurrence_actions_need_a_key_and_only_three_change_state() {
for action in LifecycleAction::ALL {
assert_eq!(
action.needs_key(),
matches!(
action,
LifecycleAction::Skip | LifecycleAction::Defer | LifecycleAction::Ignore
),
"{}",
action.as_str()
);
assert_eq!(
action.changes_state(),
matches!(
action,
LifecycleAction::Pause | LifecycleAction::Resume | LifecycleAction::End
),
"{}",
action.as_str()
);
}
}
#[test]
fn a_request_is_unconfirmed_until_it_says_so() {
let request = LifecycleRequest::new(series_id(), LifecycleAction::Pause, "alice");
assert!(!request.confirmed);
assert!(request.occurrence_key.is_none());
let confirmed = request.clone().confirm();
assert!(confirmed.confirmed);
assert_eq!(confirmed.action, LifecycleAction::Pause);
}
#[test]
fn the_detail_names_the_action_the_key_the_state_change_and_the_reason() {
let date = DateKey::from_date(
time::Date::from_calendar_date(2026, time::Month::January, 2).expect("a date"),
);
let key = OccurrenceKey::new(&series_id(), &date);
let request = LifecycleRequest::new(series_id(), LifecycleAction::Skip, "alice")
.about(key)
.because("release week")
.confirm();
let detail = lifecycle_detail(
&series_id(),
LifecycleAction::Skip,
Some(&date),
SeriesLifecycle::Active,
SeriesLifecycle::Active,
&request,
);
assert!(detail.contains("series `s-abc` skip"), "{detail}");
assert!(detail.contains("occurrence `s-abc@2026-01-02`"), "{detail}");
assert!(detail.contains(": release week"), "{detail}");
assert!(
!detail.contains("state"),
"a keyed action changes no state: {detail}"
);
let paused = lifecycle_detail(
&series_id(),
LifecycleAction::Pause,
None,
SeriesLifecycle::Active,
SeriesLifecycle::Paused,
&LifecycleRequest::new(series_id(), LifecycleAction::Pause, "alice"),
);
assert!(paused.contains("(state active -> paused)"), "{paused}");
}
#[test]
fn outcomes_have_stable_words_and_render_their_reason() {
let applied = LifecycleOutcome::Applied {
series_id: series_id(),
action: LifecycleAction::Pause,
state: SeriesLifecycle::Paused,
key: None,
detail: "series `s-abc` pause (state active -> paused)".to_string(),
};
assert_eq!(applied.as_str(), "applied");
assert!(applied.is_applied());
assert!(applied.render().contains("pause"));
let noop = LifecycleOutcome::NoOp {
series_id: series_id(),
action: LifecycleAction::Pause,
message: "already paused".to_string(),
};
assert_eq!(noop.as_str(), "no-op");
assert!(noop.is_noop());
assert!(noop.render().contains("already paused"));
let rejected = LifecycleOutcome::Rejected {
diagnostics: vec![
LifecycleDiagnostic::new(
LifecycleDiagnosticKind::NotConfirmed,
"a lifecycle action must be confirmed",
),
LifecycleDiagnostic::new(LifecycleDiagnosticKind::UnknownSeries, "no series"),
],
};
assert_eq!(rejected.as_str(), "rejected");
assert!(rejected.is_rejected());
assert!(rejected.render().contains("not_confirmed"));
assert!(rejected.render().contains("and 1 more"));
}
}