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}