Skip to main content

tix/cycle_schedule/lifecycle/
model.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// One of the seven lifecycle actions (`data-model.md` §6).
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum LifecycleAction {
7    /// Capture the instance's immutable snapshot, mark it closed, and log the event.
8    Close,
9    /// Reopen a closed instance: restore `state` to `active` and log the event.
10    ///
11    /// WP04 of mission `cycle-capacity-planning-01M3C85C`. `close`'s inverse, and
12    /// deliberately **not** its mirror: a closed cycle's capacity snapshot is
13    /// immutable historical evidence (C-008/NFR-002), so a reopen restores the record's
14    /// state and appends one `cycle_reopened` event naming the snapshot it reopens —
15    /// and captures nothing. It is the one way a closed cycle may be configured again.
16    Reopen,
17    /// Push the instance's due date out by one cadence period.
18    Extend,
19    /// Record the sequence as skipped so it is never regenerated.
20    Skip,
21    /// Pause the schedule and every affected instance.
22    Pause,
23    /// Resume the schedule and every affected instance.
24    Resume,
25    /// Re-derive every future instance's dates from the schedule's own definition,
26    /// and re-derive the cursor.
27    AdjustFuture,
28    /// The explicit "no action" decision: nothing is written.
29    Ignore,
30}
31
32impl LifecycleAction {
33    /// Every action, in the data model's order — with WP04's `reopen` beside its
34    /// inverse `close`.
35    pub const ALL: [Self; 8] = [
36        Self::Close,
37        Self::Reopen,
38        Self::Extend,
39        Self::Skip,
40        Self::Pause,
41        Self::Resume,
42        Self::AdjustFuture,
43        Self::Ignore,
44    ];
45
46    /// The contract's spelling, used verbatim in payloads and tests.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Close => "close",
51            Self::Reopen => "reopen",
52            Self::Extend => "extend",
53            Self::Skip => "skip",
54            Self::Pause => "pause",
55            Self::Resume => "resume",
56            Self::AdjustFuture => "adjust-future",
57            Self::Ignore => "ignore",
58        }
59    }
60
61    /// Whether the action records a reason, and therefore refuses a missing one.
62    ///
63    /// `close`, `reopen`, `extend`, `skip` and `adjust-future` change what a cycle
64    /// *is*; each is an auditable decision, so the request must carry the reason it was
65    /// taken. `pause`/`resume` are state toggles, and `ignore` decides nothing.
66    #[must_use]
67    pub const fn requires_reason(self) -> bool {
68        matches!(
69            self,
70            Self::Close | Self::Reopen | Self::Extend | Self::Skip | Self::AdjustFuture
71        )
72    }
73
74    /// Whether the action writes anything at all. Only `ignore` does not, and it is
75    /// therefore the only action exempt from the actor/timestamp requirement.
76    #[must_use]
77    pub const fn writes(self) -> bool {
78        !matches!(self, Self::Ignore)
79    }
80
81    /// The `## History` kind an applied action appends, or `None` for `ignore`.
82    #[must_use]
83    pub(super) const fn history_kind(self) -> Option<HistoryEventKind> {
84        match self {
85            Self::Close => Some(HistoryEventKind::CycleClosed),
86            Self::Reopen => Some(HistoryEventKind::CycleReopened),
87            Self::Extend => Some(HistoryEventKind::CycleExtended),
88            Self::Skip => Some(HistoryEventKind::CycleSkipped),
89            Self::Pause => Some(HistoryEventKind::CyclePaused),
90            Self::Resume => Some(HistoryEventKind::CycleResumed),
91            Self::AdjustFuture => Some(HistoryEventKind::CycleAdjusted),
92            Self::Ignore => None,
93        }
94    }
95
96    /// Parse a stored or CLI spelling.
97    pub fn parse(raw: &str) -> Result<Self> {
98        match raw {
99            "close" => Ok(Self::Close),
100            "reopen" => Ok(Self::Reopen),
101            "extend" => Ok(Self::Extend),
102            "skip" => Ok(Self::Skip),
103            "pause" => Ok(Self::Pause),
104            "resume" => Ok(Self::Resume),
105            "adjust-future" => Ok(Self::AdjustFuture),
106            "ignore" => Ok(Self::Ignore),
107            other => Err(TixError::InvalidInput(format!(
108                "`{other}` is not a lifecycle action; expected one of `close`, `reopen`, `extend`, \
109                 `skip`, `pause`, `resume`, `adjust-future`, `ignore`"
110            ))),
111        }
112    }
113}
114
115/// The explicit, confirmed intent to carry out one lifecycle action.
116///
117/// Every field is supplied and none is inferred. `timestamp` is the caller's explicit
118/// instant — this codebase has no clock — required by every action that writes;
119/// `reason` is required by the five actions that record one (see
120/// [`LifecycleAction::requires_reason`]).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct LifecycleRequest {
123    /// Which action to carry out.
124    pub action: LifecycleAction,
125    /// The schedule to act on, by stable id.
126    pub schedule_id: CycleScheduleId,
127    /// The instance to act on, when the action names one.
128    pub cycle_id: Option<GeneratedCycleId>,
129    /// Who asked — the audit actor.
130    pub actor: String,
131    /// Why, when the action records one.
132    pub reason: Option<String>,
133    /// The explicit instant (RFC 3339, `YYYY-MM-DDTHH:MM:SSZ`) the action happened.
134    pub timestamp: Option<String>,
135    /// Whether the caller confirmed the action. An unconfirmed request is inert.
136    pub confirmed: bool,
137    /// The capacity calculation a `close` records as its evidence, when the caller
138    /// computed one (mission `cycle-capacity-planning-01M3C85C`, WP04).
139    ///
140    /// **The caller supplies this, and a later reader must not "fix" that by teaching
141    /// [`apply`] to build one.** The `close` path has no `Config` and no issue store —
142    /// only the cycle records, the schedule index and this request — so it cannot run
143    /// the calculation, and threading a store through `apply` would change every CLI,
144    /// TUI and MCP call site for a value those surfaces already hold. The caller that
145    /// owns the project configuration and the issue store computes it (one
146    /// [`CapacityCalculation::for_scope`]) and sets it here.
147    ///
148    /// `None` — the default, and what every caller written before this field did —
149    /// means the close captures no capacity evidence, which is exactly the
150    /// issue-181 behaviour: a snapshot with an empty `measurements` map. A payload
151    /// this mission wrote reads back as "no capacity evidence", never an error, so an
152    /// un-updated caller is not broken by the field's arrival.
153    pub capacity: Option<CapacityCalculation>,
154}
155
156impl LifecycleRequest {
157    /// A request that is **not** yet confirmed.
158    #[must_use]
159    pub fn new(
160        action: LifecycleAction,
161        schedule_id: CycleScheduleId,
162        actor: impl Into<String>,
163    ) -> Self {
164        Self {
165            action,
166            schedule_id,
167            cycle_id: None,
168            actor: actor.into(),
169            reason: None,
170            timestamp: None,
171            confirmed: false,
172            capacity: None,
173        }
174    }
175
176    /// Record the capacity calculation a `close` should capture as its evidence.
177    #[must_use]
178    pub fn with_capacity(mut self, calculation: CapacityCalculation) -> Self {
179        self.capacity = Some(calculation);
180        self
181    }
182
183    /// Record the confirmation.
184    #[must_use]
185    pub fn confirm(mut self) -> Self {
186        self.confirmed = true;
187        self
188    }
189
190    /// Name the instance the action targets.
191    #[must_use]
192    pub fn for_cycle(mut self, cycle_id: GeneratedCycleId) -> Self {
193        self.cycle_id = Some(cycle_id);
194        self
195    }
196
197    /// Record why.
198    #[must_use]
199    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
200        self.reason = Some(reason.into());
201        self
202    }
203
204    /// Record the explicit instant.
205    #[must_use]
206    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
207        self.timestamp = Some(timestamp.into());
208        self
209    }
210
211    /// The trimmed reason, empty when none was supplied.
212    pub(super) fn reason_text(&self) -> &str {
213        self.reason.as_deref().map(str::trim).unwrap_or_default()
214    }
215}
216
217/// What a refusal was, spelled in `snake_case` for payloads and tests.
218///
219/// The vocabulary is shared with the other cycle-schedule diagnostics where the fact
220/// is the same (`unknown_schedule`, `missing_cadence`, `invalid_cadence`,
221/// `unresolvable_date` are WP03's/WP04's spellings), so one refusal reads the same
222/// whichever surface produced it (C-008).
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
224pub enum LifecycleDiagnosticKind {
225    /// The request was not confirmed, so nothing was even read.
226    NotConfirmed,
227    /// The action records a reason and the request carried none.
228    MissingReason,
229    /// The reason carries a character a `## History` entry cannot hold.
230    ///
231    /// WP04 of mission `cycle-capacity-planning-01M3C85C`. A History entry is one line
232    /// whose fields are separated by U+2014, so a reason carrying a line break would
233    /// render as more than one line — and the extra line would parse as an event nobody
234    /// wrote. It is refused rather than trimmed: silently rewriting a person's reason
235    /// would put text in the audit log that they did not write.
236    InvalidReason,
237    /// The actor carries a line break, which would split the entry in two.
238    InvalidActor,
239    /// No schedule with that id is defined.
240    UnknownSchedule,
241    /// The action names an instance that does not exist.
242    UnknownCycle,
243    /// The action must name an instance and the request named none.
244    MissingCycle,
245    /// The cycle id belongs to a different schedule.
246    CycleNotOwned,
247    /// The action records an actor and the request carried none.
248    MissingActor,
249    /// The action records a time and the request carried none.
250    MissingTimestamp,
251    /// The timestamp is not the crate's `YYYY-MM-DDTHH:MM:SSZ` shape.
252    InvalidTimestamp,
253    /// The schedule is ended, which is terminal.
254    TerminalSchedule,
255    /// The instance is closed or skipped, which is terminal for this action.
256    TerminalCycle,
257    /// The schedule names no cadence family.
258    MissingCadence,
259    /// The cadence resolves to a rule the shared validator refuses.
260    InvalidCadence,
261    /// The duration is missing or a step the shared owner refuses.
262    InvalidDuration,
263    /// A date the action needs cannot be derived.
264    UnresolvableDate,
265    /// A skip would leave an earlier sequence unrecorded, forking the cursor past a
266    /// hole generation can no longer fill.
267    MissingSequence,
268    /// A file is already at the instance's snapshot path and is not its own snapshot.
269    SnapshotCollision,
270    /// The reference label the placeholder would take is already taken.
271    ReferenceCollision,
272    /// The file the placeholder would be written to is already occupied.
273    NameCollision,
274    /// A record write failed; nothing else was attempted.
275    SchedulingFailed,
276    /// The configuration (schedule state or cursor) could not be persisted; the
277    /// record writes were rolled back.
278    ConfigNotPersisted,
279}
280
281impl LifecycleDiagnosticKind {
282    /// The contract's spelling, used verbatim in payloads and tests.
283    #[must_use]
284    pub const fn as_str(self) -> &'static str {
285        match self {
286            Self::NotConfirmed => "not_confirmed",
287            Self::MissingReason => "missing_reason",
288            Self::InvalidReason => "invalid_reason",
289            Self::InvalidActor => "invalid_actor",
290            Self::UnknownSchedule => "unknown_schedule",
291            Self::UnknownCycle => "unknown_cycle",
292            Self::MissingCycle => "missing_cycle",
293            Self::CycleNotOwned => "cycle_not_owned",
294            Self::MissingActor => "missing_actor",
295            Self::MissingTimestamp => "missing_timestamp",
296            Self::InvalidTimestamp => "invalid_timestamp",
297            Self::TerminalSchedule => "terminal_schedule",
298            Self::TerminalCycle => "terminal_cycle",
299            Self::MissingCadence => "missing_cadence",
300            Self::InvalidCadence => "invalid_cadence",
301            Self::InvalidDuration => "invalid_duration",
302            Self::UnresolvableDate => "unresolvable_date",
303            Self::MissingSequence => "missing_sequence",
304            Self::SnapshotCollision => "snapshot_collision",
305            Self::ReferenceCollision => "reference_collision",
306            Self::NameCollision => "name_collision",
307            Self::SchedulingFailed => "scheduling_failure",
308            Self::ConfigNotPersisted => "config_not_persisted",
309        }
310    }
311}
312
313/// One field-aware reason an action was refused.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct LifecycleDiagnostic {
316    /// What was wrong.
317    pub kind: LifecycleDiagnosticKind,
318    /// The configuration or cycle path the caller must fix.
319    pub field: String,
320    /// One sentence saying what to do about it.
321    pub message: String,
322}
323
324impl LifecycleDiagnostic {
325    /// A finding about `field`.
326    pub(super) fn new(
327        kind: LifecycleDiagnosticKind,
328        field: impl Into<String>,
329        message: impl Into<String>,
330    ) -> Self {
331        Self {
332            kind,
333            field: field.into(),
334            message: message.into(),
335        }
336    }
337
338    /// Render through the crate's one diagnostic renderer.
339    #[must_use]
340    pub fn render(&self) -> String {
341        render_diagnostics(
342            "E-tix-cycle-schedule",
343            std::iter::once((self.kind.as_str(), self.message.as_str())),
344        )
345    }
346}
347
348/// What one confirmed lifecycle action did (`data-model.md` §6, T029).
349///
350/// Exactly one of three shapes, each a *result* rather than an error, so a surface
351/// reports it instead of catching it. Exactly one outcome per action.
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum LifecycleOutcome {
354    /// The action changed something. `snapshot_path` is present on a close that
355    /// captured one, and `dates` on an action that moved a window.
356    Applied {
357        /// The action that was carried out.
358        action: LifecycleAction,
359        /// The schedule it acted on.
360        schedule_id: CycleScheduleId,
361        /// The instance it acted on, when it named one.
362        cycle_id: Option<GeneratedCycleId>,
363        /// The captured snapshot, project-relative, when one was written.
364        snapshot_path: Option<String>,
365        /// The resulting `(start_date, due_date)`, when the action moved a window.
366        dates: Option<(String, String)>,
367        /// One sentence for a surface.
368        message: String,
369    },
370    /// The action's postcondition already held; nothing was written.
371    NoOp {
372        /// The action that was requested.
373        action: LifecycleAction,
374        /// The schedule it named.
375        schedule_id: CycleScheduleId,
376        /// The instance it named, when it named one.
377        cycle_id: Option<GeneratedCycleId>,
378        /// One sentence saying why nothing was written.
379        message: String,
380    },
381    /// The action was refused. Nothing was written.
382    Rejected {
383        /// The action that was requested.
384        action: LifecycleAction,
385        /// The schedule it named.
386        schedule_id: CycleScheduleId,
387        /// The instance it named, when it named one.
388        cycle_id: Option<GeneratedCycleId>,
389        /// Every reason, in the order they were found.
390        diagnostics: Vec<LifecycleDiagnostic>,
391    },
392}
393
394impl LifecycleOutcome {
395    /// The stable word a surface prints: `applied`, `no-op` or `rejected`.
396    #[must_use]
397    pub fn as_str(&self) -> &'static str {
398        match self {
399            Self::Applied { .. } => "applied",
400            Self::NoOp { .. } => "no-op",
401            Self::Rejected { .. } => "rejected",
402        }
403    }
404
405    /// Whether something was written.
406    #[must_use]
407    pub fn is_applied(&self) -> bool {
408        matches!(self, Self::Applied { .. })
409    }
410
411    /// Whether the postcondition already held.
412    #[must_use]
413    pub fn is_noop(&self) -> bool {
414        matches!(self, Self::NoOp { .. })
415    }
416
417    /// Whether the action was refused.
418    #[must_use]
419    pub fn is_rejected(&self) -> bool {
420        matches!(self, Self::Rejected { .. })
421    }
422
423    /// The action the outcome is about.
424    #[must_use]
425    pub fn action(&self) -> LifecycleAction {
426        match self {
427            Self::Applied { action, .. }
428            | Self::NoOp { action, .. }
429            | Self::Rejected { action, .. } => *action,
430        }
431    }
432
433    /// The schedule the outcome is about.
434    #[must_use]
435    pub fn schedule_id(&self) -> &CycleScheduleId {
436        match self {
437            Self::Applied { schedule_id, .. }
438            | Self::NoOp { schedule_id, .. }
439            | Self::Rejected { schedule_id, .. } => schedule_id,
440        }
441    }
442
443    /// The instance the outcome concerns, when it concerns one.
444    #[must_use]
445    pub fn cycle_id(&self) -> Option<&GeneratedCycleId> {
446        match self {
447            Self::Applied { cycle_id, .. }
448            | Self::NoOp { cycle_id, .. }
449            | Self::Rejected { cycle_id, .. } => cycle_id.as_ref(),
450        }
451    }
452
453    /// The captured snapshot, project-relative, present on an applied close.
454    #[must_use]
455    pub fn snapshot_path(&self) -> Option<&str> {
456        match self {
457            Self::Applied { snapshot_path, .. } => snapshot_path.as_deref(),
458            Self::NoOp { .. } | Self::Rejected { .. } => None,
459        }
460    }
461
462    /// The resulting dates, present on an action that moved a window.
463    #[must_use]
464    pub fn dates(&self) -> Option<&(String, String)> {
465        match self {
466            Self::Applied { dates, .. } => dates.as_ref(),
467            Self::NoOp { .. } | Self::Rejected { .. } => None,
468        }
469    }
470
471    /// Every diagnostic, empty for `applied` and `no-op`.
472    #[must_use]
473    pub fn diagnostics(&self) -> &[LifecycleDiagnostic] {
474        match self {
475            Self::Rejected { diagnostics, .. } => diagnostics,
476            Self::Applied { .. } | Self::NoOp { .. } => &[],
477        }
478    }
479
480    /// One line for a surface.
481    #[must_use]
482    pub fn render(&self) -> String {
483        match self {
484            Self::Applied {
485                action,
486                cycle_id,
487                snapshot_path,
488                dates,
489                message,
490                ..
491            } => {
492                let mut line = format!("{} `{}` applied", action.as_str(), self.schedule_id());
493                if let Some(cycle_id) = cycle_id {
494                    line.push_str(&format!(" to `{cycle_id}`"));
495                }
496                if let Some(path) = snapshot_path {
497                    line.push_str(&format!(" (snapshot `{path}`)"));
498                }
499                if let Some((start, due)) = dates {
500                    line.push_str(&format!(" ({start} → {due})"));
501                }
502                format!("{line}: {message}")
503            }
504            Self::NoOp {
505                action, message, ..
506            } => {
507                format!(
508                    "{} `{}` no-op: {message}",
509                    action.as_str(),
510                    self.schedule_id()
511                )
512            }
513            Self::Rejected { diagnostics, .. } => {
514                let first = diagnostics
515                    .first()
516                    .map(LifecycleDiagnostic::render)
517                    .unwrap_or_else(|| "rejected".to_string());
518                if diagnostics.len() > 1 {
519                    format!("{first} (and {} more)", diagnostics.len() - 1)
520                } else {
521                    first
522                }
523            }
524        }
525    }
526}