1use crate::error::render_diagnostics;
68use crate::model::HistoryEventKind;
69use crate::recurrence::rule::DateKey;
70use crate::recurrence::series::{KeyState, OccurrenceKey, SeriesId, SeriesIndex, SeriesLifecycle};
71use crate::storage::Store;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum LifecycleAction {
76 Pause,
78 Resume,
80 End,
82 Skip,
84 Defer,
86 Ignore,
88}
89
90impl LifecycleAction {
91 pub const ALL: [Self; 6] = [
93 Self::Pause,
94 Self::Resume,
95 Self::End,
96 Self::Skip,
97 Self::Defer,
98 Self::Ignore,
99 ];
100
101 pub const fn as_str(self) -> &'static str {
103 match self {
104 Self::Pause => "pause",
105 Self::Resume => "resume",
106 Self::End => "end",
107 Self::Skip => "skip",
108 Self::Defer => "defer",
109 Self::Ignore => "ignore",
110 }
111 }
112
113 pub const fn needs_key(self) -> bool {
115 matches!(self, Self::Skip | Self::Defer | Self::Ignore)
116 }
117
118 pub const fn changes_state(self) -> bool {
121 matches!(self, Self::Pause | Self::Resume | Self::End)
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct LifecycleRequest {
128 pub series_id: SeriesId,
130 pub action: LifecycleAction,
132 pub occurrence_key: Option<OccurrenceKey>,
134 pub actor: String,
136 pub reason: Option<String>,
138 pub confirmed: bool,
140}
141
142impl LifecycleRequest {
143 pub fn new(series_id: SeriesId, action: LifecycleAction, actor: impl Into<String>) -> Self {
145 Self {
146 series_id,
147 action,
148 occurrence_key: None,
149 actor: actor.into(),
150 reason: None,
151 confirmed: false,
152 }
153 }
154
155 pub fn about(mut self, key: OccurrenceKey) -> Self {
157 self.occurrence_key = Some(key);
158 self
159 }
160
161 pub fn because(mut self, reason: impl Into<String>) -> Self {
163 self.reason = Some(reason.into());
164 self
165 }
166
167 pub fn confirm(mut self) -> Self {
169 self.confirmed = true;
170 self
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum LifecycleDiagnosticKind {
177 NotConfirmed,
179 UnknownSeries,
181 EndedSeries,
183 MissingKey,
185 KeySeriesMismatch,
187 AlreadyGenerated,
190 DefinitionUnreadable,
192 Refused,
194}
195
196impl LifecycleDiagnosticKind {
197 pub const fn as_str(self) -> &'static str {
199 match self {
200 Self::NotConfirmed => "not_confirmed",
201 Self::UnknownSeries => "unknown_series",
202 Self::EndedSeries => "series_ended",
203 Self::MissingKey => "missing_occurrence_key",
204 Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
205 Self::AlreadyGenerated => "occurrence_already_generated",
206 Self::DefinitionUnreadable => "definition_unreadable",
207 Self::Refused => "refused",
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct LifecycleDiagnostic {
215 pub kind: LifecycleDiagnosticKind,
217 pub message: String,
219}
220
221impl LifecycleDiagnostic {
222 fn new(kind: LifecycleDiagnosticKind, message: impl Into<String>) -> Self {
223 Self {
224 kind,
225 message: message.into(),
226 }
227 }
228
229 pub fn render(&self) -> String {
231 render_diagnostics(
232 "E-tix-series",
233 std::iter::once((self.kind.as_str(), self.message.as_str())),
234 )
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum LifecycleOutcome {
241 Applied {
243 series_id: SeriesId,
245 action: LifecycleAction,
247 state: SeriesLifecycle,
249 key: Option<OccurrenceKey>,
251 detail: String,
253 },
254 NoOp {
256 series_id: SeriesId,
258 action: LifecycleAction,
260 message: String,
262 },
263 Rejected {
265 diagnostics: Vec<LifecycleDiagnostic>,
267 },
268}
269
270impl LifecycleOutcome {
271 pub fn is_applied(&self) -> bool {
273 matches!(self, Self::Applied { .. })
274 }
275
276 pub fn is_noop(&self) -> bool {
278 matches!(self, Self::NoOp { .. })
279 }
280
281 pub fn is_rejected(&self) -> bool {
283 matches!(self, Self::Rejected { .. })
284 }
285
286 pub fn as_str(&self) -> &'static str {
288 match self {
289 Self::Applied { .. } => "applied",
290 Self::NoOp { .. } => "no-op",
291 Self::Rejected { .. } => "rejected",
292 }
293 }
294
295 pub fn render(&self) -> String {
297 match self {
298 Self::Applied { action, detail, .. } => format!("{}: {detail}", action.as_str()),
299 Self::NoOp {
300 action, message, ..
301 } => {
302 format!("{}: no-op: {message}", action.as_str())
303 }
304 Self::Rejected { diagnostics } => {
305 let first = diagnostics
306 .first()
307 .map(LifecycleDiagnostic::render)
308 .unwrap_or_else(|| "rejected".to_string());
309 if diagnostics.len() > 1 {
310 format!("{first} (and {} more)", diagnostics.len() - 1)
311 } else {
312 first
313 }
314 }
315 }
316 }
317}
318
319#[allow(clippy::too_many_lines)]
325pub fn apply(store: &Store, request: &LifecycleRequest) -> crate::error::Result<LifecycleOutcome> {
326 if !request.confirmed {
328 return Ok(rejected(LifecycleDiagnostic::new(
329 LifecycleDiagnosticKind::NotConfirmed,
330 "a lifecycle action must be confirmed: no refresh, timer or detection \
331 can apply one",
332 )));
333 }
334 let issues = store.list()?;
338 let index = SeriesIndex::build(&issues);
339 let Some(series) = index.series_by_id(&request.series_id) else {
340 return Ok(rejected(LifecycleDiagnostic::new(
341 LifecycleDiagnosticKind::UnknownSeries,
342 format!("no series `{}` is defined", request.series_id),
343 )));
344 };
345 let action = request.action;
346
347 if series.state == SeriesLifecycle::Ended && action != LifecycleAction::End {
349 return Ok(rejected(LifecycleDiagnostic::new(
350 LifecycleDiagnosticKind::EndedSeries,
351 format!(
352 "series `{}` has ended; `end` is terminal, and only `resume` on a \
353 non-ended series or a new series can generate again",
354 series.id
355 ),
356 )));
357 }
358
359 let mut date: Option<DateKey> = None;
361 if action.needs_key() {
362 let Some(key) = request.occurrence_key.as_ref() else {
363 return Ok(rejected(LifecycleDiagnostic::new(
364 LifecycleDiagnosticKind::MissingKey,
365 format!(
366 "`{}` is about one occurrence and needs `--date` (the key \
367 `<series-id>@<YYYY-MM-DD>`)",
368 action.as_str()
369 ),
370 )));
371 };
372 let (key_series, key_date) = match OccurrenceKey::parse(key.as_str()) {
373 Ok(parts) => parts,
374 Err(error) => {
375 return Ok(rejected(LifecycleDiagnostic::new(
376 LifecycleDiagnosticKind::MissingKey,
377 error.first().message.clone(),
378 )));
379 }
380 };
381 if key_series != request.series_id {
382 return Ok(rejected(LifecycleDiagnostic::new(
383 LifecycleDiagnosticKind::KeySeriesMismatch,
384 format!(
385 "key `{key}` names series `{key_series}` but the request names `{}`",
386 request.series_id
387 ),
388 )));
389 }
390 if index.key_state(key) == KeyState::Generated {
394 return Ok(rejected(LifecycleDiagnostic::new(
395 LifecycleDiagnosticKind::AlreadyGenerated,
396 format!(
397 "occurrence `{key}` already exists ({}); skipping, deferring or \
398 ignoring it would say something untrue about a file that is there",
399 index
400 .occurrence(key)
401 .map(|record| record.filename.clone())
402 .unwrap_or_else(|| "in the index".to_string())
403 ),
404 )));
405 }
406 date = Some(key_date);
407 }
408
409 match action {
411 LifecycleAction::Pause if series.state == SeriesLifecycle::Paused => {
412 return Ok(noop(
413 series.id.clone(),
414 action,
415 "the series is already paused",
416 ));
417 }
418 LifecycleAction::Resume if series.state == SeriesLifecycle::Active => {
419 return Ok(noop(
420 series.id.clone(),
421 action,
422 "the series is already active",
423 ));
424 }
425 LifecycleAction::End if series.state == SeriesLifecycle::Ended => {
426 return Ok(noop(
427 series.id.clone(),
428 action,
429 "the series has already ended",
430 ));
431 }
432 LifecycleAction::Skip | LifecycleAction::Ignore => {
433 if let Some(date) = date.as_ref()
434 && series.is_skipped(date)
435 {
436 return Ok(noop(
437 series.id.clone(),
438 action,
439 &format!("`{date}` is already recorded as skipped"),
440 ));
441 }
442 }
443 LifecycleAction::Defer => {
444 if let Some(date) = date.as_ref()
445 && series.next_cursor.as_ref() == Some(date)
446 {
447 return Ok(noop(
448 series.id.clone(),
449 action,
450 &format!("the cursor is already past `{date}`"),
451 ));
452 }
453 if let Some(date) = date.as_ref()
454 && series.is_skipped(date)
455 {
456 return Ok(noop(
457 series.id.clone(),
458 action,
459 &format!("`{date}` is already recorded as skipped"),
460 ));
461 }
462 }
463 _ => {}
464 }
465
466 let Some(definition) = issues
468 .iter()
469 .find(|issue| issue.number == series.issue_number)
470 else {
471 return Ok(rejected(LifecycleDiagnostic::new(
472 LifecycleDiagnosticKind::DefinitionUnreadable,
473 format!(
474 "series `{}` is defined by issue #{} ({}) but that issue could not be read",
475 series.id, series.issue_number, series.filename
476 ),
477 )));
478 };
479
480 let before = series.state;
481 let after = match action {
482 LifecycleAction::Pause => SeriesLifecycle::Paused,
483 LifecycleAction::Resume => SeriesLifecycle::Active,
484 LifecycleAction::End => SeriesLifecycle::Ended,
485 LifecycleAction::Skip | LifecycleAction::Defer | LifecycleAction::Ignore => before,
486 };
487 let detail = lifecycle_detail(&series.id, action, date.as_ref(), before, after, request);
488
489 let written = store.transaction(|| {
490 let mut definition = definition.clone();
491 let reference = definition.series.as_mut().ok_or_else(|| {
492 crate::error::TixError::InvalidInput(format!(
493 "issue #{} no longer carries a series table",
494 definition.number
495 ))
496 })?;
497 match action {
498 LifecycleAction::Pause | LifecycleAction::Resume | LifecycleAction::End => {
499 reference.state = Some(after);
500 }
501 LifecycleAction::Skip | LifecycleAction::Ignore => {
502 let date = date.expect("a keyed action carries its date");
505 if !reference.skipped.iter().any(|raw| raw == date.as_str()) {
506 reference.skipped.push(date.as_str().to_string());
507 }
508 reference.next_cursor = Some(date.as_str().to_string());
509 }
510 LifecycleAction::Defer => {
511 let date = date.expect("a keyed action carries its date");
512 reference.next_cursor = Some(date.as_str().to_string());
513 }
514 }
515 definition.history.append_now(
516 &request.actor,
517 HistoryEventKind::TransitionAccepted,
518 &detail,
519 );
520 store.update(&definition.filename, &definition)?;
521 Ok(())
522 });
523
524 match written {
525 Ok(()) => Ok(LifecycleOutcome::Applied {
526 series_id: series.id.clone(),
527 action,
528 state: after,
529 key: request.occurrence_key.clone(),
530 detail,
531 }),
532 Err(error) => Ok(rejected(LifecycleDiagnostic::new(
533 LifecycleDiagnosticKind::Refused,
534 format!("the store refused the lifecycle write: {error}"),
535 ))),
536 }
537}
538
539fn lifecycle_detail(
545 series: &SeriesId,
546 action: LifecycleAction,
547 date: Option<&DateKey>,
548 before: SeriesLifecycle,
549 after: SeriesLifecycle,
550 request: &LifecycleRequest,
551) -> String {
552 let mut detail = format!("series `{series}` {}", action.as_str());
553 if let Some(date) = date {
554 detail.push_str(&format!(" occurrence `{series}@{date}`"));
555 }
556 if action.changes_state() && before != after {
557 detail.push_str(&format!(
558 " (state {} -> {})",
559 before.as_str(),
560 after.as_str()
561 ));
562 }
563 if let Some(reason) = request.reason.as_deref() {
564 let reason = reason.trim();
565 if !reason.is_empty() {
566 detail.push_str(&format!(": {reason}"));
567 }
568 }
569 detail
570}
571
572fn rejected(diagnostic: LifecycleDiagnostic) -> LifecycleOutcome {
574 LifecycleOutcome::Rejected {
575 diagnostics: vec![diagnostic],
576 }
577}
578
579fn noop(series_id: SeriesId, action: LifecycleAction, message: &str) -> LifecycleOutcome {
581 LifecycleOutcome::NoOp {
582 series_id,
583 action,
584 message: message.to_string(),
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 #[allow(clippy::wildcard_imports)]
591 use super::*;
592
593 fn series_id() -> SeriesId {
594 SeriesId::parse("s-abc").expect("a valid id")
595 }
596
597 #[test]
598 fn every_action_has_one_stable_spelling() {
599 let spellings: Vec<&str> = LifecycleAction::ALL.iter().map(|a| a.as_str()).collect();
600 assert_eq!(
601 spellings,
602 vec!["pause", "resume", "end", "skip", "defer", "ignore"]
603 );
604 assert_eq!(LifecycleAction::ALL.len(), 6, "the six actions");
605 }
606
607 #[test]
608 fn only_the_occurrence_actions_need_a_key_and_only_three_change_state() {
609 for action in LifecycleAction::ALL {
610 assert_eq!(
611 action.needs_key(),
612 matches!(
613 action,
614 LifecycleAction::Skip | LifecycleAction::Defer | LifecycleAction::Ignore
615 ),
616 "{}",
617 action.as_str()
618 );
619 assert_eq!(
620 action.changes_state(),
621 matches!(
622 action,
623 LifecycleAction::Pause | LifecycleAction::Resume | LifecycleAction::End
624 ),
625 "{}",
626 action.as_str()
627 );
628 }
629 }
630
631 #[test]
632 fn a_request_is_unconfirmed_until_it_says_so() {
633 let request = LifecycleRequest::new(series_id(), LifecycleAction::Pause, "alice");
634 assert!(!request.confirmed);
635 assert!(request.occurrence_key.is_none());
636 let confirmed = request.clone().confirm();
637 assert!(confirmed.confirmed);
638 assert_eq!(confirmed.action, LifecycleAction::Pause);
639 }
640
641 #[test]
642 fn the_detail_names_the_action_the_key_the_state_change_and_the_reason() {
643 let date = DateKey::from_date(
644 time::Date::from_calendar_date(2026, time::Month::January, 2).expect("a date"),
645 );
646 let key = OccurrenceKey::new(&series_id(), &date);
647 let request = LifecycleRequest::new(series_id(), LifecycleAction::Skip, "alice")
648 .about(key)
649 .because("release week")
650 .confirm();
651 let detail = lifecycle_detail(
652 &series_id(),
653 LifecycleAction::Skip,
654 Some(&date),
655 SeriesLifecycle::Active,
656 SeriesLifecycle::Active,
657 &request,
658 );
659 assert!(detail.contains("series `s-abc` skip"), "{detail}");
660 assert!(detail.contains("occurrence `s-abc@2026-01-02`"), "{detail}");
661 assert!(detail.contains(": release week"), "{detail}");
662 assert!(
663 !detail.contains("state"),
664 "a keyed action changes no state: {detail}"
665 );
666
667 let paused = lifecycle_detail(
668 &series_id(),
669 LifecycleAction::Pause,
670 None,
671 SeriesLifecycle::Active,
672 SeriesLifecycle::Paused,
673 &LifecycleRequest::new(series_id(), LifecycleAction::Pause, "alice"),
674 );
675 assert!(paused.contains("(state active -> paused)"), "{paused}");
676 }
677
678 #[test]
679 fn outcomes_have_stable_words_and_render_their_reason() {
680 let applied = LifecycleOutcome::Applied {
681 series_id: series_id(),
682 action: LifecycleAction::Pause,
683 state: SeriesLifecycle::Paused,
684 key: None,
685 detail: "series `s-abc` pause (state active -> paused)".to_string(),
686 };
687 assert_eq!(applied.as_str(), "applied");
688 assert!(applied.is_applied());
689 assert!(applied.render().contains("pause"));
690
691 let noop = LifecycleOutcome::NoOp {
692 series_id: series_id(),
693 action: LifecycleAction::Pause,
694 message: "already paused".to_string(),
695 };
696 assert_eq!(noop.as_str(), "no-op");
697 assert!(noop.is_noop());
698 assert!(noop.render().contains("already paused"));
699
700 let rejected = LifecycleOutcome::Rejected {
701 diagnostics: vec![
702 LifecycleDiagnostic::new(
703 LifecycleDiagnosticKind::NotConfirmed,
704 "a lifecycle action must be confirmed",
705 ),
706 LifecycleDiagnostic::new(LifecycleDiagnosticKind::UnknownSeries, "no series"),
707 ],
708 };
709 assert_eq!(rejected.as_str(), "rejected");
710 assert!(rejected.is_rejected());
711 assert!(rejected.render().contains("not_confirmed"));
712 assert!(rejected.render().contains("and 1 more"));
713 }
714}