Skip to main content

tix/recurrence/
lifecycle.rs

1//! Explicit lifecycle controls with append-only History (mission
2//! `recurring-issues-01M3C7G7`, WP05; `data-model.md` §4/§7 and
3//! `contracts/series-generator.md`).
4//!
5//! # A lifecycle is a set of decisions, not a process
6//!
7//! Nothing here runs on a timer, on refresh, or because detection said something
8//! was due. [`apply`] performs exactly one **confirmed** action on one series and
9//! returns what happened. Detection reads state; this writes it; and only a caller
10//! who confirmed the action can reach the write (C-003).
11//!
12//! # The six actions, and what each one does to the schedule
13//!
14//! Every action is a state change on the *series definition issue*
15//! (`series.state`, `series.skipped`, `series.next_cursor`), applied in one
16//! [`Store::transaction`] with one appended History entry. "Next candidate" is the
17//! `next_cursor` the detector reads, so the table below is the documented
18//! next-candidate behaviour `data-model.md` §4 asks for:
19//!
20//! | action   | `state`            | `skipped`  | `next_cursor` | next candidate is |
21//! |----------|--------------------|------------|---------------|-------------------|
22//! | `pause`  | `active` → `paused`| unchanged  | unchanged     | none — generation is suspended until `resume` |
23//! | `resume` | `paused` → `active`| unchanged  | unchanged     | whatever the cursor says |
24//! | `end`    | any → `ended`      | unchanged  | unchanged     | none — `ended` is terminal |
25//! | `skip`   | unchanged          | + the date | → the date    | the date after the skipped one |
26//! | `defer`  | unchanged          | unchanged  | → the date    | the date after the deferred one; the deferred date stays **explicitly** generatable |
27//! | `ignore` | unchanged          | + the date | → the date    | the date after it |
28//!
29//! `skip` and `ignore` have the *same schedule effect* on purpose: a schedule has
30//! exactly one way to say "this date is not happening" (the date is recorded, so
31//! generation can never recreate it). What differs is the decision in the log —
32//! "not doing this one" versus "stop offering this one" — and the log is where a
33//! reader looks for a decision. `defer` is the one that keeps the date available:
34//! it only moves the cursor, so an explicit `generate` for that date still works.
35//!
36//! # It decides against the file, not a caller's snapshot
37//!
38//! [`apply`] takes a [`Store`] and reads the series itself. A caller's
39//! [`SeriesIndex`] is a *snapshot* — one built to render a list or a warning is a
40//! keystroke old by the time an action arrives — and deciding "is this series
41//! already paused?" from it can apply the same action twice and append two events.
42//! The index stays the right input for detection, which is read-only and may be
43//! minutes stale; a state transition is not, so this layer re-reads.
44//!
45//! # Idempotence, and the terminal case
46//!
47//! An action that is already in effect is a **no-op**: pausing a paused series,
48//! resuming an active one, ending an ended one, skipping a date that is already
49//! recorded, deferring a date the cursor is already past. A no-op appends nothing,
50//! which is what makes "repeated actions have exact event counts" true.
51//!
52//! `ended` is terminal for everything except `end` itself (which is a no-op):
53//! pausing, resuming, skipping, deferring or ignoring an ended series is a
54//! **rejection**, with nothing written.
55//!
56//! # History
57//!
58//! Each applied action appends **one** [`HistoryEventKind::TransitionAccepted`]
59//! entry to the defining issue, carrying the actor, the action, the occurrence key
60//! where there is one, and the reason where one was given. That is the crate's
61//! existing "an accepted decision about this issue's state was applied" kind, and a
62//! series definition is an issue whose state these actions change; writing a
63//! second audit log, or a kind per action, would fragment the one log the mission's
64//! C-007 points at. Prior entries are never rewritten: the history is append-only
65//! by construction ([`crate::model::History::append_now`] pushes).
66
67use 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/// The six lifecycle actions (`data-model.md` §7).
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum LifecycleAction {
76    /// Suspend generation until an explicit resume.
77    Pause,
78    /// Resume a paused series. Never inferred from a refresh.
79    Resume,
80    /// End the series. Terminal for generation.
81    End,
82    /// Record one occurrence date as skipped, so it is never generated.
83    Skip,
84    /// Move the candidate on without recording the date as skipped.
85    Defer,
86    /// Stop offering one occurrence date: recorded, like `skip`, as a decision.
87    Ignore,
88}
89
90impl LifecycleAction {
91    /// Every action, in the order a surface should offer them.
92    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    /// The stable spelling the CLI and the MCP tools print.
102    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    /// Whether the action is about one occurrence and therefore needs a key.
114    pub const fn needs_key(self) -> bool {
115        matches!(self, Self::Skip | Self::Defer | Self::Ignore)
116    }
117
118    /// Whether the action changes the series' lifecycle state (as opposed to one
119    /// occurrence's standing).
120    pub const fn changes_state(self) -> bool {
121        matches!(self, Self::Pause | Self::Resume | Self::End)
122    }
123}
124
125/// One confirmed lifecycle action on one series (T023/T024).
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct LifecycleRequest {
128    /// The series to act on.
129    pub series_id: SeriesId,
130    /// Which action.
131    pub action: LifecycleAction,
132    /// The occurrence key, required by `skip`/`defer`/`ignore`.
133    pub occurrence_key: Option<OccurrenceKey>,
134    /// Who acted; lands in the History entry.
135    pub actor: String,
136    /// Why, when the caller gave one.
137    pub reason: Option<String>,
138    /// Whether the caller confirmed the action. Unconfirmed requests are inert.
139    pub confirmed: bool,
140}
141
142impl LifecycleRequest {
143    /// A request that is **not** yet confirmed.
144    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    /// Name the occurrence the action is about.
156    pub fn about(mut self, key: OccurrenceKey) -> Self {
157        self.occurrence_key = Some(key);
158        self
159    }
160
161    /// Record why.
162    pub fn because(mut self, reason: impl Into<String>) -> Self {
163        self.reason = Some(reason.into());
164        self
165    }
166
167    /// Record the confirmation.
168    pub fn confirm(mut self) -> Self {
169        self.confirmed = true;
170        self
171    }
172}
173
174/// Why a lifecycle action was refused.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum LifecycleDiagnosticKind {
177    /// The request was not confirmed, so nothing was even read.
178    NotConfirmed,
179    /// No series with that id is defined.
180    UnknownSeries,
181    /// The series has ended; only `end` is idempotent on it, and it is a no-op.
182    EndedSeries,
183    /// The action is about an occurrence but no key was given.
184    MissingKey,
185    /// The key names a different series than the request.
186    KeySeriesMismatch,
187    /// The action is about an occurrence that already exists, so there is nothing
188    /// to skip, defer or ignore.
189    AlreadyGenerated,
190    /// The issue defining the series could not be read.
191    DefinitionUnreadable,
192    /// The store refused the write.
193    Refused,
194}
195
196impl LifecycleDiagnosticKind {
197    /// The stable spelling the CLI and the MCP tools print.
198    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/// One actionable reason a lifecycle action was refused.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct LifecycleDiagnostic {
215    /// What was wrong.
216    pub kind: LifecycleDiagnosticKind,
217    /// One sentence saying what to do about it.
218    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    /// Render through the crate's one diagnostic renderer.
230    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/// The result of one lifecycle action (the outcome matrix T026 asks for).
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum LifecycleOutcome {
241    /// The action took effect and one History entry was appended.
242    Applied {
243        /// The series.
244        series_id: SeriesId,
245        /// What was applied.
246        action: LifecycleAction,
247        /// The lifecycle state afterwards.
248        state: SeriesLifecycle,
249        /// The occurrence the action was about, when it was about one.
250        key: Option<OccurrenceKey>,
251        /// The sentence written to History.
252        detail: String,
253    },
254    /// The action was already in effect: nothing was written, including no event.
255    NoOp {
256        /// The series.
257        series_id: SeriesId,
258        /// What was asked for.
259        action: LifecycleAction,
260        /// One sentence saying why nothing changed.
261        message: String,
262    },
263    /// The action was refused. Nothing was written.
264    Rejected {
265        /// Every reason, in the order they were found.
266        diagnostics: Vec<LifecycleDiagnostic>,
267    },
268}
269
270impl LifecycleOutcome {
271    /// Whether the action took effect.
272    pub fn is_applied(&self) -> bool {
273        matches!(self, Self::Applied { .. })
274    }
275
276    /// Whether it was already in effect.
277    pub fn is_noop(&self) -> bool {
278        matches!(self, Self::NoOp { .. })
279    }
280
281    /// Whether it was refused.
282    pub fn is_rejected(&self) -> bool {
283        matches!(self, Self::Rejected { .. })
284    }
285
286    /// The stable word a surface prints: `applied`, `no-op` or `rejected`.
287    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    /// One line for a surface.
296    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/// Apply one confirmed lifecycle action (T023-T026).
320///
321/// Reads the series **from the store** (see the module docs: a state transition
322/// must decide against the file), writes through the same store, and returns an
323/// outcome for every decision it makes; only a genuine read failure propagates.
324#[allow(clippy::too_many_lines)]
325pub fn apply(store: &Store, request: &LifecycleRequest) -> crate::error::Result<LifecycleOutcome> {
326    // Consent first, and before the store is touched.
327    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    // One read of the repository: the series identity, the occurrence keys and the
335    // definition issue all come from it, so nothing is decided from a snapshot that
336    // may already be out of date.
337    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    // `ended` is terminal for every action but `end` itself (a no-op).
348    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    // The occurrence-bound actions need a key, and it must name this series.
360    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        // An occurrence that exists cannot be skipped, deferred or ignored: those
391        // actions decide about a date that has not happened. `tix delete` removes
392        // an occurrence; this never pretends to.
393        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    // The no-op matrix: an action already in effect appends nothing.
410    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    // The definition issue is where all of this lives.
467    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                // The same schedule effect for both, and no second field to keep in
503                // step: the decision is what the History entry records.
504                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
539/// The one History sentence an applied action writes.
540///
541/// It names the action, the occurrence where there is one, the state change where
542/// there was one, and the caller's reason when they gave one — so an audit reader
543/// never has to reconstruct why from the diff.
544fn 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
572/// One refusal, as an outcome.
573fn rejected(diagnostic: LifecycleDiagnostic) -> LifecycleOutcome {
574    LifecycleOutcome::Rejected {
575        diagnostics: vec![diagnostic],
576    }
577}
578
579/// One no-op, as an outcome.
580fn 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}