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}