tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
//! Explicit lifecycle controls with append-only History (mission
//! `recurring-issues-01M3C7G7`, WP05; `data-model.md` §4/§7 and
//! `contracts/series-generator.md`).
//!
//! # A lifecycle is a set of decisions, not a process
//!
//! Nothing here runs on a timer, on refresh, or because detection said something
//! was due. [`apply`] performs exactly one **confirmed** action on one series and
//! returns what happened. Detection reads state; this writes it; and only a caller
//! who confirmed the action can reach the write (C-003).
//!
//! # The six actions, and what each one does to the schedule
//!
//! Every action is a state change on the *series definition issue*
//! (`series.state`, `series.skipped`, `series.next_cursor`), applied in one
//! [`Store::transaction`] with one appended History entry. "Next candidate" is the
//! `next_cursor` the detector reads, so the table below is the documented
//! next-candidate behaviour `data-model.md` §4 asks for:
//!
//! | action   | `state`            | `skipped`  | `next_cursor` | next candidate is |
//! |----------|--------------------|------------|---------------|-------------------|
//! | `pause`  | `active` → `paused`| unchanged  | unchanged     | none — generation is suspended until `resume` |
//! | `resume` | `paused` → `active`| unchanged  | unchanged     | whatever the cursor says |
//! | `end`    | any → `ended`      | unchanged  | unchanged     | none — `ended` is terminal |
//! | `skip`   | unchanged          | + the date | → the date    | the date after the skipped one |
//! | `defer`  | unchanged          | unchanged  | → the date    | the date after the deferred one; the deferred date stays **explicitly** generatable |
//! | `ignore` | unchanged          | + the date | → the date    | the date after it |
//!
//! `skip` and `ignore` have the *same schedule effect* on purpose: a schedule has
//! exactly one way to say "this date is not happening" (the date is recorded, so
//! generation can never recreate it). What differs is the decision in the log —
//! "not doing this one" versus "stop offering this one" — and the log is where a
//! reader looks for a decision. `defer` is the one that keeps the date available:
//! it only moves the cursor, so an explicit `generate` for that date still works.
//!
//! # It decides against the file, not a caller's snapshot
//!
//! [`apply`] takes a [`Store`] and reads the series itself. A caller's
//! [`SeriesIndex`] is a *snapshot* — one built to render a list or a warning is a
//! keystroke old by the time an action arrives — and deciding "is this series
//! already paused?" from it can apply the same action twice and append two events.
//! The index stays the right input for detection, which is read-only and may be
//! minutes stale; a state transition is not, so this layer re-reads.
//!
//! # Idempotence, and the terminal case
//!
//! An action that is already in effect is a **no-op**: pausing a paused series,
//! resuming an active one, ending an ended one, skipping a date that is already
//! recorded, deferring a date the cursor is already past. A no-op appends nothing,
//! which is what makes "repeated actions have exact event counts" true.
//!
//! `ended` is terminal for everything except `end` itself (which is a no-op):
//! pausing, resuming, skipping, deferring or ignoring an ended series is a
//! **rejection**, with nothing written.
//!
//! # History
//!
//! Each applied action appends **one** [`HistoryEventKind::TransitionAccepted`]
//! entry to the defining issue, carrying the actor, the action, the occurrence key
//! where there is one, and the reason where one was given. That is the crate's
//! existing "an accepted decision about this issue's state was applied" kind, and a
//! series definition is an issue whose state these actions change; writing a
//! second audit log, or a kind per action, would fragment the one log the mission's
//! C-007 points at. Prior entries are never rewritten: the history is append-only
//! by construction ([`crate::model::History::append_now`] pushes).

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;

/// The six lifecycle actions (`data-model.md` §7).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleAction {
    /// Suspend generation until an explicit resume.
    Pause,
    /// Resume a paused series. Never inferred from a refresh.
    Resume,
    /// End the series. Terminal for generation.
    End,
    /// Record one occurrence date as skipped, so it is never generated.
    Skip,
    /// Move the candidate on without recording the date as skipped.
    Defer,
    /// Stop offering one occurrence date: recorded, like `skip`, as a decision.
    Ignore,
}

impl LifecycleAction {
    /// Every action, in the order a surface should offer them.
    pub const ALL: [Self; 6] = [
        Self::Pause,
        Self::Resume,
        Self::End,
        Self::Skip,
        Self::Defer,
        Self::Ignore,
    ];

    /// The stable spelling the CLI and the MCP tools print.
    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",
        }
    }

    /// Whether the action is about one occurrence and therefore needs a key.
    pub const fn needs_key(self) -> bool {
        matches!(self, Self::Skip | Self::Defer | Self::Ignore)
    }

    /// Whether the action changes the series' lifecycle state (as opposed to one
    /// occurrence's standing).
    pub const fn changes_state(self) -> bool {
        matches!(self, Self::Pause | Self::Resume | Self::End)
    }
}

/// One confirmed lifecycle action on one series (T023/T024).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleRequest {
    /// The series to act on.
    pub series_id: SeriesId,
    /// Which action.
    pub action: LifecycleAction,
    /// The occurrence key, required by `skip`/`defer`/`ignore`.
    pub occurrence_key: Option<OccurrenceKey>,
    /// Who acted; lands in the History entry.
    pub actor: String,
    /// Why, when the caller gave one.
    pub reason: Option<String>,
    /// Whether the caller confirmed the action. Unconfirmed requests are inert.
    pub confirmed: bool,
}

impl LifecycleRequest {
    /// A request that is **not** yet confirmed.
    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,
        }
    }

    /// Name the occurrence the action is about.
    pub fn about(mut self, key: OccurrenceKey) -> Self {
        self.occurrence_key = Some(key);
        self
    }

    /// Record why.
    pub fn because(mut self, reason: impl Into<String>) -> Self {
        self.reason = Some(reason.into());
        self
    }

    /// Record the confirmation.
    pub fn confirm(mut self) -> Self {
        self.confirmed = true;
        self
    }
}

/// Why a lifecycle action was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleDiagnosticKind {
    /// The request was not confirmed, so nothing was even read.
    NotConfirmed,
    /// No series with that id is defined.
    UnknownSeries,
    /// The series has ended; only `end` is idempotent on it, and it is a no-op.
    EndedSeries,
    /// The action is about an occurrence but no key was given.
    MissingKey,
    /// The key names a different series than the request.
    KeySeriesMismatch,
    /// The action is about an occurrence that already exists, so there is nothing
    /// to skip, defer or ignore.
    AlreadyGenerated,
    /// The issue defining the series could not be read.
    DefinitionUnreadable,
    /// The store refused the write.
    Refused,
}

impl LifecycleDiagnosticKind {
    /// The stable spelling the CLI and the MCP tools print.
    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",
        }
    }
}

/// One actionable reason a lifecycle action was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleDiagnostic {
    /// What was wrong.
    pub kind: LifecycleDiagnosticKind,
    /// One sentence saying what to do about it.
    pub message: String,
}

impl LifecycleDiagnostic {
    fn new(kind: LifecycleDiagnosticKind, message: impl Into<String>) -> Self {
        Self {
            kind,
            message: message.into(),
        }
    }

    /// Render through the crate's one diagnostic renderer.
    pub fn render(&self) -> String {
        render_diagnostics(
            "E-tix-series",
            std::iter::once((self.kind.as_str(), self.message.as_str())),
        )
    }
}

/// The result of one lifecycle action (the outcome matrix T026 asks for).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleOutcome {
    /// The action took effect and one History entry was appended.
    Applied {
        /// The series.
        series_id: SeriesId,
        /// What was applied.
        action: LifecycleAction,
        /// The lifecycle state afterwards.
        state: SeriesLifecycle,
        /// The occurrence the action was about, when it was about one.
        key: Option<OccurrenceKey>,
        /// The sentence written to History.
        detail: String,
    },
    /// The action was already in effect: nothing was written, including no event.
    NoOp {
        /// The series.
        series_id: SeriesId,
        /// What was asked for.
        action: LifecycleAction,
        /// One sentence saying why nothing changed.
        message: String,
    },
    /// The action was refused. Nothing was written.
    Rejected {
        /// Every reason, in the order they were found.
        diagnostics: Vec<LifecycleDiagnostic>,
    },
}

impl LifecycleOutcome {
    /// Whether the action took effect.
    pub fn is_applied(&self) -> bool {
        matches!(self, Self::Applied { .. })
    }

    /// Whether it was already in effect.
    pub fn is_noop(&self) -> bool {
        matches!(self, Self::NoOp { .. })
    }

    /// Whether it was refused.
    pub fn is_rejected(&self) -> bool {
        matches!(self, Self::Rejected { .. })
    }

    /// The stable word a surface prints: `applied`, `no-op` or `rejected`.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Applied { .. } => "applied",
            Self::NoOp { .. } => "no-op",
            Self::Rejected { .. } => "rejected",
        }
    }

    /// One line for a surface.
    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
                }
            }
        }
    }
}

/// Apply one confirmed lifecycle action (T023-T026).
///
/// Reads the series **from the store** (see the module docs: a state transition
/// must decide against the file), writes through the same store, and returns an
/// outcome for every decision it makes; only a genuine read failure propagates.
#[allow(clippy::too_many_lines)]
pub fn apply(store: &Store, request: &LifecycleRequest) -> crate::error::Result<LifecycleOutcome> {
    // Consent first, and before the store is touched.
    if !request.confirmed {
        return Ok(rejected(LifecycleDiagnostic::new(
            LifecycleDiagnosticKind::NotConfirmed,
            "a lifecycle action must be confirmed: no refresh, timer or detection \
             can apply one",
        )));
    }
    // One read of the repository: the series identity, the occurrence keys and the
    // definition issue all come from it, so nothing is decided from a snapshot that
    // may already be out of date.
    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;

    // `ended` is terminal for every action but `end` itself (a no-op).
    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
            ),
        )));
    }

    // The occurrence-bound actions need a key, and it must name this series.
    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
                ),
            )));
        }
        // An occurrence that exists cannot be skipped, deferred or ignored: those
        // actions decide about a date that has not happened. `tix delete` removes
        // an occurrence; this never pretends to.
        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);
    }

    // The no-op matrix: an action already in effect appends nothing.
    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"),
                ));
            }
        }
        _ => {}
    }

    // The definition issue is where all of this lives.
    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 => {
                // The same schedule effect for both, and no second field to keep in
                // step: the decision is what the History entry records.
                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}"),
        ))),
    }
}

/// The one History sentence an applied action writes.
///
/// It names the action, the occurrence where there is one, the state change where
/// there was one, and the caller's reason when they gave one — so an audit reader
/// never has to reconstruct why from the diff.
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
}

/// One refusal, as an outcome.
fn rejected(diagnostic: LifecycleDiagnostic) -> LifecycleOutcome {
    LifecycleOutcome::Rejected {
        diagnostics: vec![diagnostic],
    }
}

/// One no-op, as an outcome.
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"));
    }
}