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
#[allow(clippy::wildcard_imports)]
use super::*;

/// One of the seven lifecycle actions (`data-model.md` ยง6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleAction {
    /// Capture the instance's immutable snapshot, mark it closed, and log the event.
    Close,
    /// Reopen a closed instance: restore `state` to `active` and log the event.
    ///
    /// WP04 of mission `cycle-capacity-planning-01M3C85C`. `close`'s inverse, and
    /// deliberately **not** its mirror: a closed cycle's capacity snapshot is
    /// immutable historical evidence (C-008/NFR-002), so a reopen restores the record's
    /// state and appends one `cycle_reopened` event naming the snapshot it reopens โ€”
    /// and captures nothing. It is the one way a closed cycle may be configured again.
    Reopen,
    /// Push the instance's due date out by one cadence period.
    Extend,
    /// Record the sequence as skipped so it is never regenerated.
    Skip,
    /// Pause the schedule and every affected instance.
    Pause,
    /// Resume the schedule and every affected instance.
    Resume,
    /// Re-derive every future instance's dates from the schedule's own definition,
    /// and re-derive the cursor.
    AdjustFuture,
    /// The explicit "no action" decision: nothing is written.
    Ignore,
}

impl LifecycleAction {
    /// Every action, in the data model's order โ€” with WP04's `reopen` beside its
    /// inverse `close`.
    pub const ALL: [Self; 8] = [
        Self::Close,
        Self::Reopen,
        Self::Extend,
        Self::Skip,
        Self::Pause,
        Self::Resume,
        Self::AdjustFuture,
        Self::Ignore,
    ];

    /// The contract's spelling, used verbatim in payloads and tests.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Close => "close",
            Self::Reopen => "reopen",
            Self::Extend => "extend",
            Self::Skip => "skip",
            Self::Pause => "pause",
            Self::Resume => "resume",
            Self::AdjustFuture => "adjust-future",
            Self::Ignore => "ignore",
        }
    }

    /// Whether the action records a reason, and therefore refuses a missing one.
    ///
    /// `close`, `reopen`, `extend`, `skip` and `adjust-future` change what a cycle
    /// *is*; each is an auditable decision, so the request must carry the reason it was
    /// taken. `pause`/`resume` are state toggles, and `ignore` decides nothing.
    #[must_use]
    pub const fn requires_reason(self) -> bool {
        matches!(
            self,
            Self::Close | Self::Reopen | Self::Extend | Self::Skip | Self::AdjustFuture
        )
    }

    /// Whether the action writes anything at all. Only `ignore` does not, and it is
    /// therefore the only action exempt from the actor/timestamp requirement.
    #[must_use]
    pub const fn writes(self) -> bool {
        !matches!(self, Self::Ignore)
    }

    /// The `## History` kind an applied action appends, or `None` for `ignore`.
    #[must_use]
    pub(super) const fn history_kind(self) -> Option<HistoryEventKind> {
        match self {
            Self::Close => Some(HistoryEventKind::CycleClosed),
            Self::Reopen => Some(HistoryEventKind::CycleReopened),
            Self::Extend => Some(HistoryEventKind::CycleExtended),
            Self::Skip => Some(HistoryEventKind::CycleSkipped),
            Self::Pause => Some(HistoryEventKind::CyclePaused),
            Self::Resume => Some(HistoryEventKind::CycleResumed),
            Self::AdjustFuture => Some(HistoryEventKind::CycleAdjusted),
            Self::Ignore => None,
        }
    }

    /// Parse a stored or CLI spelling.
    pub fn parse(raw: &str) -> Result<Self> {
        match raw {
            "close" => Ok(Self::Close),
            "reopen" => Ok(Self::Reopen),
            "extend" => Ok(Self::Extend),
            "skip" => Ok(Self::Skip),
            "pause" => Ok(Self::Pause),
            "resume" => Ok(Self::Resume),
            "adjust-future" => Ok(Self::AdjustFuture),
            "ignore" => Ok(Self::Ignore),
            other => Err(TixError::InvalidInput(format!(
                "`{other}` is not a lifecycle action; expected one of `close`, `reopen`, `extend`, \
                 `skip`, `pause`, `resume`, `adjust-future`, `ignore`"
            ))),
        }
    }
}

/// The explicit, confirmed intent to carry out one lifecycle action.
///
/// Every field is supplied and none is inferred. `timestamp` is the caller's explicit
/// instant โ€” this codebase has no clock โ€” required by every action that writes;
/// `reason` is required by the five actions that record one (see
/// [`LifecycleAction::requires_reason`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleRequest {
    /// Which action to carry out.
    pub action: LifecycleAction,
    /// The schedule to act on, by stable id.
    pub schedule_id: CycleScheduleId,
    /// The instance to act on, when the action names one.
    pub cycle_id: Option<GeneratedCycleId>,
    /// Who asked โ€” the audit actor.
    pub actor: String,
    /// Why, when the action records one.
    pub reason: Option<String>,
    /// The explicit instant (RFC 3339, `YYYY-MM-DDTHH:MM:SSZ`) the action happened.
    pub timestamp: Option<String>,
    /// Whether the caller confirmed the action. An unconfirmed request is inert.
    pub confirmed: bool,
    /// The capacity calculation a `close` records as its evidence, when the caller
    /// computed one (mission `cycle-capacity-planning-01M3C85C`, WP04).
    ///
    /// **The caller supplies this, and a later reader must not "fix" that by teaching
    /// [`apply`] to build one.** The `close` path has no `Config` and no issue store โ€”
    /// only the cycle records, the schedule index and this request โ€” so it cannot run
    /// the calculation, and threading a store through `apply` would change every CLI,
    /// TUI and MCP call site for a value those surfaces already hold. The caller that
    /// owns the project configuration and the issue store computes it (one
    /// [`CapacityCalculation::for_scope`]) and sets it here.
    ///
    /// `None` โ€” the default, and what every caller written before this field did โ€”
    /// means the close captures no capacity evidence, which is exactly the
    /// issue-181 behaviour: a snapshot with an empty `measurements` map. A payload
    /// this mission wrote reads back as "no capacity evidence", never an error, so an
    /// un-updated caller is not broken by the field's arrival.
    pub capacity: Option<CapacityCalculation>,
}

impl LifecycleRequest {
    /// A request that is **not** yet confirmed.
    #[must_use]
    pub fn new(
        action: LifecycleAction,
        schedule_id: CycleScheduleId,
        actor: impl Into<String>,
    ) -> Self {
        Self {
            action,
            schedule_id,
            cycle_id: None,
            actor: actor.into(),
            reason: None,
            timestamp: None,
            confirmed: false,
            capacity: None,
        }
    }

    /// Record the capacity calculation a `close` should capture as its evidence.
    #[must_use]
    pub fn with_capacity(mut self, calculation: CapacityCalculation) -> Self {
        self.capacity = Some(calculation);
        self
    }

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

    /// Name the instance the action targets.
    #[must_use]
    pub fn for_cycle(mut self, cycle_id: GeneratedCycleId) -> Self {
        self.cycle_id = Some(cycle_id);
        self
    }

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

    /// Record the explicit instant.
    #[must_use]
    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
        self.timestamp = Some(timestamp.into());
        self
    }

    /// The trimmed reason, empty when none was supplied.
    pub(super) fn reason_text(&self) -> &str {
        self.reason.as_deref().map(str::trim).unwrap_or_default()
    }
}

/// What a refusal was, spelled in `snake_case` for payloads and tests.
///
/// The vocabulary is shared with the other cycle-schedule diagnostics where the fact
/// is the same (`unknown_schedule`, `missing_cadence`, `invalid_cadence`,
/// `unresolvable_date` are WP03's/WP04's spellings), so one refusal reads the same
/// whichever surface produced it (C-008).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleDiagnosticKind {
    /// The request was not confirmed, so nothing was even read.
    NotConfirmed,
    /// The action records a reason and the request carried none.
    MissingReason,
    /// The reason carries a character a `## History` entry cannot hold.
    ///
    /// WP04 of mission `cycle-capacity-planning-01M3C85C`. A History entry is one line
    /// whose fields are separated by U+2014, so a reason carrying a line break would
    /// render as more than one line โ€” and the extra line would parse as an event nobody
    /// wrote. It is refused rather than trimmed: silently rewriting a person's reason
    /// would put text in the audit log that they did not write.
    InvalidReason,
    /// The actor carries a line break, which would split the entry in two.
    InvalidActor,
    /// No schedule with that id is defined.
    UnknownSchedule,
    /// The action names an instance that does not exist.
    UnknownCycle,
    /// The action must name an instance and the request named none.
    MissingCycle,
    /// The cycle id belongs to a different schedule.
    CycleNotOwned,
    /// The action records an actor and the request carried none.
    MissingActor,
    /// The action records a time and the request carried none.
    MissingTimestamp,
    /// The timestamp is not the crate's `YYYY-MM-DDTHH:MM:SSZ` shape.
    InvalidTimestamp,
    /// The schedule is ended, which is terminal.
    TerminalSchedule,
    /// The instance is closed or skipped, which is terminal for this action.
    TerminalCycle,
    /// The schedule names no cadence family.
    MissingCadence,
    /// The cadence resolves to a rule the shared validator refuses.
    InvalidCadence,
    /// The duration is missing or a step the shared owner refuses.
    InvalidDuration,
    /// A date the action needs cannot be derived.
    UnresolvableDate,
    /// A skip would leave an earlier sequence unrecorded, forking the cursor past a
    /// hole generation can no longer fill.
    MissingSequence,
    /// A file is already at the instance's snapshot path and is not its own snapshot.
    SnapshotCollision,
    /// The reference label the placeholder would take is already taken.
    ReferenceCollision,
    /// The file the placeholder would be written to is already occupied.
    NameCollision,
    /// A record write failed; nothing else was attempted.
    SchedulingFailed,
    /// The configuration (schedule state or cursor) could not be persisted; the
    /// record writes were rolled back.
    ConfigNotPersisted,
}

impl LifecycleDiagnosticKind {
    /// The contract's spelling, used verbatim in payloads and tests.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::NotConfirmed => "not_confirmed",
            Self::MissingReason => "missing_reason",
            Self::InvalidReason => "invalid_reason",
            Self::InvalidActor => "invalid_actor",
            Self::UnknownSchedule => "unknown_schedule",
            Self::UnknownCycle => "unknown_cycle",
            Self::MissingCycle => "missing_cycle",
            Self::CycleNotOwned => "cycle_not_owned",
            Self::MissingActor => "missing_actor",
            Self::MissingTimestamp => "missing_timestamp",
            Self::InvalidTimestamp => "invalid_timestamp",
            Self::TerminalSchedule => "terminal_schedule",
            Self::TerminalCycle => "terminal_cycle",
            Self::MissingCadence => "missing_cadence",
            Self::InvalidCadence => "invalid_cadence",
            Self::InvalidDuration => "invalid_duration",
            Self::UnresolvableDate => "unresolvable_date",
            Self::MissingSequence => "missing_sequence",
            Self::SnapshotCollision => "snapshot_collision",
            Self::ReferenceCollision => "reference_collision",
            Self::NameCollision => "name_collision",
            Self::SchedulingFailed => "scheduling_failure",
            Self::ConfigNotPersisted => "config_not_persisted",
        }
    }
}

/// One field-aware reason an action was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleDiagnostic {
    /// What was wrong.
    pub kind: LifecycleDiagnosticKind,
    /// The configuration or cycle path the caller must fix.
    pub field: String,
    /// One sentence saying what to do about it.
    pub message: String,
}

impl LifecycleDiagnostic {
    /// A finding about `field`.
    pub(super) fn new(
        kind: LifecycleDiagnosticKind,
        field: impl Into<String>,
        message: impl Into<String>,
    ) -> Self {
        Self {
            kind,
            field: field.into(),
            message: message.into(),
        }
    }

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

/// What one confirmed lifecycle action did (`data-model.md` ยง6, T029).
///
/// Exactly one of three shapes, each a *result* rather than an error, so a surface
/// reports it instead of catching it. Exactly one outcome per action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleOutcome {
    /// The action changed something. `snapshot_path` is present on a close that
    /// captured one, and `dates` on an action that moved a window.
    Applied {
        /// The action that was carried out.
        action: LifecycleAction,
        /// The schedule it acted on.
        schedule_id: CycleScheduleId,
        /// The instance it acted on, when it named one.
        cycle_id: Option<GeneratedCycleId>,
        /// The captured snapshot, project-relative, when one was written.
        snapshot_path: Option<String>,
        /// The resulting `(start_date, due_date)`, when the action moved a window.
        dates: Option<(String, String)>,
        /// One sentence for a surface.
        message: String,
    },
    /// The action's postcondition already held; nothing was written.
    NoOp {
        /// The action that was requested.
        action: LifecycleAction,
        /// The schedule it named.
        schedule_id: CycleScheduleId,
        /// The instance it named, when it named one.
        cycle_id: Option<GeneratedCycleId>,
        /// One sentence saying why nothing was written.
        message: String,
    },
    /// The action was refused. Nothing was written.
    Rejected {
        /// The action that was requested.
        action: LifecycleAction,
        /// The schedule it named.
        schedule_id: CycleScheduleId,
        /// The instance it named, when it named one.
        cycle_id: Option<GeneratedCycleId>,
        /// Every reason, in the order they were found.
        diagnostics: Vec<LifecycleDiagnostic>,
    },
}

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

    /// Whether something was written.
    #[must_use]
    pub fn is_applied(&self) -> bool {
        matches!(self, Self::Applied { .. })
    }

    /// Whether the postcondition already held.
    #[must_use]
    pub fn is_noop(&self) -> bool {
        matches!(self, Self::NoOp { .. })
    }

    /// Whether the action was refused.
    #[must_use]
    pub fn is_rejected(&self) -> bool {
        matches!(self, Self::Rejected { .. })
    }

    /// The action the outcome is about.
    #[must_use]
    pub fn action(&self) -> LifecycleAction {
        match self {
            Self::Applied { action, .. }
            | Self::NoOp { action, .. }
            | Self::Rejected { action, .. } => *action,
        }
    }

    /// The schedule the outcome is about.
    #[must_use]
    pub fn schedule_id(&self) -> &CycleScheduleId {
        match self {
            Self::Applied { schedule_id, .. }
            | Self::NoOp { schedule_id, .. }
            | Self::Rejected { schedule_id, .. } => schedule_id,
        }
    }

    /// The instance the outcome concerns, when it concerns one.
    #[must_use]
    pub fn cycle_id(&self) -> Option<&GeneratedCycleId> {
        match self {
            Self::Applied { cycle_id, .. }
            | Self::NoOp { cycle_id, .. }
            | Self::Rejected { cycle_id, .. } => cycle_id.as_ref(),
        }
    }

    /// The captured snapshot, project-relative, present on an applied close.
    #[must_use]
    pub fn snapshot_path(&self) -> Option<&str> {
        match self {
            Self::Applied { snapshot_path, .. } => snapshot_path.as_deref(),
            Self::NoOp { .. } | Self::Rejected { .. } => None,
        }
    }

    /// The resulting dates, present on an action that moved a window.
    #[must_use]
    pub fn dates(&self) -> Option<&(String, String)> {
        match self {
            Self::Applied { dates, .. } => dates.as_ref(),
            Self::NoOp { .. } | Self::Rejected { .. } => None,
        }
    }

    /// Every diagnostic, empty for `applied` and `no-op`.
    #[must_use]
    pub fn diagnostics(&self) -> &[LifecycleDiagnostic] {
        match self {
            Self::Rejected { diagnostics, .. } => diagnostics,
            Self::Applied { .. } | Self::NoOp { .. } => &[],
        }
    }

    /// One line for a surface.
    #[must_use]
    pub fn render(&self) -> String {
        match self {
            Self::Applied {
                action,
                cycle_id,
                snapshot_path,
                dates,
                message,
                ..
            } => {
                let mut line = format!("{} `{}` applied", action.as_str(), self.schedule_id());
                if let Some(cycle_id) = cycle_id {
                    line.push_str(&format!(" to `{cycle_id}`"));
                }
                if let Some(path) = snapshot_path {
                    line.push_str(&format!(" (snapshot `{path}`)"));
                }
                if let Some((start, due)) = dates {
                    line.push_str(&format!(" ({start} โ†’ {due})"));
                }
                format!("{line}: {message}")
            }
            Self::NoOp {
                action, message, ..
            } => {
                format!(
                    "{} `{}` no-op: {message}",
                    action.as_str(),
                    self.schedule_id()
                )
            }
            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
                }
            }
        }
    }
}