Skip to main content

tix/model/
history.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// The heading that introduces the History section, per `data-model.md` §8.
5///
6/// The section is recognised by a line whose trimmed content is exactly this —
7/// nothing else introduces it.
8pub const HISTORY_HEADING: &str = "## History";
9
10/// What kind of mutation a [`HistoryEvent`] records.
11///
12/// Closed set per `data-model.md` §8. `blocked_move_acknowledged` is defined
13/// here so WP04 consumes it without editing this file.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum HistoryEventKind {
17    /// A relationship was created.
18    RelationshipAdded,
19    /// A relationship was removed.
20    RelationshipRemoved,
21    /// A relationship was retargeted or re-kinded.
22    RelationshipRetargeted,
23    /// A move toward completion was confirmed while blockers were open.
24    BlockedMoveAcknowledged,
25    /// An issue gained its first parent.
26    ParentSet,
27    /// An existing parent reference was repointed.
28    ParentChanged,
29    /// A parent reference was removed.
30    ParentCleared,
31    /// A status change was accepted by the transition service and applied.
32    ///
33    /// WP03. The name says *accepted* because acceptance is the only outcome that
34    /// reaches the log: a rejected transition is completely inert and appends
35    /// nothing (contract §3.5), and a re-applied transition is idempotent and also
36    /// appends nothing. So the presence of this kind in a file **is** the record
37    /// that the change was accepted — a name like `transition_attempted` would
38    /// imply rejections are logged here too, and they are not.
39    TransitionAccepted,
40    /// A triage inbox issue was accepted into the normal workflow.
41    ///
42    /// WP03 (mission `triage-inbox-review-flow-01M3C600`). Four triage kinds
43    /// rather than one `triage_decided` for the reason the parent kinds are
44    /// separate: the *decision* is the thing an audit reader is looking for, and
45    /// `triage_decided — decline …` makes them read the detail to learn which of
46    /// four mutually exclusive actions was taken. They extend the one #169 log
47    /// (research R5) — there is no second log and no triage-specific section.
48    ///
49    /// Only **applied** decisions reach the log: a refusal appends nothing and a
50    /// repeated identical decision is a no-op (contract `decision-history.md`),
51    /// exactly as with [`HistoryEventKind::TransitionAccepted`].
52    TriageAccepted,
53    /// A triage inbox issue was declined: moved to the configured declined
54    /// column, never deleted or archived (C-003).
55    TriageDeclined,
56    /// A triage inbox issue was marked a duplicate of another issue.
57    ///
58    /// The event records the decision; the typed `duplicates` edge is the
59    /// [`HistoryEventKind::RelationshipAdded`] entry. One decision, two kinds
60    /// *only* because the relation already has its own kind and inventing a
61    /// second spelling for it would leave the graph and the log disagreeing.
62    TriageDuplicated,
63    /// A triage inbox issue was deferred: it stays in the inbox with a
64    /// refreshed entry time (data-model §5).
65    TriageDeferred,
66    /// The issue file was archived: renamed in place to a dot-prefixed name
67    /// (issue #1492).
68    Archived,
69    /// An archived issue was restored to its active name (issue #1492).
70    Restored,
71    /// An issue's health state was updated, with an actor, a timestamp and a
72    /// rationale (mission `project-issue-health-status-01M3C8HX`, WP02).
73    ///
74    /// One kind for an update and one for an override rather than one
75    /// `health_changed` with a flag: the *decision* is what an audit reader looks
76    /// for, and "somebody changed the health" makes them read the detail to learn
77    /// whether it was a measurement or a person overruling one.
78    HealthUpdated,
79    /// A manual health override was applied over a derived value (WP02).
80    HealthOverridden,
81    /// An issue gained a project association (mission
82    /// `first-class-projects-01M3BYBW`, WP03).
83    ///
84    /// Two dedicated kinds rather than one generic `projects_changed`, matching
85    /// the three parent kinds and the four triage kinds: the *direction* of the
86    /// change is what an audit reader is looking for, and `projects_changed —
87    /// …` would make them read the detail to learn whether an association was
88    /// added or removed. The service declares exactly these two as
89    /// [`crate::projects::ProjectEventKind`], so the vocabulary is decided once
90    /// and mapped here by [`Self::from_project`].
91    ProjectAssociated,
92    /// An issue lost a project association (WP03).
93    ProjectDissociated,
94    /// An issue gained an epic reference (mission
95    /// `epics-work-items-01M3BYW4`, WP03).
96    ///
97    /// Two dedicated kinds rather than one generic `epics_changed`, matching the
98    /// three parent kinds, the four triage kinds, and the two project kinds: the
99    /// *direction* of the change is what an audit reader is looking for, and
100    /// `epics_changed — …` would make them read the detail to learn whether a
101    /// reference was added or removed. The service declares exactly these two as
102    /// [`crate::epics::EpicEventKind`], so the vocabulary is decided once and
103    /// mapped here by [`Self::from_epic`].
104    EpicReferenced,
105    /// An issue lost an epic reference (WP03).
106    EpicDereferenced,
107    /// A generated cycle was explicitly closed, capturing its snapshot.
108    ///
109    /// WP05 of mission `repeating-cycle-schedules-01M3C7W1`. These six kinds carry
110    /// the same append-only grammar as every issue event, but they are written to a
111    /// **generated cycle record's own body** (`docs/cycles/<stem>.md`), never to an
112    /// issue: a cycle is not an issue, and the mission's evidence for one is its
113    /// record, its snapshot and the cursor. One kind per action rather than one
114    /// `cycle_lifecycle` with a flag, for the same reason the parent/triage/project
115    /// kinds are split: the *decision* is what an audit reader looks for, and a
116    /// generic kind would make them read the detail to learn which of five
117    /// mutually-exclusive actions was taken.
118    CycleClosed,
119    /// A generated cycle's due date was extended by one cadence period.
120    CycleExtended,
121    /// A generated cycle's sequence was recorded as skipped.
122    CycleSkipped,
123    /// A schedule (and its affected instances) was paused.
124    CyclePaused,
125    /// A schedule (and its affected instances) was resumed.
126    CycleResumed,
127    /// A schedule's future instances were re-derived from its definition.
128    CycleAdjusted,
129    /// A closed cycle was explicitly reopened.
130    ///
131    /// WP04 of mission `cycle-capacity-planning-01M3C85C`. A closed cycle's capacity is
132    /// the immutable snapshot captured at close (C-008), so reopening it — the one way
133    /// its capacity may be configured again — is a capacity-affecting change and is
134    /// recorded rather than performed silently. It extends the same cycle family as
135    /// [`HistoryEventKind::CycleClosed`] and is written to the **generated cycle
136    /// record's own body**, beside `cycle_closed`: a cycle's evidence is its record, its
137    /// snapshot and the cursor, and there is no second log. One kind rather than a flag
138    /// on `cycle_closed`, for the reason the other cycle kinds are split — the *decision*
139    /// is what an audit reader looks for. An applied reopen appends exactly one entry;
140    /// the prior snapshot and every prior event are retained verbatim.
141    CycleReopened,
142    /// A time entry was created on an issue (mission
143    /// `per-issue-time-tracking-01M3C9A4`, WP01 T047).
144    ///
145    /// Pre-declared here, like `blocked_move_acknowledged` above, so WP02's mutation
146    /// service appends the event without editing this file. Three kinds rather than one
147    /// `time_changed` for the reason the parent, triage, project and epic families are
148    /// split: the *decision* is what an audit reader is looking for, and a generic kind
149    /// would make them read the detail to learn whether an entry was added, corrected or
150    /// removed. Only an **applied** mutation appends one (`contracts/time-entry.md`,
151    /// data-model §2): a refused write appends nothing, and the `detail` carries the
152    /// entry id and the created value so the entry is attributable after any later edit.
153    TimeEntryCreated,
154    /// A time entry's duration, date, author or note was corrected (WP01 T047).
155    ///
156    /// The `detail` carries the entry id and the before/after values, so the prior value
157    /// remains attributable after the correction (C-004). Pre-declared with its siblings
158    /// so WP02 never edits this file.
159    TimeEntryUpdated,
160    /// A time entry was deleted from an issue's current projection (WP01 T047).
161    ///
162    /// The entry leaves the issue's frontmatter and the event, with its prior value,
163    /// stays in History — a deletion removes the current projection, never the evidence
164    /// (C-004). Pre-declared with its siblings so WP02 never edits this file.
165    TimeEntryDeleted,
166    /// An issue's `estimate` value changed (mission
167    /// `burndown-scope-analytics-01M3C9J1`, WP01 T046).
168    ///
169    /// FR-002 distinguishes scope growth from completion, and an estimate change moves
170    /// the planned *size* of the work without changing how many issues are in scope —
171    /// so it is a kind of its own rather than a flag on a membership or a status event,
172    /// and the analytics classification folds it as `estimate_change`. This is the one
173    /// class the mission's own table would otherwise never produce: no surface used to
174    /// append an event for an estimate edit at all, so the class would have stayed
175    /// permanently unknown before an explicit event kind was added for it.
176    ///
177    /// Appended by `tix edit --estimate` at the seam `--parent` already appends to,
178    /// with the before/after values in `detail` (`estimate: <before> -> <after>`,
179    /// `none` for an absent or blank value). An unchanged value appends **nothing**,
180    /// and a refused edit appends nothing — the entry exists only for a change that
181    /// was applied.
182    EstimateChanged,
183    /// An issue gained or lost a cycle membership (WP01 T046).
184    ///
185    /// One kind rather than two direction-split ones, and that is the documented
186    /// exception to the split-by-direction convention the parent/triage/project/epic
187    /// families follow: a single `--cycles` edit routinely adds one cycle *and* removes
188    /// another, so "which direction?" has no single answer to split on. The direction is
189    /// therefore in `detail` — `cycles: <before> -> <after>`, `none` for an empty list —
190    /// which is what makes the change readable without replaying state.
191    ///
192    /// Appended by `tix edit --cycles` at the same seam, and only when the membership
193    /// actually changed: an unchanged list appends nothing.
194    CycleMembershipChanged,
195}
196
197impl HistoryEventKind {
198    /// The canonical snake_case spelling written to the file.
199    pub fn as_str(self) -> &'static str {
200        match self {
201            HistoryEventKind::RelationshipAdded => "relationship_added",
202            HistoryEventKind::RelationshipRemoved => "relationship_removed",
203            HistoryEventKind::RelationshipRetargeted => "relationship_retargeted",
204            HistoryEventKind::BlockedMoveAcknowledged => "blocked_move_acknowledged",
205            // Three dedicated kinds rather than one generic `parent_changed` (research
206            // open question 2, resolved in `data-model.md` §6.1): "set" and "cleared" are
207            // not changes *of* a parent, they are its beginning and its end, and
208            // collapsing them leaves the audit trail unable to distinguish "gained a
209            // parent" from "moved to a different one" without reconstructing state from
210            // the detail text.
211            HistoryEventKind::ParentSet => "parent_set",
212            HistoryEventKind::ParentChanged => "parent_changed",
213            HistoryEventKind::ParentCleared => "parent_cleared",
214            HistoryEventKind::TransitionAccepted => "transition_accepted",
215            HistoryEventKind::TriageAccepted => "triage_accepted",
216            HistoryEventKind::TriageDeclined => "triage_declined",
217            HistoryEventKind::TriageDuplicated => "triage_duplicated",
218            HistoryEventKind::TriageDeferred => "triage_deferred",
219            HistoryEventKind::Archived => "archived",
220            HistoryEventKind::Restored => "restored",
221            HistoryEventKind::HealthUpdated => "health_updated",
222            HistoryEventKind::HealthOverridden => "health_overridden",
223            HistoryEventKind::ProjectAssociated => "project_associated",
224            HistoryEventKind::ProjectDissociated => "project_dissociated",
225            HistoryEventKind::EpicReferenced => "epic_referenced",
226            HistoryEventKind::EpicDereferenced => "epic_dereferenced",
227            HistoryEventKind::CycleClosed => "cycle_closed",
228            HistoryEventKind::CycleExtended => "cycle_extended",
229            HistoryEventKind::CycleSkipped => "cycle_skipped",
230            HistoryEventKind::CyclePaused => "cycle_paused",
231            HistoryEventKind::CycleResumed => "cycle_resumed",
232            HistoryEventKind::CycleAdjusted => "cycle_adjusted",
233            HistoryEventKind::CycleReopened => "cycle_reopened",
234            HistoryEventKind::TimeEntryCreated => "time_entry_created",
235            HistoryEventKind::TimeEntryUpdated => "time_entry_updated",
236            HistoryEventKind::TimeEntryDeleted => "time_entry_deleted",
237            HistoryEventKind::EstimateChanged => "estimate_changed",
238            HistoryEventKind::CycleMembershipChanged => "cycle_membership_changed",
239        }
240    }
241
242    /// Whether this kind records a triage decision (WP03's four).
243    ///
244    /// The membership test the decision service's idempotence check and
245    /// `not_in_triage`/`already_decided` split both ask, so "is this a triage
246    /// decision?" has one answer rather than a four-way `matches!` at every
247    /// call site — where a fifth kind would be forgotten first.
248    #[must_use]
249    pub fn is_triage_decision(self) -> bool {
250        matches!(
251            self,
252            HistoryEventKind::TriageAccepted
253                | HistoryEventKind::TriageDeclined
254                | HistoryEventKind::TriageDuplicated
255                | HistoryEventKind::TriageDeferred
256        )
257    }
258}
259
260impl fmt::Display for HistoryEventKind {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.write_str(self.as_str())
263    }
264}
265
266impl FromStr for HistoryEventKind {
267    type Err = TixError;
268
269    /// Parse a kind spelling. An unknown kind is **rejected**, never coerced
270    /// to a default: a silently reclassified audit entry is worse than a
271    /// parse failure, and the field is part of an append-only log.
272    fn from_str(s: &str) -> Result<Self> {
273        match s {
274            "relationship_added" => Ok(HistoryEventKind::RelationshipAdded),
275            "relationship_removed" => Ok(HistoryEventKind::RelationshipRemoved),
276            "relationship_retargeted" => Ok(HistoryEventKind::RelationshipRetargeted),
277            "blocked_move_acknowledged" => Ok(HistoryEventKind::BlockedMoveAcknowledged),
278            "parent_set" => Ok(HistoryEventKind::ParentSet),
279            "parent_changed" => Ok(HistoryEventKind::ParentChanged),
280            "parent_cleared" => Ok(HistoryEventKind::ParentCleared),
281            "transition_accepted" => Ok(HistoryEventKind::TransitionAccepted),
282            "triage_accepted" => Ok(HistoryEventKind::TriageAccepted),
283            "triage_declined" => Ok(HistoryEventKind::TriageDeclined),
284            "triage_duplicated" => Ok(HistoryEventKind::TriageDuplicated),
285            "triage_deferred" => Ok(HistoryEventKind::TriageDeferred),
286            "archived" => Ok(HistoryEventKind::Archived),
287            "restored" => Ok(HistoryEventKind::Restored),
288            "health_updated" => Ok(HistoryEventKind::HealthUpdated),
289            "health_overridden" => Ok(HistoryEventKind::HealthOverridden),
290            "project_associated" => Ok(HistoryEventKind::ProjectAssociated),
291            "project_dissociated" => Ok(HistoryEventKind::ProjectDissociated),
292            "epic_referenced" => Ok(HistoryEventKind::EpicReferenced),
293            "epic_dereferenced" => Ok(HistoryEventKind::EpicDereferenced),
294            "cycle_closed" => Ok(HistoryEventKind::CycleClosed),
295            "cycle_extended" => Ok(HistoryEventKind::CycleExtended),
296            "cycle_skipped" => Ok(HistoryEventKind::CycleSkipped),
297            "cycle_paused" => Ok(HistoryEventKind::CyclePaused),
298            "cycle_resumed" => Ok(HistoryEventKind::CycleResumed),
299            "cycle_adjusted" => Ok(HistoryEventKind::CycleAdjusted),
300            "cycle_reopened" => Ok(HistoryEventKind::CycleReopened),
301            "time_entry_created" => Ok(HistoryEventKind::TimeEntryCreated),
302            "time_entry_updated" => Ok(HistoryEventKind::TimeEntryUpdated),
303            "time_entry_deleted" => Ok(HistoryEventKind::TimeEntryDeleted),
304            "estimate_changed" => Ok(HistoryEventKind::EstimateChanged),
305            "cycle_membership_changed" => Ok(HistoryEventKind::CycleMembershipChanged),
306            other => Err(TixError::Parse(format!(
307                "unknown History event kind `{other}`"
308            ))),
309        }
310    }
311}
312
313impl HistoryEventKind {
314    /// The model kind for a hierarchy event.
315    ///
316    /// Vocabulary translation, not a rule: the service decides *which* transition
317    /// happened, and this maps that decision onto the log's spelling. It lives on
318    /// the type that owns the log rather than at each call site, because three
319    /// callers need it — the storage write path, the `--parent` edit flag, and MCP's
320    /// `update_issue` in WP07 — and a mapping repeated three times is one that will
321    /// be completed three times and fixed once.
322    pub fn from_parent(kind: crate::hierarchy::ParentEventKind) -> Self {
323        match kind {
324            crate::hierarchy::ParentEventKind::ParentSet => HistoryEventKind::ParentSet,
325            crate::hierarchy::ParentEventKind::ParentChanged => HistoryEventKind::ParentChanged,
326            crate::hierarchy::ParentEventKind::ParentCleared => HistoryEventKind::ParentCleared,
327        }
328    }
329
330    /// The model kind for a project association event (WP03).
331    ///
332    /// The same vocabulary translation as [`Self::from_parent`]: the project
333    /// service decides *which* mutation happened by returning a
334    /// [`crate::projects::ProjectEventKind`], and this maps that decision onto the
335    /// log's spelling. It lives on the type that owns the log because more than one
336    /// caller needs it — the storage write path, and the CLI/TUI/MCP surfaces that
337    /// follow — and a mapping repeated per call site is one that will be completed
338    /// per call site and fixed once.
339    #[must_use]
340    pub fn from_project(kind: crate::projects::ProjectEventKind) -> Self {
341        match kind {
342            crate::projects::ProjectEventKind::ProjectAssociated => {
343                HistoryEventKind::ProjectAssociated
344            }
345            crate::projects::ProjectEventKind::ProjectDissociated => {
346                HistoryEventKind::ProjectDissociated
347            }
348        }
349    }
350
351    /// The model kind for an epic reference event (WP03).
352    ///
353    /// The same vocabulary translation as [`Self::from_project`]: the epic service
354    /// decides *which* mutation happened by returning a
355    /// [`crate::epics::EpicEventKind`], and this maps that decision onto the log's
356    /// spelling. It lives on the type that owns the log because more than one caller
357    /// needs it — the storage write path, and the CLI/TUI/MCP surfaces that follow —
358    /// and a mapping repeated per call site is one that will be completed per call
359    /// site and fixed once.
360    #[must_use]
361    pub fn from_epic(kind: crate::epics::EpicEventKind) -> Self {
362        match kind {
363            crate::epics::EpicEventKind::EpicReferenced => HistoryEventKind::EpicReferenced,
364            crate::epics::EpicEventKind::EpicDereferenced => HistoryEventKind::EpicDereferenced,
365        }
366    }
367}
368
369/// The auditable record of one **accepted** status transition (`data-model.md`
370/// §5, WP03 T012).
371///
372/// ## Why this is not a `HistoryEventKind` alone
373///
374/// A [`HistoryEvent`] has exactly four fields — timestamp, actor, kind, detail —
375/// and the on-disk line format is fixed at four by the U+2014 split in
376/// [`HistoryEvent::parse`]. `data-model.md` §5 wants seven. The three surplus
377/// fields therefore live in `detail`, and this struct is what converts between
378/// the two: [`Self::to_event`] and [`Self::from_event`] are the only places that
379/// encoding is written or read.
380///
381/// ## The `detail` grammar
382///
383/// ```text
384/// detail := head [ "; override: " reason ]
385/// head   := from " -> " to " (" from_category " -> " to_category ")"
386/// ```
387///
388/// e.g. `todo -> in progress (unstarted -> active)`, or
389/// `done -> in progress (completed -> active); override: mis-filed`.
390///
391/// **It is a grammar, not prose**, and that is a load-bearing choice rather than a
392/// tidiness one. WP06's `lint_direct_edits` has to compare a file's *current*
393/// `status` against the column its last accepted transition landed in; recovering
394/// that column out of free-form English is guesswork, and a heuristic that
395/// misreads it produces a lint result nobody can trust. A fixed grammar makes the
396/// comparison exact, and makes "this detail is not a transition record"
397/// detectable rather than a guess.
398///
399/// The parse is deliberately anchored rather than free-floating: the override is
400/// split off at the first `") ; override: "`, and the categories are taken from
401/// between the last `" ("` and the closing `")"`, so a **reason** may contain the
402/// bare `"-> "` and the bare `"; override: "` without disturbing the fields. That
403/// anchoring is the whole reason the marker includes the paren — see
404/// [`OVERRIDE_MARKER`].
405///
406/// ## Known limit: a column name containing `" -> "`
407///
408/// The two column names are separated by the first `" -> "`, and nothing escapes
409/// it, so a column literally named `a -> b` would render as
410/// `a -> b -> in progress (unstarted -> active)` and parse back with `from == "a"`.
411/// The categories still parse correctly, and the *categories* are what an audit
412/// reader wants, but a WP06 that recovers `to` from a file would recover `b`.
413///
414/// This is inherent to a delimiter-based grammar, not a bug in this
415/// implementation: any unescaped separator has a name it cannot represent, and
416/// escaping would put backslashes into a line a human reads. A board column named
417/// with an embedded arrow is not a realistic configuration, so the limit is
418/// documented and pinned by a test rather than designed around. WP06 reads `to`
419/// from these records, so it inherits the assumption and should not be surprised
420/// by it.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct TransitionDecision {
423    /// When the transition was accepted, RFC 3339 — the same shape and clock as
424    /// every other History entry.
425    pub timestamp: String,
426    /// Who performed it.
427    ///
428    /// A plain `String`, carried through to [`HistoryEvent::actor`] untouched, so
429    /// identity handling is the History log's existing one: a bare handle or a full
430    /// `Name <email>`, with a legacy `@alice` tolerated on read. This struct
431    /// re-implements none of that and cannot drift from it.
432    pub actor: String,
433    /// The column the issue was in, as stored.
434    pub from: String,
435    /// The column it was moved to, as stored.
436    pub to: String,
437    /// The category `from` resolved to, recorded for auditability.
438    ///
439    /// **Derived, never stored on the issue and never re-derived on read** — that
440    /// is the point. If a board's `category_map` is later edited, the historical
441    /// record still says what the category *was* when the move was accepted, which
442    /// is the only way the audit trail stays true.
443    pub from_category: String,
444    /// The category `to` resolved to, recorded for the same reason.
445    pub to_category: String,
446    /// Why a policy rule was overridden, when one was.
447    ///
448    /// Required to be `Some` for an override to exist at all: the service refuses a
449    /// bare "I did it anyway" (see `TransitionOverride`), so this is never `Some`
450    /// with an empty or blank reason.
451    pub override_reason: Option<String>,
452}
453
454impl TransitionDecision {
455    /// Build a decision from an accepted transition.
456    pub fn new(
457        timestamp: impl Into<String>,
458        actor: impl Into<String>,
459        from: impl Into<String>,
460        to: impl Into<String>,
461        from_category: impl Into<String>,
462        to_category: impl Into<String>,
463        override_reason: Option<String>,
464    ) -> Self {
465        Self {
466            timestamp: timestamp.into(),
467            actor: actor.into(),
468            from: from.into(),
469            to: to.into(),
470            from_category: from_category.into(),
471            to_category: to_category.into(),
472            override_reason,
473        }
474    }
475
476    /// Render to the single `detail` string the History line carries.
477    pub fn detail(&self) -> String {
478        let mut out = format!(
479            "{from}{arrow}{to} ({fc}{arrow}{tc})",
480            from = self.from,
481            to = self.to,
482            fc = self.from_category,
483            tc = self.to_category,
484            arrow = DETAIL_ARROW,
485        );
486        // A blank reason renders as no marker at all rather than as an empty field,
487        // so `from_event` can never come back with `Some("")` — the same reasoning
488        // that makes a blank actor render as an empty field rather than being
489        // dropped, applied in the other direction.
490        if let Some(reason) = self.override_reason.as_deref().map(str::trim) {
491            if !reason.is_empty() {
492                // The head already ends in the `)` the marker expects, so the
493                // marker supplies the semicolon and the space only. Writing the
494                // marker as a whole and *also* leaving the head's `)` is what
495                // produced `...active)); override:` on the first pass.
496                out.push_str(OVERRIDE_MARKER.trim_start_matches(')'));
497                out.push_str(reason);
498            }
499        }
500        out
501    }
502
503    /// Recover a decision from a History entry.
504    ///
505    /// `Err` when `event` is not a `transition_accepted` entry or its `detail` does
506    /// not match the grammar — never a partially-filled struct. A caller can
507    /// therefore treat a successful parse as "this file records a transition to
508    /// `to`", which is exactly the question WP06 asks.
509    pub fn from_event(event: &HistoryEvent) -> Result<Self> {
510        if event.kind != HistoryEventKind::TransitionAccepted {
511            return Err(TixError::Parse(format!(
512                "History entry is a `{}`, not a `transition_accepted`",
513                event.kind
514            )));
515        }
516        let malformed =
517            |what: &str| TixError::Parse(format!("transition record {what}: `{}`", event.detail));
518        // The reason is split off at the FIRST anchored marker: the head is the
519        // only `)` followed by `; override: `, so a reason containing the bare
520        // marker survives. See `OVERRIDE_MARKER`.
521        let (head, override_reason) = match event.detail.split_once(OVERRIDE_MARKER) {
522            Some((head, reason)) => (head, Some(reason.trim().to_string())),
523            None => (event.detail.as_str(), None),
524        };
525        // The marker *includes* the head's closing paren, so put it back and let
526        // the grammar below see one shape whether or not a reason was present. The
527        // alternative — two separate shapes to parse — is how the two halves of a
528        // grammar drift apart.
529        let head = match &override_reason {
530            Some(_) => format!("{head})"),
531            None => head.to_string(),
532        };
533        let head = head.as_str();
534        let (from, rest) = head
535            .split_once(DETAIL_ARROW)
536            .ok_or_else(|| malformed("has no `from -> to`"))?;
537        let rest = rest
538            .strip_suffix(')')
539            .ok_or_else(|| malformed("has no closing `)` after the categories"))?;
540        let (to, categories) = rest
541            .split_once(CATEGORY_OPEN)
542            .ok_or_else(|| malformed("has no ` (` before the categories"))?;
543        let (from_category, to_category) = categories
544            .split_once(DETAIL_ARROW)
545            .ok_or_else(|| malformed("has no `from_category -> to_category`"))?;
546        for (name, value) in [
547            ("from", from),
548            ("to", to),
549            ("from_category", from_category),
550            ("to_category", to_category),
551        ] {
552            if value.trim().is_empty() {
553                return Err(malformed(name));
554            }
555        }
556        Ok(Self {
557            timestamp: event.timestamp.clone(),
558            actor: event.actor.clone(),
559            from: from.to_string(),
560            to: to.to_string(),
561            from_category: from_category.to_string(),
562            to_category: to_category.to_string(),
563            override_reason,
564        })
565    }
566
567    /// The History entry to append for this decision.
568    pub fn to_event(&self) -> HistoryEvent {
569        HistoryEvent::new(
570            self.timestamp.clone(),
571            self.actor.clone(),
572            HistoryEventKind::TransitionAccepted,
573            self.detail(),
574        )
575    }
576
577    /// Whether this decision is a same-column no-op record.
578    ///
579    /// The seam never appends one — idempotency is decided by
580    /// `TransitionOutcome::Accepted { changed: false }` — but a **hand-edited** file
581    /// can contain one, and WP06's `lint_direct_edits` needs to tell that apart
582    /// from a real move rather than reading it as "status became todo".
583    pub fn is_no_op(&self) -> bool {
584        self.from == self.to
585    }
586}
587
588/// One recorded mutation, and one line of the `## History` section.
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct HistoryEvent {
591    /// When the mutation happened, RFC 3339.
592    pub timestamp: String,
593    /// Who performed it. Handled like [`Issue::assignee`]: a bare handle or a
594    /// full identity, with a legacy `@alice` tolerated on read.
595    pub actor: String,
596    /// What kind of mutation this was.
597    pub kind: HistoryEventKind,
598    /// The field-aware description shown in detail views.
599    pub detail: String,
600}
601
602impl HistoryEvent {
603    /// Build an event.
604    pub fn new(
605        timestamp: impl Into<String>,
606        actor: impl Into<String>,
607        kind: HistoryEventKind,
608        detail: impl Into<String>,
609    ) -> Self {
610        Self {
611            timestamp: timestamp.into(),
612            actor: actor.into(),
613            kind,
614            detail: detail.into(),
615        }
616    }
617
618    /// The one-line form written to the file:
619    /// `- <timestamp> — <actor> — <kind> — <detail>`.
620    ///
621    /// Four fields, not the three an earlier draft of `data-model.md` §8
622    /// specified: a three-field form cannot carry `actor`, so the struct field
623    /// would not survive a round trip and the audit trail would silently lose
624    /// who performed each mutation. `data-model.md` §8 is corrected to match on
625    /// the base and mission branches — it is a protected path, so the fix is not
626    /// in this lane. **Read §8 there, not this comment**, if the two ever
627    /// disagree.
628    ///
629    /// A blank actor renders as an empty field rather than being dropped, so
630    /// the field count stays fixed and parsing never has to guess.
631    pub(super) fn render(&self) -> String {
632        format!(
633            "- {t} {h} {a} {h} {k} {h} {d}",
634            t = self.timestamp.trim(),
635            h = HISTORY_SEP,
636            a = self.actor.trim(),
637            k = self.kind.as_str(),
638            d = self.detail
639        )
640    }
641
642    /// Parse one rendered list item.
643    ///
644    /// The four fields are split on the **first three** separators only, so a
645    /// detail or actor containing an em dash survives intact.
646    pub(super) fn parse(line: &str) -> Result<Self> {
647        let item = line
648            .trim()
649            .strip_prefix("- ")
650            .ok_or_else(|| TixError::Parse(format!("History entry is not a list item: {line}")))?;
651        // Split on the first three separators only, so a detail containing an
652        // em dash survives intact. Fields are read **positionally** rather than
653        // as "the next non-empty value": the actor may legitimately be blank,
654        // which is exactly what `render` writes when no identity was recorded,
655        // so demanding a non-empty value there would make the writer's own
656        // output unparseable.
657        let parts: Vec<&str> = item.splitn(4, HISTORY_SEP).collect();
658        let missing =
659            |what: &str| TixError::Parse(format!("History entry is missing `{what}`: {line}"));
660        if parts.len() < 3 {
661            return Err(missing("a field separator"));
662        }
663        let timestamp = parts[0].trim();
664        if timestamp.is_empty() {
665            return Err(missing("timestamp"));
666        }
667        let actor = parts[1].trim().to_string();
668        let kind_text = parts[2].trim();
669        if kind_text.is_empty() {
670            return Err(missing("kind"));
671        }
672        let kind = kind_text.parse::<HistoryEventKind>()?;
673        let detail = parts.get(3).copied().unwrap_or_default().trim().to_string();
674        Ok(Self {
675            timestamp: timestamp.to_string(),
676            actor,
677            kind,
678            detail,
679        })
680    }
681}
682
683/// Whether a line is a **recognised** History entry attempt.
684///
685/// Recognition requires the U+2014 separator, and nothing else. That is
686/// deliberately stricter than "looks like" one: a looser test here is not a
687/// nicety, it decides whether a line is *moved into the audit log* or left in
688/// the body. A timestamp-shaped heuristic accepted a body to-do item
689/// (`- 2026-02-02 review the migration`) as an entry attempt, which emptied
690/// `body` and made `tix lint` blame the author — the original M3 defect arriving
691/// through a different door, while the documentation claimed the opposite.
692///
693/// A line that is genuinely an entry but was typed with ASCII hyphens is
694/// therefore *not* recognised, and never becomes an event. It may still be
695/// *retained* in the section; see [`looks_like_an_unrecognised_entry`].
696pub fn looks_like_history_entry(line: &str) -> bool {
697    let item = line.trim();
698    let Some(rest) = item.strip_prefix("- ") else {
699        return false;
700    };
701    rest.contains(HISTORY_SEP)
702}
703
704/// Whether a line looks like a History entry that was **not** recognised — an
705/// entry hand-typed with ASCII hyphens instead of U+2014.
706///
707/// This is the shape test behind two decisions, and they use it differently:
708///
709/// - `parse_front` asks whether an ambiguous line is a hand-edited *entry* rather
710///   than body prose. Being wrong in that direction costs the author's bytes,
711///   because a demoted line is body text and a later `tix edit --body` destroys
712///   it with no trace.
713/// - `tix lint` asks whether to *report* a line, where a false positive costs a
714///   user-visible diagnostic on every run and a false negative costs a silent
715///   typo.
716///
717/// A mistyped entry has the whole shape of one: a date, then three further
718/// hyphen-separated fields — `<timestamp> - <actor> - <kind> - <detail>`. Both
719/// halves are required. An earlier version accepted any 10-character run of
720/// digits, dashes and punctuation, which flagged `- 10:00-11:00 sync with infra`,
721/// `- 1234567890 is the upstream ticket`, `+49-30-12345678` and `1.2.3.4.5.6`; a
722/// later one required only three fields, which flagged `- 2026-01-01 - Bob -
723/// Done`. Four fields is what the format actually specifies, so that is what is
724/// required.
725pub fn looks_like_an_unrecognised_entry(line: &str) -> bool {
726    let Some(rest) = strip_list_marker(line) else {
727        return false;
728    };
729    if rest.contains(HISTORY_SEP) {
730        return false; // genuinely recognised; not this function's business
731    }
732    entry_shaped_by_ascii_hyphens(rest, STRICT_TAIL_FIELDS)
733}
734
735/// Whether a line is an **entry attempt**: a list item whose content is shaped
736/// like an entry, under any accepted marker.
737///
738/// This is the parser's question, and it is deliberately looser than
739/// [`looks_like_an_unrecognised_entry`]'s, because the two have opposite costs.
740/// Retaining a line that turns out to be prose moves it into the audit log *and
741/// says so*; demoting a line that turns out to be an entry puts it in the body,
742/// where the next `tix edit --body` deletes it with no trace. Retention is the
743/// recoverable direction, so the parser errs towards retaining and the
744/// diagnostic errs towards silence.
745/// Whether a line introduces the `## History` section, under the same
746/// tolerance the parser applies: trailing spaces or tabs are ignored, leading
747/// whitespace is not.
748///
749/// Public so `tix lint` can report a **misplaced** section using the parser's own
750/// rule. A second copy of this test could disagree with the parser about which
751/// lines are headings, and then lint would report violations that do not exist
752/// while missing ones that do.
753pub fn is_history_heading(line: &str) -> bool {
754    line.trim_end_matches([' ', '\t']) == HISTORY_HEADING
755}
756
757/// One line of the `## History` section.
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(untagged)]
760pub enum HistoryEntry {
761    /// A well-formed event.
762    Event(HistoryEvent),
763    /// A line that could not be parsed, preserved **verbatim**.
764    ///
765    /// The section is documented as human-editable, so a hand edit will
766    /// eventually produce a line this parser cannot read — ASCII hyphens
767    /// instead of the U+2014 separators is the obvious one. Such a line is
768    /// never dropped, never coerced, and never auto-repaired: it is carried
769    /// through a read/write cycle unchanged and reported by `tix lint` as a
770    /// `history` finding on that issue only. It must never fail the
771    /// parse of the whole file, because one mistyped line in one issue would
772    /// otherwise make every issue in the repository unreadable.
773    Unparsed(String),
774}
775
776/// The append-only event log of an issue (`data-model.md` §8).
777///
778/// Append-only is a hard guarantee of this type: the only mutating operation
779/// is [`History::append`]. There is deliberately no remove, no reorder, no
780/// trim, and no compaction — those are out of scope, but the container is
781/// shaped so a future retention policy has a seam.
782#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
783pub struct History {
784    /// Every line of the section, in file order, which is append order.
785    ///
786    /// Held as an ordered list of entries rather than a flat `Vec<HistoryEvent>`
787    /// so an **unparseable** line keeps its position and its exact text. That
788    /// is what lets the section round-trip byte-stably without ever dropping or
789    /// repairing a line a human hand-edited.
790    pub entries: Vec<HistoryEntry>,
791}
792
793impl History {
794    /// An empty log.
795    pub fn new() -> Self {
796        Self::default()
797    }
798
799    /// Append one event. Never rewrites, reorders, or removes an existing line.
800    pub fn append(&mut self, event: HistoryEvent) {
801        self.entries.push(HistoryEntry::Event(event));
802    }
803
804    /// Append with the current UTC timestamp.
805    pub fn append_now(&mut self, actor: &str, kind: HistoryEventKind, detail: &str) {
806        self.append(HistoryEvent::new(utc_now_rfc3339(), actor, kind, detail));
807    }
808
809    /// The parsed events, in file order.
810    ///
811    /// Unparseable lines are excluded here; see [`History::unparsed`].
812    pub fn events(&self) -> Vec<&HistoryEvent> {
813        self.entries
814            .iter()
815            .filter_map(|e| match e {
816                HistoryEntry::Event(event) => Some(event),
817                HistoryEntry::Unparsed(_) => None,
818            })
819            .collect()
820    }
821
822    /// Lines that could not be parsed, verbatim and in file order.
823    ///
824    /// Non-empty means the section was hand-edited into a shape this parser
825    /// does not accept. `tix lint` reports each as a `history`
826    /// finding; nothing else treats it as an error.
827    pub fn unparsed(&self) -> Vec<&str> {
828        self.entries
829            .iter()
830            .filter_map(|e| match e {
831                HistoryEntry::Event(_) => None,
832                HistoryEntry::Unparsed(line) => Some(line.as_str()),
833            })
834            .collect()
835    }
836
837    /// Whether the section has no lines at all.
838    pub fn is_empty(&self) -> bool {
839        self.entries.is_empty()
840    }
841
842    /// How many lines the section holds, parsed or not.
843    pub fn len(&self) -> usize {
844        self.entries.len()
845    }
846
847    /// Render the section, including its heading and the blank line after it.
848    ///
849    /// An empty log renders to the empty string, so the section is *omitted*
850    /// from the file entirely rather than written as an empty heading. An
851    /// unparseable line is re-emitted exactly as it was read, in place, so a
852    /// read/write cycle is byte-stable whether or not the section is clean.
853    pub fn render(&self) -> String {
854        if self.is_empty() {
855            return String::new();
856        }
857        let mut out = String::from(HISTORY_HEADING);
858        out.push_str("\n\n");
859        for entry in &self.entries {
860            match entry {
861                HistoryEntry::Event(event) => {
862                    out.push_str(&event.render());
863                    out.push('\n');
864                }
865                HistoryEntry::Unparsed(line) => {
866                    out.push_str(line);
867                    out.push('\n');
868                }
869            }
870        }
871        out.push('\n');
872        out
873    }
874
875    /// Parse a leading `## History` section, returning the log and whatever body
876    /// text followed it.
877    ///
878    /// Only a section at the *front* of the body is recognised, matching the
879    /// position `data-model.md` §8 pins (immediately after the frontmatter). A
880    /// `## History` heading appearing later in the prose is left alone as
881    /// ordinary body text.
882    ///
883    /// The section ends at the first line that is neither blank nor a list item,
884    /// so a **blank line between entries does not end it** — a blank line inside
885    /// a list is the most ordinary thing a human or a Markdown formatter
886    /// produces, and terminating there would silently demote audit entries to
887    /// body prose and reorder the log. Unparseable list items are kept verbatim
888    /// rather than failing the file.
889    pub fn parse_front(body: &str) -> (Self, String) {
890        let lead = body.len() - body.trim_start_matches('\n').len();
891        let trimmed = &body[lead..];
892        let Some(rest) = trimmed.strip_prefix(HISTORY_HEADING) else {
893            return (History::new(), body.to_string());
894        };
895        // The heading must be alone on its line — but trailing spaces and tabs on
896        // it are still that line. Requiring a bare `\n` meant `## History   ` was
897        // not recognised at all, and since the heading is then neither the
898        // section nor body, the *first* relationship write rendered a second
899        // `## History` above the original. Cycle 6 executed that; it had been
900        // filed against #1299 as a CRLF-only consequence, which understated it —
901        // a trailing space is a far likelier trigger than CRLF.
902        let rest = rest.trim_start_matches([' ', '\t']);
903        // #1299: a CRLF file spells the heading's line ending `\r\n`.
904        let Some(after_heading) = rest
905            .strip_prefix("\r\n")
906            .or_else(|| rest.strip_prefix('\n'))
907        else {
908            return (History::new(), body.to_string());
909        };
910        let after_heading = after_heading.trim_start_matches(['\r', '\n']);
911
912        let mut history = History::new();
913        let mut offset = 0usize;
914        // Whether the section carried a list item that was **judged prose and
915        // dropped by the `break` below**. That is the #1336 shape — a list line
916        // above ordinary prose empties the section and deletes the heading — and
917        // it is what the empty-history guard restores. A section of pure prose (a
918        // bare `## History` heading above a paragraph) carries no list item, so
919        // dropping the derived heading stays correct.
920        let mut saw_rejected_list_item = false;
921        for line in after_heading.split_inclusive('\n') {
922            // `item` is the trimmed form used for *shape* decisions; `raw_line`
923            // is what a preserved `Unparsed` entry stores, so a hand edit keeps
924            // its own bytes.
925            let raw_line = line;
926            let item = raw_line.trim();
927            if item.is_empty() {
928                // A blank line *inside* the section is skipped, not treated as
929                // the end of it. It is consumed, so the body that follows does
930                // not start with stray blank lines.
931                offset += line.len();
932                continue;
933            }
934            if !looks_like_history_entry(item) {
935                // The section has no end-of-section delimiter, so this line is
936                // ambiguous: either body prose, or a hand-edited line *inside*
937                // the section. The two are told apart by what follows.
938                //
939                // If every remaining non-blank line is entry-shaped, the
940                // ambiguous line was a hand edit inside the section, so the
941                // section continues and the line is kept verbatim as
942                // `Unparsed` — nothing is dropped and the real events after it
943                // stay events. If anything after it is not entry-shaped, this
944                // is body prose and the section genuinely ends here.
945                //
946                // The alternative — absorbing any `- ` line — moved body content
947                // into the audit log, deleted the blank line separating them on
948                // the first write, and then had `tix lint` blaming the author's
949                // prose as unparseable entries. Trigger was ordinary:
950                // `tix edit --body "- step one\n- step two"`.
951                // Collected rather than consumed with `next()` + `all()`: that
952                // skips judging the *first* following line, and `all()` over an
953                // empty remainder is vacuously true — so a section followed by a
954                // single non-entry line was absorbed. Both mistakes are the same
955                // one: judging nothing and concluding "all of it is entries".
956                let following: Vec<&str> = after_heading[offset + line.len()..]
957                    .lines()
958                    .filter(|l| !l.trim().is_empty())
959                    .collect();
960                // Two different questions, and conflating them is what broke this
961                // twice.
962                //
963                // *Does anything after this disqualify it as body prose?* When
964                // nothing follows, nothing can — `all()` is then vacuously true
965                // and that is the **correct** answer, not a bug. A guard
966                // requiring a non-empty remainder (added to stop an earlier
967                // vacuous-truth defect) made the last line in a section always
968                // terminate it, which deleted the `## History` heading, demoted
969                // the author's line into the body, and rendered the next append
970                // *above* it — file order stopped being append order, and
971                // `tix lint` said nothing because a demoted line is body prose
972                // and the unparsed loop no longer sees it.
973                //
974                // *Is this line shaped like a list item at all?* That still
975                // decides, at end of section, whether keeping it is defensible.
976                // `- step one` at EOF is far more likely a hand-edited entry than
977                // the only body content of an issue, and keeping it *discloses*
978                // the problem. A bare paragraph at EOF is prose, full stop, and
979                // absorbing it would pollute the audit log.
980                // Three inputs, and which ones matter depends on whether
981                // anything follows. Cycle 5 found this half-fixed: consulting
982                // only `is_list_item`, and only when nothing followed, left the
983                // whole defect alive for any ambiguous line with body prose
984                // after it.
985                //
986                // The asymmetry that decides the design: a *demoted* line is body
987                // text, and `tix edit --body` replaces the body wholesale — so a
988                // hand-edited entry that was demoted is destroyed by an ordinary
989                // command, with lint silent. A *retained* line is kept verbatim
990                // inside the section and reported. Given the choice, retaining is
991                // the recoverable one, so the tie only goes to "this is prose"
992                // when the line is not plausibly an entry at all.
993                let plausible_entry = is_entry_attempt(item);
994                let after_all_entries = following.iter().all(|l| looks_like_history_entry(l));
995                let keep = if following.is_empty() {
996                    // Nothing after to judge by, so shape alone decides. A bare
997                    // paragraph at end of section is prose; anything list-shaped
998                    // is kept, because there is no following content that could
999                    // have been prose instead.
1000                    is_list_item(item)
1001                } else {
1002                    plausible_entry || after_all_entries
1003                };
1004                if !keep {
1005                    // A list-shaped line is the one case where the `break`
1006                    // discards something that could have been an audit entry, so
1007                    // record it for the empty-history guard after the loop.
1008                    saw_rejected_list_item |= is_list_item(item);
1009                    break;
1010                }
1011                // The **raw** line, not the trimmed one: `Unparsed` promises a
1012                // hand edit is carried through a read/write cycle unchanged, and
1013                // trimming silently repaired leading indentation and trailing
1014                // whitespace — exactly the auto-repair data-model §8 forbids.
1015                history.entries.push(HistoryEntry::Unparsed(
1016                    raw_line.trim_end_matches('\n').to_string(),
1017                ));
1018                offset += line.len();
1019                continue;
1020            }
1021            match HistoryEvent::parse(item) {
1022                Ok(event) => history.entries.push(HistoryEntry::Event(event)),
1023                // Kept verbatim. Diagnosed by `tix lint`, never repaired, never
1024                // dropped, and never fatal to the file.
1025                // Raw line, for the same reason as above.
1026                // Terminator only. `trim_end()` also eats trailing spaces and
1027                // tabs, which is the auto-repair data-model §8 forbids — and
1028                // `Err` is the arm a *recognised* entry lands in, so it is the
1029                // more likely one to carry real trailing whitespace.
1030                Err(_) => history.entries.push(HistoryEntry::Unparsed(
1031                    raw_line.trim_end_matches('\n').to_string(),
1032                )),
1033            }
1034            offset += line.len();
1035        }
1036
1037        if history.entries.is_empty() && saw_rejected_list_item {
1038            // Nothing was retained and a list-shaped line was dropped. The
1039            // heading was consumed above, so the current return
1040            // (`after_heading[offset..]`) would delete it while leaving every
1041            // line after it as body prose: `tix view` loses the section and only
1042            // a later append renders a new heading above the demoted text. That
1043            // is #1336 — a list-shaped line judged prose empties the whole
1044            // section and deletes the audit trail with no diagnostic. Return the
1045            // original body verbatim (heading and all) so the file round-trips.
1046            //
1047            // A section of **pure prose** (no list item) is deliberately not
1048            // restored: the heading is derived from the log, and `storage`'s
1049            // `t010_an_empty_history_section_is_dropped_not_preserved` pins that
1050            // a heading with nothing to record is omitted rather than kept as a
1051            // bare heading. There is no audit entry to lose there.
1052            return (history, body.to_string());
1053        }
1054        (history, after_heading[offset..].to_string())
1055    }
1056}