tix/model.rs
1//! Issue data model and file-surface types.
2//!
3//! Mirrors `contracts/file-format.md` and `data-model.md` exactly. The
4//! [`Issue`] struct is the Serde surface for the YAML frontmatter of an
5//! issue file; the markdown body is carried in `body` and round-trips
6//! verbatim. [`Status`], [`Priority`] and [`Labels`] are the value types
7//! that appear in frontmatter.
8
9use std::collections::{BTreeMap, HashSet};
10use std::fmt;
11use std::str::FromStr;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::error::{Result, TixError};
17use crate::relationships::{IssueView, Relationship};
18use crate::slug::slugify;
19
20/// A board column name. A newtype over the user-defined column strings from
21/// `tix.toml`; unknown names survive deserialization because columns are
22/// config-defined (a renamed column renders as a "Mismatched" pseudo-column,
23/// never a crash).
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25pub struct Status(pub String);
26
27impl Status {
28 /// Create a status from a column name.
29 pub fn new(name: impl Into<String>) -> Self {
30 Status(name.into())
31 }
32
33 /// Whether this status equals `name` (exact, case-sensitive).
34 pub fn is(&self, name: &str) -> bool {
35 self.0 == name
36 }
37
38 /// The column name this status refers to.
39 pub fn display(&self) -> &str {
40 &self.0
41 }
42}
43
44impl Default for Status {
45 fn default() -> Self {
46 Status("todo".to_string())
47 }
48}
49
50impl fmt::Display for Status {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(&self.0)
53 }
54}
55
56/// Issue priority.
57///
58/// The documented set is `low` / `medium` / `high` with `medium` as the
59/// default (spec + data-model). Any other value is preserved as-is for
60/// forward compatibility (data-model: "unknown values accepted as-is",
61/// "preserved ... but not normalized"), so e.g. `urgent` round-trips
62/// unchanged.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
64pub enum Priority {
65 Low,
66 #[default]
67 Medium,
68 High,
69 /// An unknown priority string, preserved verbatim (forward-compatible).
70 ///
71 /// The empty string is *not* treated as unknown: it is an explicitly unset
72 /// priority, which `tix lint` accepts and which round-trips as `''`. It stays
73 /// in this variant rather than getting a variant of its own because
74 /// `Priority` is public API on a published crate, and adding a variant to a
75 /// non-`#[non_exhaustive]` enum is a semver break for every downstream
76 /// `match`. Use [`Priority::is_undefined`] rather than comparing the string.
77 Other(String),
78}
79
80impl Priority {
81 fn as_str(&self) -> &str {
82 match self {
83 Priority::Low => "low",
84 Priority::Medium => "medium",
85 Priority::High => "high",
86 Priority::Other(s) => s,
87 }
88 }
89
90 /// Whether the priority is explicitly **unset** — written `priority: ''`.
91 ///
92 /// Distinct from [`Priority::default`], which is `Medium`. An issue can
93 /// therefore say "no priority" without being read as "medium", and without
94 /// the value being silently rewritten on the next write.
95 ///
96 /// `tix lint` accepts this state; see [`crate::commands::lint`] for the
97 /// check it is exempt from.
98 pub fn is_undefined(&self) -> bool {
99 matches!(self, Priority::Other(s) if s.is_empty())
100 }
101}
102
103impl FromStr for Priority {
104 type Err = std::convert::Infallible;
105
106 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
107 Ok(match s.to_ascii_lowercase().as_str() {
108 "low" => Priority::Low,
109 "medium" => Priority::Medium,
110 "high" => Priority::High,
111 _ => Priority::Other(s.to_string()),
112 })
113 }
114}
115
116impl fmt::Display for Priority {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str(self.as_str())
119 }
120}
121
122impl Serialize for Priority {
123 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
124 s.serialize_str(self.as_str())
125 }
126}
127
128impl<'de> Deserialize<'de> for Priority {
129 fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
130 let raw = String::deserialize(d)?;
131 match raw.parse() {
132 Ok(parsed) => Ok(parsed),
133 Err(_) => Ok(Priority::Other(raw)),
134 }
135 }
136}
137
138/// Issue labels. Free-form strings kept exactly as authored (case and
139/// spaces preserved); default `[]`.
140pub type Labels = Vec<String>;
141
142/// Assignee file-encoding rule (FR-027 / FR-014, `contracts/file-format.md`
143/// rule 5).
144///
145/// - **In memory**: a manual handle is always bare (`alice`, never
146/// `@alice`); a git-derived identity is the full `Name <email>` string,
147/// verbatim.
148/// - **Read** ([`de_assignee`]): a single leading `@` is stripped, so both
149/// the legacy `@alice` bytes and the bare `alice` bytes normalize to the
150/// same in-memory handle. A full identity / identity-like value never
151/// starts with `@` and passes through untouched.
152/// - **Write** ([`se_assignee`]): a value carrying an identity form (both `<`
153/// and `>` present, e.g. `Name <email>`) is persisted verbatim — no forced
154/// `@` and no mangling of the angle brackets. Any other value is a manual
155/// handle and is persisted with a single leading `@`, byte-identical to the
156/// legacy `@alice` files (C-010) and to what `contracts/cli.md` conformance
157/// pins for `--assignee @alice`.
158/// - `None` persists as `null` (omitted by `skip_serializing_if`).
159///
160/// The in-memory value stays display-neutral: the `@` cosmetic a CLI/TUI
161/// renders around handles is their concern, not the model's.
162fn de_assignee<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
163 let opt = Option::<String>::deserialize(d)?;
164 Ok(opt.map(|v| v.strip_prefix('@').unwrap_or(&v).to_string()))
165}
166
167/// Serialize an assignee per the encoding rule documented on [`de_assignee`]:
168/// identity-like values verbatim, manual handles `@`-prefixed (legacy bytes).
169fn se_assignee<S: Serializer>(opt: &Option<String>, s: S) -> std::result::Result<S::Ok, S::Error> {
170 match opt {
171 None => s.serialize_none(),
172 Some(value) => {
173 let is_identity = value.contains('<') && value.contains('>');
174 let full = if is_identity || value.starts_with('@') {
175 value.clone()
176 } else {
177 format!("@{value}")
178 };
179 s.serialize_str(&full)
180 }
181 }
182}
183
184/// Serialization rule for the optional planning lists `cycles`/`milestones`
185/// (`contracts/file-format-planning.md`): the key is omitted when absent or
186/// empty (only non-empty lists serialize), and duplicate entries are
187/// collapsed on write preserving first-seen order.
188fn skip_planning_list(v: &Option<Vec<String>>) -> bool {
189 v.as_deref().is_none_or(|items| items.is_empty())
190}
191
192fn se_planning_list<S: Serializer>(
193 v: &Option<Vec<String>>,
194 s: S,
195) -> std::result::Result<S::Ok, S::Error> {
196 let items: Vec<&String> = match v {
197 Some(items) => {
198 items
199 .iter()
200 .fold((Vec::new(), HashSet::new()), |(mut out, mut seen), item| {
201 if seen.insert(item.as_str()) {
202 out.push(item);
203 }
204 (out, seen)
205 })
206 .0
207 }
208 None => Vec::new(),
209 };
210 items.serialize(s)
211}
212
213/// Today's date as a local-free calendar date in `YYYY-MM-DD`.
214///
215/// Computed from the Unix epoch in UTC; `crate`-wide reproducible and
216/// dependency-free. Later WPs reuse it for `created`/`updated` and the
217/// date prefix of filenames.
218pub fn today() -> String {
219 let secs = SystemTime::now()
220 .duration_since(UNIX_EPOCH)
221 .map(|d| d.as_secs())
222 .unwrap_or(0);
223 let days = (secs / 86_400) as i64;
224 let (y, m, d) = civil_from_days(days);
225 format!("{y:04}-{m:02}-{d:02}")
226}
227
228/// The current UTC instant as RFC 3339 (`2026-09-27T10:14:02Z`), used for
229/// [`HistoryEvent`] timestamps.
230///
231/// Dependency-free like [`today`]: the epoch seconds are converted through
232/// [`civil_from_days`] and the time-of-day is derived arithmetically.
233pub fn utc_now_rfc3339() -> String {
234 let secs = SystemTime::now()
235 .duration_since(UNIX_EPOCH)
236 .map(|d| d.as_secs())
237 .unwrap_or(0);
238 let days = (secs / 86_400) as i64;
239 let rem = secs % 86_400;
240 let (y, m, d) = civil_from_days(days);
241 format!(
242 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
243 rem / 3600,
244 (rem % 3600) / 60,
245 rem % 60
246 )
247}
248
249/// Whether a frontmatter document still carries the retired `links:` key.
250///
251/// The key is not part of the model at all, so it cannot be detected after
252/// deserialization, and it must never be *read* as typed data (C-001). The
253/// frontmatter is therefore also parsed as a plain YAML mapping and checked for
254/// a **top-level** `links` key.
255///
256/// A raw line scan was tried first and was wrong in both directions: it matched
257/// an indented `links:` under some other key, and it missed a quoted `"links":`
258/// key. Matching the parsed top-level key gets both right, and a line inside a
259/// `|`/`>` block scalar is unambiguously not a key.
260///
261/// A frontmatter that will not parse as a mapping falls back to a line scan.
262/// That fallback is **indentation-aware**, matching only column-0 keys.
263///
264/// It has to be. `serde_yaml` rejects duplicate keys when deserializing into a
265/// `Mapping` but *tolerates* a duplicate unknown key when deserializing into
266/// `Issue`, so a file can reach the fallback and still parse. An earlier
267/// indentation-blind fallback then matched the *nested* `links:` in
268/// `meta:\n links: []` and reported a retired key on a file that has none —
269/// the two code paths disagreeing about what "top-level" means, which is
270/// precisely what the primary path was introduced to get right.
271///
272/// **An empty `links` is not a finding.** The key is reported because its *value*
273/// is no longer readable, and an empty value has nothing to lose. Treating mere
274/// presence as the defect made `tix lint` reject every issue file this repository
275/// has ever written — they all carry a `links: []` from the old model — with no
276/// way to make them valid short of hand-editing every file, because the mission
277/// provides no migration for the key. A tool that declares its entire existing
278/// corpus invalid on the strength of a field that was empty is not reporting a
279/// problem, it is refusing to read its own data.
280/// Every top-level key this model knows, in `Issue`'s declaration order, plus the
281/// retired `links:` at the end.
282///
283/// The trailing `links` is not a field — see below — so "declaration order" holds
284/// for the modelled keys and the retired key is simply appended.
285///
286/// Used by [`capture_extra_frontmatter`] to decide what is "unknown". Kept as an
287/// explicit list rather than derived from the struct because a macro cannot read a
288/// struct's own field names, and a mistake here is silent data loss: a key wrongly
289/// listed as known is dropped on write, which is the bug #1361 exists to remove.
290///
291/// A mistake in the other direction is equally silent and was equally real: when
292/// `parent` was added to [`Issue`] and not here, [`capture_extra_frontmatter`] took it
293/// for an unknown key and the writer emitted `parent: 7` **twice** in one file — once
294/// from the field, once spliced back from the capture. The file still parsed, so
295/// nothing failed until a test compared bytes.
296/// `known_frontmatter_keys_cover_every_written_key` derives the key set from a
297/// fully-populated [`Issue`] and is the guard for it: adding a frontmatter field
298/// without registering its key here is now a compile-clean, test-red change.
299///
300/// The retired `links:` is here on purpose. It is **recognised but never captured**,
301/// so it is reported by its own `retired_links_key` finding and then dropped (C-001).
302/// Capturing it would round-trip it and quietly undo the typed-relationship
303/// migration — the data would come back, but the model would still not understand it.
304pub const KNOWN_FRONTMATTER_KEYS: &[&str] = &[
305 "number",
306 "id",
307 "title",
308 "status",
309 "labels",
310 "type",
311 "priority",
312 "assignee",
313 "estimate",
314 "commit",
315 "gitlab_id",
316 "linear_id",
317 "mission",
318 "task",
319 "relationships",
320 "related_issues",
321 "parent",
322 // Mission `recurring-issues-01M3C7G7` (WP02): the series/occurrence table.
323 "series",
324 // Mission `project-issue-health-status-01M3C8HX` (WP02): the current health
325 // projection. Registered here the moment the field was added — leaving it out is
326 // the `parent` defect above, which wrote the key twice in one file.
327 "health",
328 "files",
329 "cycles",
330 "milestones",
331 // Mission `first-class-projects-01M3BYBW` (WP03): the project association
332 // list. Registered the moment the field was added — the `parent` defect above
333 // is exactly what an unregistered new field does.
334 "projects",
335 // Mission `epics-work-items-01M3BYW4` (WP03): the epic reference list.
336 // Registered the moment the field was added — an unregistered key is captured
337 // as unknown and spliced back, so every file gains a duplicate block. The
338 // `known_frontmatter_keys_cover_every_written_key` fixture populates it so this
339 // guard can go red.
340 "epics",
341 "reviewer",
342 "start_date",
343 "due_date",
344 "entered_at",
345 "source",
346 // Mission `per-issue-time-tracking-01M3C9A4` (WP01 T004): the issue's current
347 // time entries. Registered the moment the field was added — an unregistered
348 // key is captured as unknown and spliced back, so every file that carries one
349 // gains a duplicate block (the `parent` defect above). The
350 // `known_frontmatter_keys_cover_every_written_key` fixture populates it, so
351 // this guard goes red if the registration ever drifts.
352 "time_entries",
353 "created",
354 "updated",
355 "links",
356];
357
358/// Capture every top-level frontmatter key this model does not know, as
359/// `key -> raw source block` (#1361).
360///
361/// **Raw block, not a parsed value.** Re-serialising a parsed value would re-quote
362/// and re-indent it, so a key written one way would come back another way and every
363/// write would produce a diff. Splicing the original bytes keeps the round trip
364/// byte-stable for content this model has no opinion about — which is the whole
365/// point of preserving it.
366///
367/// A block is the key's own line plus every following line that continues it: an
368/// indented line, or a block-sequence item. The scan is textual on purpose. A YAML
369/// parse would be tidier, but the raw text is what has to be reproduced, and
370/// re-deriving block boundaries from a parse is how a nested list ends up half
371/// captured.
372///
373/// `links` is skipped. See [`KNOWN_FRONTMATTER_KEYS`].
374pub fn capture_extra_frontmatter(frontmatter: &str) -> BTreeMap<String, String> {
375 let mut out = BTreeMap::new();
376 let mut current: Option<(String, String)> = None;
377 for line in frontmatter.lines() {
378 match top_level_key(line) {
379 Some(key) => {
380 if let Some((name, block)) = current.take() {
381 out.insert(name, block);
382 }
383 if KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()) {
384 continue;
385 }
386 current = Some((key, line.to_string()));
387 }
388 None => {
389 // A continuation line: indented, or a block-sequence item. A blank
390 // line ends the block, because YAML does too.
391 if let Some((_, block)) = current.as_mut() {
392 if line.trim().is_empty() {
393 current = None;
394 } else {
395 block.push('\n');
396 block.push_str(line);
397 }
398 }
399 }
400 }
401 }
402 if let Some((name, block)) = current.take() {
403 out.insert(name, block);
404 }
405 out
406}
407
408/// The key of a top-level YAML mapping entry, or `None` for a continuation line.
409///
410/// Only column 0 counts. An indented `key:` is part of a nested mapping, and a
411/// `- item` is a sequence entry; treating either as a top-level key would end the
412/// previous block early and capture a fragment.
413fn top_level_key(line: &str) -> Option<String> {
414 if !line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
415 return None;
416 }
417 let colon = line.find(':')?;
418 let key = &line[..colon];
419 if key.is_empty()
420 || !key
421 .chars()
422 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
423 {
424 return None;
425 }
426 Some(key.to_string())
427}
428
429fn frontmatter_has_retired_links_key(frontmatter: &str) -> bool {
430 if let Ok(mapping) = serde_yaml::from_str::<serde_yaml::Mapping>(frontmatter) {
431 let key = serde_yaml::Value::String("links".into());
432 return match mapping.get(&key) {
433 Some(value) => !links_value_is_empty(value),
434 None => false,
435 };
436 }
437 frontmatter.lines().any(|line| {
438 if line.starts_with(' ') || line.starts_with('\t') {
439 return false;
440 }
441 line.split_once(':')
442 .map(|(key, _)| key.trim() == "links")
443 .unwrap_or(false)
444 })
445}
446
447/// Whether a `links:` value carries no entries, in the shapes the old model
448/// wrote and the shapes a hand-edit can produce: `[]`, `~`/`null`, an empty
449/// scalar, and a block sequence with no items (`links:\n` followed by the next
450/// key). Anything else is populated and therefore a finding.
451fn links_value_is_empty(value: &serde_yaml::Value) -> bool {
452 match value {
453 serde_yaml::Value::Null => true,
454 serde_yaml::Value::Sequence(items) => items.is_empty(),
455 serde_yaml::Value::String(s) => s.trim().is_empty() || s.trim() == "[]",
456 serde_yaml::Value::Mapping(m) => m.is_empty(),
457 serde_yaml::Value::Tagged(t) => links_value_is_empty(&t.value),
458 _ => false,
459 }
460}
461
462/// A strict `YYYY-MM-DD` calendar-date check (shape + real month/day with
463/// per-month day counts and leap years, `year >= 1970`), so `2026-13-99`,
464/// `2026-02-30` and `1969-12-31` are all flagged as malformed. The shared
465/// validation path for date-bearing config and frontmatter fields (FR-007).
466pub fn validate_date(s: &str) -> bool {
467 let b = s.as_bytes();
468 if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
469 return false;
470 }
471 for &c in &b[0..4] {
472 if !c.is_ascii_digit() {
473 return false;
474 }
475 }
476 for &c in &[b[5], b[6], b[8], b[9]] {
477 if !c.is_ascii_digit() {
478 return false;
479 }
480 }
481 let year = u32::from(b[0] - b'0') * 1000
482 + u32::from(b[1] - b'0') * 100
483 + u32::from(b[2] - b'0') * 10
484 + u32::from(b[3] - b'0');
485 let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
486 let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
487 if year < 1970 || !(1..=12).contains(&month) {
488 return false;
489 }
490 day >= 1 && day <= days_in_month(year, month)
491}
492
493/// Real day count for a Gregorian month (leap-aware).
494fn days_in_month(year: u32, month: u32) -> u32 {
495 match month {
496 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
497 4 | 6 | 9 | 11 => 30,
498 2 => {
499 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
500 if leap { 29 } else { 28 }
501 }
502 _ => 0,
503 }
504}
505
506/// Message-bearing [`validate_date`] for surfaces that want to explain what
507/// was wrong. The error names the offending value; callers add field context.
508fn validate_date_msg(s: &str) -> std::result::Result<(), String> {
509 if validate_date(s) {
510 Ok(())
511 } else {
512 Err(format!(
513 "`{s}` is not a `YYYY-MM-DD` calendar date (year >= 1970)"
514 ))
515 }
516}
517
518/// Validate a `(start_date, due_date)` pair for an issue (FR-007, C-002).
519///
520/// Each present date must parse as a calendar date; when both are present,
521/// `due_date >= start_date` must hold. Returns a config-class error naming
522/// both fields on an ordering violation, matching
523/// `contracts/file-format-planning.md`.
524pub fn validate_issue_dates(start_date: Option<&str>, due_date: Option<&str>) -> Result<()> {
525 if let Some(start) = start_date
526 && validate_date_msg(start).is_err()
527 {
528 return Err(TixError::Config(format!(
529 "start_date {start:?} must be a `YYYY-MM-DD` calendar date"
530 )));
531 }
532 if let Some(due) = due_date {
533 if validate_date_msg(due).is_err() {
534 return Err(TixError::Config(format!(
535 "due_date {due:?} must be a `YYYY-MM-DD` calendar date"
536 )));
537 }
538 if let Some(start) = start_date
539 && !date_lte(start, due)
540 {
541 return Err(TixError::Config(format!(
542 "due_date {due:?} must be `>= start_date` {start:?}"
543 )));
544 }
545 }
546 Ok(())
547}
548
549/// Chronological order for two validated `YYYY-MM-DD` strings.
550///
551/// Plain lexicographic comparison equals chronological order for the
552/// formats [`validate_date`] accepts (fixed-width zero-padded fields), so
553/// later WPs (overdue, filters, cascade) reuse identical ordering logic.
554pub fn date_lte(a: &str, b: &str) -> bool {
555 a <= b
556}
557
558/// Convert days since the Unix epoch to a `(year, month, day)` civil date
559/// (Howard Hinnant's `civil_from_days`).
560fn civil_from_days(z: i64) -> (i64, u32, u32) {
561 let z = z + 719_468;
562 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
563 let doe = z - era * 146_097; // [0, 146096]
564 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
565 let y = yoe + era * 400;
566 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
567 let mp = (5 * doy + 2) / 153; // [0, 11]
568 let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
569 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
570 (if m <= 2 { y + 1 } else { y }, m, d)
571}
572
573/// Parse a `### <author> (<YYYY-MM-DD>)` comment head into its parts.
574///
575/// The date is the trailing `(<YYYY-MM-DD>)` group; the author is everything
576/// before it. A head without a well-formed trailing date still yields the
577/// author (the date defaults to `""`), so malformed heads survive the parse
578/// without crashing (`contracts/comments-format.md` rule "preserve verbatim").
579fn parse_comment_head(after: &str) -> (String, String) {
580 let (author, date) = match after.len() {
581 len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
582 let close = len - 1;
583 let open = after[..close].rfind(" (");
584 match open {
585 Some(open) => {
586 let maybe_date = &after[open + 2..close];
587 if maybe_date.len() == 10
588 && maybe_date.as_bytes()[4] == b'-'
589 && maybe_date.as_bytes()[7] == b'-'
590 {
591 (&after[..open], maybe_date)
592 } else {
593 (after, "")
594 }
595 }
596 None => (after, ""),
597 }
598 }
599 _ => (after, ""),
600 };
601 (author.trim_end().to_string(), date.to_string())
602}
603
604/// A single issue comment, parsed from the `## Comments` body section.
605///
606/// Mirrors `contracts/comments-format.md`: each comment is a level-3 heading
607/// `### <author> (<YYYY-MM-DD>)` followed by free text up to the next `###`
608/// head or end of file. Blank lines inside `text` are preserved verbatim
609/// (NFR-005); the model never normalizes hand-authored content.
610#[derive(Debug, Clone, PartialEq, Eq)]
611pub struct Comment {
612 pub author: String,
613 pub date: String,
614 pub text: String,
615}
616
617/// The recurrence metadata an issue carries (`series:`), mission
618/// `recurring-issues-01M3C7G7`.
619///
620/// The current-health half of the `health:` frontmatter key (mission
621/// `project-issue-health-status-01M3C8HX`, WP02 T007).
622///
623/// Every field is the file's own spelling and every field is optional, so a partial
624/// or hand-written block round-trips as written; the health layer interprets it.
625#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
626#[serde(default, deny_unknown_fields)]
627pub struct HealthRef {
628 /// The current state, e.g. `on_track`.
629 #[serde(skip_serializing_if = "Option::is_none")]
630 pub state: Option<String>,
631 /// How it was obtained: `current`, `derived`, or `override`.
632 #[serde(skip_serializing_if = "Option::is_none")]
633 pub source: Option<String>,
634 /// The policy version the value was derived under, when it was derived.
635 #[serde(skip_serializing_if = "Option::is_none")]
636 pub policy_version: Option<String>,
637 /// When the value was last updated, as the event recorded it.
638 #[serde(skip_serializing_if = "Option::is_none")]
639 pub updated_at: Option<String>,
640}
641
642/// One table, two roles, told apart by which fields are set — the definition
643/// carries `rule`, an occurrence carries `key`/`date`. A `role:` flag would be a
644/// second source of truth a writer could disagree with, so there is none:
645/// [`crate::recurrence::series::SeriesRef::role`] derives it.
646///
647/// Identity is (`id`, `date`) and never the issue number, the title, or the
648/// filename (C-002): those are display values that move, and an occurrence that
649/// moved with them would be generated twice.
650#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
651#[serde(deny_unknown_fields)]
652pub struct SeriesRef {
653 /// The stable series id. Required in both roles.
654 pub id: String,
655 /// The series' display name. Mutable label, never identity; on the
656 /// definition, and copied onto an occurrence for readability.
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub name: Option<String>,
659 /// The canonical local date key (`YYYY-MM-DD`) an occurrence was generated
660 /// for. Set on occurrences only.
661 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub date: Option<String>,
663 /// The occurrence's idempotency key (`<series-id>@<YYYY-MM-DD>`). Stored
664 /// rather than re-derived, so a file whose key the current rule would no
665 /// longer produce is still readable — and still a no-op (C-006).
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub key: Option<String>,
668 /// The schedule, as the file spells it. Set on the definition only.
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub rule: Option<SeriesRule>,
671 /// The lifecycle state. Set on the definition only.
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub state: Option<crate::recurrence::series::SeriesLifecycle>,
674 /// The generation cursor: the last date the generator considered. Set on the
675 /// definition only.
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub next_cursor: Option<String>,
678 /// Occurrence dates the user explicitly skipped, so generation never
679 /// recreates them (C-006). Set on the definition only.
680 #[serde(default, skip_serializing_if = "Vec::is_empty")]
681 pub skipped: Vec<String>,
682}
683
684/// The rule half of `series:`, as the file spells it (`data-model.md` §2).
685///
686/// Typed for the fields the contract names and **flat strings** for everything
687/// else, so a cadence key this build does not know round-trips verbatim in
688/// `extensions` instead of being dropped on the next edit. The typed rule with its
689/// validation and its arithmetic is
690/// [`crate::recurrence::rule::RecurrenceRule`], built from this by
691/// [`crate::recurrence::series::Series::from_ref`] — the split exists so the issue
692/// model stays `Eq` (the typed rule's preserved extensions hold floats, which have
693/// no `Eq`) while the file keeps every key it was given.
694#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
695pub struct SeriesRule {
696 /// The family: `daily`, `weekly`, `monthly`, `interval`, `cron-like`.
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub kind: Option<String>,
699 /// The cadence, where the family takes one.
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub interval: Option<u32>,
702 /// The schedule origin, a canonical `YYYY-MM-DD` date.
703 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub anchor: Option<String>,
705 /// The timezone policy, e.g. `+02:00` or a named zone (refused by validation).
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub timezone: Option<String>,
708 /// The cron-like expression, behind the reviewed parser gate.
709 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub expression: Option<String>,
711 /// The unit an `interval` rule counts in: `days`, `weeks`, `months`.
712 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub unit: Option<String>,
714 /// Preserved unknown keys, exactly as they were written.
715 #[serde(default, flatten)]
716 pub extensions: BTreeMap<String, String>,
717}
718
719/// An issue: one markdown file with YAML frontmatter.
720///
721/// Field order and names are frozen by `contracts/file-format.md` FR-012.
722/// `number` is a stored frontmatter key assigned by the storage layer at
723/// create time by scanning the live issue set (issue #52; previously from
724/// `tix.toml` `[state].next_number`, which is no longer persisted); this
725/// module models and validates it but never allocates. `filename` and
726/// `body` are memory-only (`#[serde(skip)]`).
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728pub struct Issue {
729 /// Human-facing sequential number: a convenience alias, allocated
730 /// per-checkout as `max(number) + 1` and **allowed to collide** across
731 /// branches (an agent's and a human's `tix create` may both produce `#7`).
732 /// It is stable for every surviving issue in one store (C-007) but is not
733 /// an identity — use [`Self::id`] for that.
734 pub number: u32,
735 /// Globally unique identity: 8 Crockford base32 characters (see
736 /// [`crate::id`]), assigned at create and stored in the frontmatter and the
737 /// filename prefix.
738 ///
739 /// Optional so every issue written before the id schema keeps parsing
740 /// unchanged; such an issue simply has no canonical id yet (and its
741 /// filename is not id-prefixed). New issues always carry one. Unlike
742 /// `number`, this value never collides by accident: it is random across
743 /// clones, so two branches creating issue #7 each still create two distinct
744 /// issues.
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub id: Option<String>,
747 /// Human title; drives the slug in the filename.
748 #[serde(default)]
749 pub title: String,
750 /// Column (board workflow stage) the issue sits in.
751 #[serde(default)]
752 pub status: Status,
753 /// Free-form labels.
754 #[serde(default)]
755 pub labels: Labels,
756 /// Issue type (free-form, e.g. `bug`, `feature`).
757 ///
758 /// First-class since the type promotion; it used to be kept as a
759 /// `[custom_attributes]` key. `tix lint` accepts a value only from the
760 /// configured `custom_types` (or the built-in defaults). Optional, and
761 /// skipped when absent so a file without it round-trips byte-identically.
762 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
763 pub type_: Option<String>,
764 /// Priority; defaults to `medium`.
765 #[serde(default)]
766 pub priority: Priority,
767 /// Assignee. In memory: bare handle (`alice`) or full `Name <email>`
768 /// identity, verbatim. Files serialize per the rule documented on
769 /// [`de_assignee`]/[`se_assignee`] (legacy `@alice` tolerated on read).
770 #[serde(
771 default,
772 skip_serializing_if = "Option::is_none",
773 deserialize_with = "de_assignee",
774 serialize_with = "se_assignee"
775 )]
776 pub assignee: Option<String>,
777 /// Free-form estimate (e.g. `2`, `S`, `3d`); default `null`.
778 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub estimate: Option<String>,
780 /// Git commit recorded when moved to `done` (FR-026).
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub commit: Option<String>,
783 /// Remote GitLab issue id after export/import.
784 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub gitlab_id: Option<String>,
786 /// Remote Linear issue id after export/import.
787 #[serde(default, skip_serializing_if = "Option::is_none")]
788 pub linear_id: Option<String>,
789 /// Spec Kitty mission slug (e.g. `v3-archive-review`) this issue belongs to, if any.
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub mission: Option<String>,
792 /// Spec Kitty work package id (e.g. `WP01`) this issue links to, if any.
793 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub task: Option<String>,
795 /// Typed relationships to other issues, replacing the retired generic
796 /// `links: Vec<String>` bag (C-001).
797 ///
798 /// Each entry is a [`Relationship`]: a typed, directed edge with a stable
799 /// id and a human-readable display name. The vocabulary is the closed
800 /// four-kind set — `blocks`, `blocked_by`, `duplicates`, `related` — and
801 /// the kind that reaches disk is always the canonical one, so `blocked_by`
802 /// is normalized into `blocks` before it is stored and a pair is never
803 /// stored twice. See [`crate::relationships`] for the direction
804 /// convention, which this field does not restate.
805 ///
806 /// Files without the key parse (`default`). An empty list is **omitted**
807 /// rather than written as `[]`: the typed field is new, so there is no
808 /// legacy expectation to preserve for it, and omitting it keeps an issue
809 /// with no relationships free of a meaningless key.
810 #[serde(default, skip_serializing_if = "Vec::is_empty")]
811 pub relationships: Vec<Relationship>,
812 /// General, cross-repository references to other issues, stored verbatim.
813 ///
814 /// Unlike [`Self::relationships`] (typed, number-keyed, local) this is a
815 /// forward-only, user-authored list of free-form references. An entry is one
816 /// of: a local issue number or selector (`42`, `#42`, a title/filename
817 /// fragment); a relative or absolute path to another issue file (possibly in
818 /// a sibling repository); or a repository URL (optionally with an issue
819 /// reference). The grammar and resolution live in [`crate::related`].
820 ///
821 /// Entries are stored **verbatim** (trimmed) rather than as a re-serialized
822 /// enum, so a path round-trips byte-stable; classification happens at
823 /// read/render time. The list is never mirrored on write: it is user data,
824 /// not a symmetric edge like the retired `links:`.
825 ///
826 /// `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so every file
827 /// without the key parses and round-trips byte-identically, and an empty list
828 /// is omitted rather than written as `[]`.
829 #[serde(default, skip_serializing_if = "Vec::is_empty")]
830 pub related_issues: Vec<String>,
831 /// This issue's **decomposition parent**, as an issue number.
832 ///
833 /// A parent is a decomposition — "this work is part of that" — and is a
834 /// **distinct concept from a typed dependency edge**: `blocks` is a scheduling
835 /// dependency, and conflating the two in one field is prohibited (C-007). It is
836 /// also distinct from the `mission` and `task` Spec Kitty pointer fields, which
837 /// are governance metadata and not a hierarchy (research R2).
838 ///
839 /// **At most one parent** (C-001). The type is a single `Option<u32>`, so two
840 /// parents are not expressible; the field is not a list and no code path appends
841 /// to it. Child membership is *derived* by inverting this pointer and is never
842 /// stored on the parent (C-003) — see [`crate::hierarchy`], which owns that
843 /// derivation.
844 ///
845 /// `#[serde(default)]` so every legacy file without the key parses unchanged,
846 /// and skipped when absent so a file that never had a parent serialises
847 /// byte-identically to its pre-mission form.
848 ///
849 /// The value is an issue **number**, not a selector: a selector may be a title
850 /// fragment, and a title is not stable, so a stored title would silently break
851 /// every child when the parent was retitled. Selectors are the *input* to
852 /// `tix parent set`; the number is what lands in the file.
853 ///
854 /// A reference whose target no longer exists is **kept**, not cleared: it becomes
855 /// the diagnosed `UnresolvedParentState` so it can be repaired.
856 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub parent: Option<u32>,
858 /// The recurrence metadata this issue carries (`series:`), when it is part of
859 /// a series — either the **definition** (it owns the rule) or one of its
860 /// **occurrences** (it carries the occurrence key). Mission
861 /// `recurring-issues-01M3C7G7`, data-model §1/§3/§6; see
862 /// [`crate::recurrence::series`] for the typed model and the index.
863 ///
864 /// One table with two roles decided by which fields are set, rather than a
865 /// `role:` flag a writer could disagree with. Optional and skipped when
866 /// absent, so every file that predates the mission parses unchanged.
867 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub series: Option<SeriesRef>,
869 /// The **current** health value (mission
870 /// `project-issue-health-status-01M3C8HX`, WP02 T007).
871 ///
872 /// A projection of the append-only Health events, kept beside them so a surface
873 /// can read the state without walking the History — and *only* the projection:
874 /// the state, how it was obtained, the policy version, and when it was last
875 /// updated. The **rationale lives in the event**, never here, because a
876 /// frontmatter-only note is exactly the untracked value C-004 forbids.
877 ///
878 /// Optional and skipped when absent, so every file that predates the mission
879 /// parses and re-serializes byte-identically. The state is the file's own
880 /// spelling (like [`SeriesRule`]'s fields): validating it is the health layer's
881 /// job, so a file carrying a value this build does not know still loads and is
882 /// **reported** rather than becoming unreadable.
883 #[serde(default, skip_serializing_if = "Option::is_none")]
884 pub health: Option<HealthRef>,
885 /// Repo-relative file paths of the governing specification (e.g.
886 /// `openspec/changes/<name>/specs/<cap>/spec.md`). Default `[]`.
887 #[serde(default)]
888 pub files: Vec<String>,
889 /// Planning cycle names this issue belongs to (FR-005). Each entry must
890 /// name a `[cycles.<name>]` config entry. `Option` so legacy files lack
891 /// the key entirely; serialized only when non-empty.
892 #[serde(
893 default,
894 skip_serializing_if = "skip_planning_list",
895 serialize_with = "se_planning_list"
896 )]
897 pub cycles: Option<Vec<String>>,
898 /// Milestone names this issue belongs to (FR-005). Each entry must name a
899 /// milestone file in `milestones_dir`. Serialized only when non-empty.
900 #[serde(
901 default,
902 skip_serializing_if = "skip_planning_list",
903 serialize_with = "se_planning_list"
904 )]
905 pub milestones: Option<Vec<String>>,
906 /// Project ids this issue is associated with (mission
907 /// `first-class-projects-01M3BYBW`, decision `01M3BYD1F3NWB0ZYXT1AMJYYVY`).
908 ///
909 /// Holds project **ids**, never names: an id is allocated once and never
910 /// changes, so a project rename cannot break an association (C-002, SC-005).
911 /// An issue may carry **several** associations (C-003), so the field is
912 /// list-valued; a single-valued `project` field is the rejected alternative
913 /// (data-model §1, contract §6).
914 ///
915 /// The wire shape is the same as [`Self::cycles`]/[`Self::milestones`] — an
916 /// `Option<Vec<String>>`, `#[serde(default)]` on read and serialized only when
917 /// non-empty — so a file that never had an association round-trips
918 /// byte-identically to its pre-mission form (NFR-002). The key is `projects`,
919 /// which is **distinct** from `cycles`, `milestones`, `mission`, and `task`:
920 /// four list/pointer reference fields on one struct is exactly where an
921 /// accidental alias would hide, so no serde alias is declared for any of them.
922 ///
923 /// `(issue, project_id)` is unique. The service returns the resulting list and
924 /// storage writes it verbatim; the shared `se_planning_list` renderer
925 /// additionally collapses a duplicate that predates the field, preserving
926 /// first-seen order.
927 ///
928 /// A reference whose project is no longer in the registry is **kept**, not
929 /// dropped: it becomes the diagnosed missing reference so it can be repaired
930 /// (C-005, contract §5).
931 #[serde(
932 default,
933 skip_serializing_if = "skip_planning_list",
934 serialize_with = "se_planning_list"
935 )]
936 pub projects: Option<Vec<String>>,
937 /// Epic ids this issue is referenced by (mission
938 /// `epics-work-items-01M3BYW4`, decision `01M3BYX33ZKN9BB275T0BNZJXD`).
939 ///
940 /// Holds epic **ids**, never names: an id is allocated once and never changes,
941 /// so renaming an epic cannot break a reference (C-003). An issue may be
942 /// referenced by **several** epics (C-003), so the field is list-valued; a
943 /// single-valued `epic` field is the rejected alternative
944 /// (`contracts/epic-registry.md` §3).
945 ///
946 /// The wire shape is the same as [`Self::projects`]/[`Self::cycles`]/
947 /// [`Self::milestones`] — an `Option<Vec<String>>`, `#[serde(default)]` on read
948 /// and serialized only when non-empty — so a file that never had a reference
949 /// round-trips byte-identically to its pre-mission form (NFR-002). The key is
950 /// `epics`, which is **distinct** from `projects`, `cycles`, `milestones`,
951 /// `mission`, `task`, and `files`: five list-valued reference fields on one
952 /// struct is exactly where an accidental alias would hide, so no serde alias is
953 /// declared for any of them.
954 ///
955 /// This is **not** a task or work-item reference (C-001): there is no task
956 /// record type, and a `task`-labelled issue is an ordinary member. The Spec
957 /// Kitty `task` pointer field above is unrelated and untouched.
958 ///
959 /// `(issue, epic_id)` is unique. The service returns the resulting list and
960 /// storage writes it verbatim; the shared `se_planning_list` renderer
961 /// additionally collapses a duplicate that predates the field, preserving
962 /// first-seen order.
963 ///
964 /// A reference whose epic is no longer in the registry is **kept**, not
965 /// dropped: it becomes the diagnosed missing reference so it can be repaired
966 /// (C-005, `contracts/epic-registry.md` §5).
967 #[serde(
968 default,
969 skip_serializing_if = "skip_planning_list",
970 serialize_with = "se_planning_list"
971 )]
972 pub epics: Option<Vec<String>>,
973 /// Advisory reviewer identity (FR-006), free-form like `assignee`.
974 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub reviewer: Option<String>,
976 /// Planning start date, `YYYY-MM-DD` (FR-006).
977 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub start_date: Option<String>,
979 /// Planning due date, `YYYY-MM-DD`; must be `>= start_date` (FR-007).
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub due_date: Option<String>,
982 /// When the issue most recently entered the triage inbox, RFC 3339 UTC
983 /// (`2026-09-30T09:12:44Z`), if it ever did.
984 ///
985 /// Mission `triage-inbox-review-flow-01M3C600`, data-model §2. Queue age is
986 /// measured from here and from nothing else: `updated` moves on every edit and
987 /// `created` predates the queue, so either would report a wait the issue did
988 /// not have (research R4). Read it through [`crate::triage::entry_time`],
989 /// which is the one documented fallback for an issue that has none.
990 ///
991 /// Optional and skipped when absent, so a repository that predates triage
992 /// round-trips byte-identically instead of gaining a key on every file (C-001).
993 ///
994 /// Refreshed on re-entry, not merged — a defer restarts the wait (data-model
995 /// §5) — and deliberately **not** stamped on every edit, which would turn it
996 /// into a second `updated`.
997 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub entered_at: Option<String>,
999 /// Where the issue's current entry came from: `new`, `import`, `defer`, or
1000 /// any other origin the caller documents (data-model §2, `source`).
1001 ///
1002 /// Optional for the same reason as [`Self::entered_at`]. It describes *this*
1003 /// entry rather than the issue's permanent provenance, so it is rewritten
1004 /// whenever the issue re-enters.
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1006 pub source: Option<String>,
1007 /// The issue's **current** time entries (mission
1008 /// `per-issue-time-tracking-01M3C9A4`, WP01 T004), under the frontmatter key
1009 /// `time_entries`.
1010 ///
1011 /// Each element is a [`crate::time_tracking::TimeEntry`]: actual effort at
1012 /// minute precision, with its own stable id, optional calendar date, author
1013 /// and note. This is the current projection only — corrections and deletions
1014 /// stay in the append-only History, never here (C-004) — and it is **not**
1015 /// the free-form `estimate` field above, which keeps its planning meaning
1016 /// (C-005). Nothing about an entry changes status or ownership (C-008).
1017 ///
1018 /// `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so a file that
1019 /// never had an entry parses unchanged and round-trips byte-identically
1020 /// instead of gaining a `time_entries: []` key, which is the same rule
1021 /// [`Self::relationships`] and [`Self::related_issues`] follow.
1022 ///
1023 /// An entry that fails [`crate::time_tracking::TimeEntry::validate`] is kept
1024 /// in this list rather than dropped, so a malformed entry stays readable and
1025 /// reportable instead of blocking the issue or vanishing on the next
1026 /// unrelated write (C-005).
1027 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1028 pub time_entries: Vec<crate::time_tracking::TimeEntry>,
1029 /// Creation date, `YYYY-MM-DD`, the leading component of the filename.
1030 #[serde(default = "today")]
1031 pub created: String,
1032 /// Last-mutation date, `YYYY-MM-DD`.
1033 #[serde(default = "today")]
1034 pub updated: String,
1035 /// Issue file name — path relative to the issues dir. A new-schema name is
1036 /// `{date}-{id}-{slug}` (e.g. `alice/type/status/2026-05-10-A1B2C3D4-fix-login.md`),
1037 /// so a directory listing is creation order; a filename written before the
1038 /// id schema is `{date}-{slug}` (e.g. `2026-05-10-fix-login.md`, or grouped
1039 /// per C-010).
1040 #[serde(skip)]
1041 pub filename: String,
1042 /// Raw markdown body; memory-only, preserved verbatim.
1043 #[serde(skip)]
1044 pub body: String,
1045 /// The append-only `## History` section, parsed out of the body on read and
1046 /// re-rendered on write; memory-only like [`Issue::body`].
1047 ///
1048 /// The section is *stripped* from `body` during parsing, so `body` holds
1049 /// only the issue's own prose and the two never disagree. Rendering puts
1050 /// the section back immediately after the frontmatter, the position
1051 /// `data-model.md` §8 pins.
1052 #[serde(skip)]
1053 pub history: History,
1054 /// Whether the source frontmatter still carries a legacy `links:` key.
1055 ///
1056 /// The key is never read as typed data (C-001) and never migrated; it is
1057 /// recorded here so the service can report it as a
1058 /// `retired_links_key` validation result. Memory-only, like the rest of the
1059 /// file-surface state.
1060 #[serde(skip)]
1061 pub has_retired_links_key: bool,
1062 /// Top-level frontmatter keys this model does not know, as `key -> raw block`.
1063 ///
1064 /// #1361. Before this, an unknown key was dropped on every write: `Issue`
1065 /// deserialises a fixed set of keys and `frontmatter` re-emits exactly those, so
1066 /// a key `tix` does not recognise was deleted by the next unrelated `tix edit` —
1067 /// silently, because `tix lint` had no unknown-key rule at all.
1068 ///
1069 /// The value is the **raw source block**, not a parsed value, and that is the
1070 /// whole design. Re-serialising a parsed value would re-quote and re-indent it, so
1071 /// a key that was written one way would come back another way and every write
1072 /// would produce a spurious diff. Splicing the original bytes back in makes the
1073 /// round trip byte-stable for content this model has no opinion about.
1074 ///
1075 /// A `BTreeMap` rather than a `HashMap` so the render order is deterministic:
1076 /// `HashMap` iteration order would reshuffle these keys on every write and
1077 /// produce a diff in a file that had not changed.
1078 ///
1079 /// `Eq` is preserved (unlike `serde_yaml::Value`, which is not `Eq`), and the
1080 /// blocks are plain `String`s, so `Issue` keeps every one of its derives.
1081 ///
1082 /// The retired `links:` key is **excluded** — see [`Issue::parse`]. It is
1083 /// recognised, reported by its own finding, and dropped (C-001); round-tripping it
1084 /// would silently undo the typed-relationship migration.
1085 #[serde(skip)]
1086 pub extra_frontmatter: BTreeMap<String, String>,
1087 /// Whether the issue is archived (issue #1492).
1088 ///
1089 /// The **filename is the marker**, never a frontmatter field: an archived
1090 /// issue's markdown file is dot-prefixed in place
1091 /// (`docs/issues/<assignee>/.<file>.md`). This field is therefore derived
1092 /// state, not data — it mirrors the leading dot of [`Issue::filename`] and
1093 /// is set by [`crate::storage::Store::list_all`], never written to disk.
1094 /// `#[serde(skip)]` keeps it out of the frontmatter, and it defaults to
1095 /// `false` for every parse.
1096 #[serde(skip)]
1097 pub archived: bool,
1098}
1099
1100// ---------------------------------------------------------------------------
1101// Append-only History section (WP02 T007)
1102// ---------------------------------------------------------------------------
1103
1104/// The heading that introduces the History section, per `data-model.md` §8.
1105///
1106/// The section is recognised by a line whose trimmed content is exactly this —
1107/// nothing else introduces it.
1108pub const HISTORY_HEADING: &str = "## History";
1109
1110/// The separator between the four fields of a rendered History entry
1111/// (U+2014 em dash).
1112const HISTORY_SEP: char = '\u{2014}';
1113
1114/// What kind of mutation a [`HistoryEvent`] records.
1115///
1116/// Closed set per `data-model.md` §8. `blocked_move_acknowledged` is defined
1117/// here so WP04 consumes it without editing this file.
1118#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1119#[serde(rename_all = "snake_case")]
1120pub enum HistoryEventKind {
1121 /// A relationship was created.
1122 RelationshipAdded,
1123 /// A relationship was removed.
1124 RelationshipRemoved,
1125 /// A relationship was retargeted or re-kinded.
1126 RelationshipRetargeted,
1127 /// A move toward completion was confirmed while blockers were open.
1128 BlockedMoveAcknowledged,
1129 /// An issue gained its first parent.
1130 ParentSet,
1131 /// An existing parent reference was repointed.
1132 ParentChanged,
1133 /// A parent reference was removed.
1134 ParentCleared,
1135 /// A status change was accepted by the transition service and applied.
1136 ///
1137 /// WP03. The name says *accepted* because acceptance is the only outcome that
1138 /// reaches the log: a rejected transition is completely inert and appends
1139 /// nothing (contract §3.5), and a re-applied transition is idempotent and also
1140 /// appends nothing. So the presence of this kind in a file **is** the record
1141 /// that the change was accepted — a name like `transition_attempted` would
1142 /// imply rejections are logged here too, and they are not.
1143 TransitionAccepted,
1144 /// A triage inbox issue was accepted into the normal workflow.
1145 ///
1146 /// WP03 (mission `triage-inbox-review-flow-01M3C600`). Four triage kinds
1147 /// rather than one `triage_decided` for the reason the parent kinds are
1148 /// separate: the *decision* is the thing an audit reader is looking for, and
1149 /// `triage_decided — decline …` makes them read the detail to learn which of
1150 /// four mutually exclusive actions was taken. They extend the one #169 log
1151 /// (research R5) — there is no second log and no triage-specific section.
1152 ///
1153 /// Only **applied** decisions reach the log: a refusal appends nothing and a
1154 /// repeated identical decision is a no-op (contract `decision-history.md`),
1155 /// exactly as with [`HistoryEventKind::TransitionAccepted`].
1156 TriageAccepted,
1157 /// A triage inbox issue was declined: moved to the configured declined
1158 /// column, never deleted or archived (C-003).
1159 TriageDeclined,
1160 /// A triage inbox issue was marked a duplicate of another issue.
1161 ///
1162 /// The event records the decision; the typed `duplicates` edge is the
1163 /// [`HistoryEventKind::RelationshipAdded`] entry. One decision, two kinds
1164 /// *only* because the relation already has its own kind and inventing a
1165 /// second spelling for it would leave the graph and the log disagreeing.
1166 TriageDuplicated,
1167 /// A triage inbox issue was deferred: it stays in the inbox with a
1168 /// refreshed entry time (data-model §5).
1169 TriageDeferred,
1170 /// The issue file was archived: renamed in place to a dot-prefixed name
1171 /// (issue #1492).
1172 Archived,
1173 /// An archived issue was restored to its active name (issue #1492).
1174 Restored,
1175 /// An issue's health state was updated, with an actor, a timestamp and a
1176 /// rationale (mission `project-issue-health-status-01M3C8HX`, WP02).
1177 ///
1178 /// One kind for an update and one for an override rather than one
1179 /// `health_changed` with a flag: the *decision* is what an audit reader looks
1180 /// for, and "somebody changed the health" makes them read the detail to learn
1181 /// whether it was a measurement or a person overruling one.
1182 HealthUpdated,
1183 /// A manual health override was applied over a derived value (WP02).
1184 HealthOverridden,
1185 /// An issue gained a project association (mission
1186 /// `first-class-projects-01M3BYBW`, WP03).
1187 ///
1188 /// Two dedicated kinds rather than one generic `projects_changed`, matching
1189 /// the three parent kinds and the four triage kinds: the *direction* of the
1190 /// change is what an audit reader is looking for, and `projects_changed —
1191 /// …` would make them read the detail to learn whether an association was
1192 /// added or removed. The service declares exactly these two as
1193 /// [`crate::projects::ProjectEventKind`], so the vocabulary is decided once
1194 /// and mapped here by [`Self::from_project`].
1195 ProjectAssociated,
1196 /// An issue lost a project association (WP03).
1197 ProjectDissociated,
1198 /// An issue gained an epic reference (mission
1199 /// `epics-work-items-01M3BYW4`, WP03).
1200 ///
1201 /// Two dedicated kinds rather than one generic `epics_changed`, matching the
1202 /// three parent kinds, the four triage kinds, and the two project kinds: the
1203 /// *direction* of the change is what an audit reader is looking for, and
1204 /// `epics_changed — …` would make them read the detail to learn whether a
1205 /// reference was added or removed. The service declares exactly these two as
1206 /// [`crate::epics::EpicEventKind`], so the vocabulary is decided once and
1207 /// mapped here by [`Self::from_epic`].
1208 EpicReferenced,
1209 /// An issue lost an epic reference (WP03).
1210 EpicDereferenced,
1211 /// A generated cycle was explicitly closed, capturing its snapshot.
1212 ///
1213 /// WP05 of mission `repeating-cycle-schedules-01M3C7W1`. These six kinds carry
1214 /// the same append-only grammar as every issue event, but they are written to a
1215 /// **generated cycle record's own body** (`docs/cycles/<stem>.md`), never to an
1216 /// issue: a cycle is not an issue, and the mission's evidence for one is its
1217 /// record, its snapshot and the cursor. One kind per action rather than one
1218 /// `cycle_lifecycle` with a flag, for the same reason the parent/triage/project
1219 /// kinds are split: the *decision* is what an audit reader looks for, and a
1220 /// generic kind would make them read the detail to learn which of five
1221 /// mutually-exclusive actions was taken.
1222 CycleClosed,
1223 /// A generated cycle's due date was extended by one cadence period.
1224 CycleExtended,
1225 /// A generated cycle's sequence was recorded as skipped.
1226 CycleSkipped,
1227 /// A schedule (and its affected instances) was paused.
1228 CyclePaused,
1229 /// A schedule (and its affected instances) was resumed.
1230 CycleResumed,
1231 /// A schedule's future instances were re-derived from its definition.
1232 CycleAdjusted,
1233 /// A closed cycle was explicitly reopened.
1234 ///
1235 /// WP04 of mission `cycle-capacity-planning-01M3C85C`. A closed cycle's capacity is
1236 /// the immutable snapshot captured at close (C-008), so reopening it — the one way
1237 /// its capacity may be configured again — is a capacity-affecting change and is
1238 /// recorded rather than performed silently. It extends the same cycle family as
1239 /// [`HistoryEventKind::CycleClosed`] and is written to the **generated cycle
1240 /// record's own body**, beside `cycle_closed`: a cycle's evidence is its record, its
1241 /// snapshot and the cursor, and there is no second log. One kind rather than a flag
1242 /// on `cycle_closed`, for the reason the other cycle kinds are split — the *decision*
1243 /// is what an audit reader looks for. An applied reopen appends exactly one entry;
1244 /// the prior snapshot and every prior event are retained verbatim.
1245 CycleReopened,
1246 /// A closed cycle's capacity was changed through the explicit reopen path.
1247 ///
1248 /// WP04. The edit's `detail` carries the reason, the prior snapshot's stable identity
1249 /// and the before/after calculation summaries (`data-model.md` §7), so the entry says
1250 /// what changed without the reader reconstructing it. Nothing rewrites the prior
1251 /// snapshot: the edit produces a new *current* calculation and this one line of
1252 /// evidence.
1253 CycleCapacityEdited,
1254 /// A time entry was created on an issue (mission
1255 /// `per-issue-time-tracking-01M3C9A4`, WP01 T047).
1256 ///
1257 /// Pre-declared here, like `blocked_move_acknowledged` above, so WP02's mutation
1258 /// service appends the event without editing this file. Three kinds rather than one
1259 /// `time_changed` for the reason the parent, triage, project and epic families are
1260 /// split: the *decision* is what an audit reader is looking for, and a generic kind
1261 /// would make them read the detail to learn whether an entry was added, corrected or
1262 /// removed. Only an **applied** mutation appends one (`contracts/time-entry.md`,
1263 /// data-model §2): a refused write appends nothing, and the `detail` carries the
1264 /// entry id and the created value so the entry is attributable after any later edit.
1265 TimeEntryCreated,
1266 /// A time entry's duration, date, author or note was corrected (WP01 T047).
1267 ///
1268 /// The `detail` carries the entry id and the before/after values, so the prior value
1269 /// remains attributable after the correction (C-004). Pre-declared with its siblings
1270 /// so WP02 never edits this file.
1271 TimeEntryUpdated,
1272 /// A time entry was deleted from an issue's current projection (WP01 T047).
1273 ///
1274 /// The entry leaves the issue's frontmatter and the event, with its prior value,
1275 /// stays in History — a deletion removes the current projection, never the evidence
1276 /// (C-004). Pre-declared with its siblings so WP02 never edits this file.
1277 TimeEntryDeleted,
1278 /// An issue's `estimate` value changed (mission
1279 /// `burndown-scope-analytics-01M3C9J1`, WP01 T046).
1280 ///
1281 /// FR-002 distinguishes scope growth from completion, and an estimate change moves
1282 /// the planned *size* of the work without changing how many issues are in scope —
1283 /// so it is a kind of its own rather than a flag on a membership or a status event,
1284 /// and the analytics classification folds it as `estimate_change`. This is the one
1285 /// class the mission's own table would otherwise never produce: no surface used to
1286 /// append an event for an estimate edit at all, so the class would have stayed
1287 /// permanently unknown in exactly the way the orphaned `cycle_capacity_edited`
1288 /// kind did.
1289 ///
1290 /// Appended by `tix edit --estimate` at the seam `--parent` already appends to,
1291 /// with the before/after values in `detail` (`estimate: <before> -> <after>`,
1292 /// `none` for an absent or blank value). An unchanged value appends **nothing**,
1293 /// and a refused edit appends nothing — the entry exists only for a change that
1294 /// was applied.
1295 EstimateChanged,
1296 /// An issue gained or lost a cycle membership (WP01 T046).
1297 ///
1298 /// One kind rather than two direction-split ones, and that is the documented
1299 /// exception to the split-by-direction convention the parent/triage/project/epic
1300 /// families follow: a single `--cycles` edit routinely adds one cycle *and* removes
1301 /// another, so "which direction?" has no single answer to split on. The direction is
1302 /// therefore in `detail` — `cycles: <before> -> <after>`, `none` for an empty list —
1303 /// which is what makes the change readable without replaying state.
1304 ///
1305 /// Appended by `tix edit --cycles` at the same seam, and only when the membership
1306 /// actually changed: an unchanged list appends nothing.
1307 CycleMembershipChanged,
1308}
1309
1310impl HistoryEventKind {
1311 /// The canonical snake_case spelling written to the file.
1312 pub fn as_str(self) -> &'static str {
1313 match self {
1314 HistoryEventKind::RelationshipAdded => "relationship_added",
1315 HistoryEventKind::RelationshipRemoved => "relationship_removed",
1316 HistoryEventKind::RelationshipRetargeted => "relationship_retargeted",
1317 HistoryEventKind::BlockedMoveAcknowledged => "blocked_move_acknowledged",
1318 // Three dedicated kinds rather than one generic `parent_changed` (research
1319 // open question 2, resolved in `data-model.md` §6.1): "set" and "cleared" are
1320 // not changes *of* a parent, they are its beginning and its end, and
1321 // collapsing them leaves the audit trail unable to distinguish "gained a
1322 // parent" from "moved to a different one" without reconstructing state from
1323 // the detail text.
1324 HistoryEventKind::ParentSet => "parent_set",
1325 HistoryEventKind::ParentChanged => "parent_changed",
1326 HistoryEventKind::ParentCleared => "parent_cleared",
1327 HistoryEventKind::TransitionAccepted => "transition_accepted",
1328 HistoryEventKind::TriageAccepted => "triage_accepted",
1329 HistoryEventKind::TriageDeclined => "triage_declined",
1330 HistoryEventKind::TriageDuplicated => "triage_duplicated",
1331 HistoryEventKind::TriageDeferred => "triage_deferred",
1332 HistoryEventKind::Archived => "archived",
1333 HistoryEventKind::Restored => "restored",
1334 HistoryEventKind::HealthUpdated => "health_updated",
1335 HistoryEventKind::HealthOverridden => "health_overridden",
1336 HistoryEventKind::ProjectAssociated => "project_associated",
1337 HistoryEventKind::ProjectDissociated => "project_dissociated",
1338 HistoryEventKind::EpicReferenced => "epic_referenced",
1339 HistoryEventKind::EpicDereferenced => "epic_dereferenced",
1340 HistoryEventKind::CycleClosed => "cycle_closed",
1341 HistoryEventKind::CycleExtended => "cycle_extended",
1342 HistoryEventKind::CycleSkipped => "cycle_skipped",
1343 HistoryEventKind::CyclePaused => "cycle_paused",
1344 HistoryEventKind::CycleResumed => "cycle_resumed",
1345 HistoryEventKind::CycleAdjusted => "cycle_adjusted",
1346 HistoryEventKind::CycleReopened => "cycle_reopened",
1347 HistoryEventKind::CycleCapacityEdited => "cycle_capacity_edited",
1348 HistoryEventKind::TimeEntryCreated => "time_entry_created",
1349 HistoryEventKind::TimeEntryUpdated => "time_entry_updated",
1350 HistoryEventKind::TimeEntryDeleted => "time_entry_deleted",
1351 HistoryEventKind::EstimateChanged => "estimate_changed",
1352 HistoryEventKind::CycleMembershipChanged => "cycle_membership_changed",
1353 }
1354 }
1355
1356 /// Whether this kind records a triage decision (WP03's four).
1357 ///
1358 /// The membership test the decision service's idempotence check and
1359 /// `not_in_triage`/`already_decided` split both ask, so "is this a triage
1360 /// decision?" has one answer rather than a four-way `matches!` at every
1361 /// call site — where a fifth kind would be forgotten first.
1362 #[must_use]
1363 pub fn is_triage_decision(self) -> bool {
1364 matches!(
1365 self,
1366 HistoryEventKind::TriageAccepted
1367 | HistoryEventKind::TriageDeclined
1368 | HistoryEventKind::TriageDuplicated
1369 | HistoryEventKind::TriageDeferred
1370 )
1371 }
1372}
1373
1374impl fmt::Display for HistoryEventKind {
1375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1376 f.write_str(self.as_str())
1377 }
1378}
1379
1380impl FromStr for HistoryEventKind {
1381 type Err = TixError;
1382
1383 /// Parse a kind spelling. An unknown kind is **rejected**, never coerced
1384 /// to a default: a silently reclassified audit entry is worse than a
1385 /// parse failure, and the field is part of an append-only log.
1386 fn from_str(s: &str) -> Result<Self> {
1387 match s {
1388 "relationship_added" => Ok(HistoryEventKind::RelationshipAdded),
1389 "relationship_removed" => Ok(HistoryEventKind::RelationshipRemoved),
1390 "relationship_retargeted" => Ok(HistoryEventKind::RelationshipRetargeted),
1391 "blocked_move_acknowledged" => Ok(HistoryEventKind::BlockedMoveAcknowledged),
1392 "parent_set" => Ok(HistoryEventKind::ParentSet),
1393 "parent_changed" => Ok(HistoryEventKind::ParentChanged),
1394 "parent_cleared" => Ok(HistoryEventKind::ParentCleared),
1395 "transition_accepted" => Ok(HistoryEventKind::TransitionAccepted),
1396 "triage_accepted" => Ok(HistoryEventKind::TriageAccepted),
1397 "triage_declined" => Ok(HistoryEventKind::TriageDeclined),
1398 "triage_duplicated" => Ok(HistoryEventKind::TriageDuplicated),
1399 "triage_deferred" => Ok(HistoryEventKind::TriageDeferred),
1400 "archived" => Ok(HistoryEventKind::Archived),
1401 "restored" => Ok(HistoryEventKind::Restored),
1402 "health_updated" => Ok(HistoryEventKind::HealthUpdated),
1403 "health_overridden" => Ok(HistoryEventKind::HealthOverridden),
1404 "project_associated" => Ok(HistoryEventKind::ProjectAssociated),
1405 "project_dissociated" => Ok(HistoryEventKind::ProjectDissociated),
1406 "epic_referenced" => Ok(HistoryEventKind::EpicReferenced),
1407 "epic_dereferenced" => Ok(HistoryEventKind::EpicDereferenced),
1408 "cycle_closed" => Ok(HistoryEventKind::CycleClosed),
1409 "cycle_extended" => Ok(HistoryEventKind::CycleExtended),
1410 "cycle_skipped" => Ok(HistoryEventKind::CycleSkipped),
1411 "cycle_paused" => Ok(HistoryEventKind::CyclePaused),
1412 "cycle_resumed" => Ok(HistoryEventKind::CycleResumed),
1413 "cycle_adjusted" => Ok(HistoryEventKind::CycleAdjusted),
1414 "cycle_reopened" => Ok(HistoryEventKind::CycleReopened),
1415 "cycle_capacity_edited" => Ok(HistoryEventKind::CycleCapacityEdited),
1416 "time_entry_created" => Ok(HistoryEventKind::TimeEntryCreated),
1417 "time_entry_updated" => Ok(HistoryEventKind::TimeEntryUpdated),
1418 "time_entry_deleted" => Ok(HistoryEventKind::TimeEntryDeleted),
1419 "estimate_changed" => Ok(HistoryEventKind::EstimateChanged),
1420 "cycle_membership_changed" => Ok(HistoryEventKind::CycleMembershipChanged),
1421 other => Err(TixError::Parse(format!(
1422 "unknown History event kind `{other}`"
1423 ))),
1424 }
1425 }
1426}
1427
1428impl HistoryEventKind {
1429 /// The model kind for a hierarchy event.
1430 ///
1431 /// Vocabulary translation, not a rule: the service decides *which* transition
1432 /// happened, and this maps that decision onto the log's spelling. It lives on
1433 /// the type that owns the log rather than at each call site, because three
1434 /// callers need it — the storage write path, the `--parent` edit flag, and MCP's
1435 /// `update_issue` in WP07 — and a mapping repeated three times is one that will
1436 /// be completed three times and fixed once.
1437 pub fn from_parent(kind: crate::hierarchy::ParentEventKind) -> Self {
1438 match kind {
1439 crate::hierarchy::ParentEventKind::ParentSet => HistoryEventKind::ParentSet,
1440 crate::hierarchy::ParentEventKind::ParentChanged => HistoryEventKind::ParentChanged,
1441 crate::hierarchy::ParentEventKind::ParentCleared => HistoryEventKind::ParentCleared,
1442 }
1443 }
1444
1445 /// The model kind for a project association event (WP03).
1446 ///
1447 /// The same vocabulary translation as [`Self::from_parent`]: the project
1448 /// service decides *which* mutation happened by returning a
1449 /// [`crate::projects::ProjectEventKind`], and this maps that decision onto the
1450 /// log's spelling. It lives on the type that owns the log because more than one
1451 /// caller needs it — the storage write path, and the CLI/TUI/MCP surfaces that
1452 /// follow — and a mapping repeated per call site is one that will be completed
1453 /// per call site and fixed once.
1454 #[must_use]
1455 pub fn from_project(kind: crate::projects::ProjectEventKind) -> Self {
1456 match kind {
1457 crate::projects::ProjectEventKind::ProjectAssociated => {
1458 HistoryEventKind::ProjectAssociated
1459 }
1460 crate::projects::ProjectEventKind::ProjectDissociated => {
1461 HistoryEventKind::ProjectDissociated
1462 }
1463 }
1464 }
1465
1466 /// The model kind for an epic reference event (WP03).
1467 ///
1468 /// The same vocabulary translation as [`Self::from_project`]: the epic service
1469 /// decides *which* mutation happened by returning a
1470 /// [`crate::epics::EpicEventKind`], and this maps that decision onto the log's
1471 /// spelling. It lives on the type that owns the log because more than one caller
1472 /// needs it — the storage write path, and the CLI/TUI/MCP surfaces that follow —
1473 /// and a mapping repeated per call site is one that will be completed per call
1474 /// site and fixed once.
1475 #[must_use]
1476 pub fn from_epic(kind: crate::epics::EpicEventKind) -> Self {
1477 match kind {
1478 crate::epics::EpicEventKind::EpicReferenced => HistoryEventKind::EpicReferenced,
1479 crate::epics::EpicEventKind::EpicDereferenced => HistoryEventKind::EpicDereferenced,
1480 }
1481 }
1482}
1483
1484/// The auditable record of one **accepted** status transition (`data-model.md`
1485/// §5, WP03 T012).
1486///
1487/// ## Why this is not a `HistoryEventKind` alone
1488///
1489/// A [`HistoryEvent`] has exactly four fields — timestamp, actor, kind, detail —
1490/// and the on-disk line format is fixed at four by the U+2014 split in
1491/// [`HistoryEvent::parse`]. `data-model.md` §5 wants seven. The three surplus
1492/// fields therefore live in `detail`, and this struct is what converts between
1493/// the two: [`Self::to_event`] and [`Self::from_event`] are the only places that
1494/// encoding is written or read.
1495///
1496/// ## The `detail` grammar
1497///
1498/// ```text
1499/// detail := head [ "; override: " reason ]
1500/// head := from " -> " to " (" from_category " -> " to_category ")"
1501/// ```
1502///
1503/// e.g. `todo -> in progress (unstarted -> active)`, or
1504/// `done -> in progress (completed -> active); override: mis-filed`.
1505///
1506/// **It is a grammar, not prose**, and that is a load-bearing choice rather than a
1507/// tidiness one. WP06's `lint_direct_edits` has to compare a file's *current*
1508/// `status` against the column its last accepted transition landed in; recovering
1509/// that column out of free-form English is guesswork, and a heuristic that
1510/// misreads it produces a lint result nobody can trust. A fixed grammar makes the
1511/// comparison exact, and makes "this detail is not a transition record"
1512/// detectable rather than a guess.
1513///
1514/// The parse is deliberately anchored rather than free-floating: the override is
1515/// split off at the first `") ; override: "`, and the categories are taken from
1516/// between the last `" ("` and the closing `")"`, so a **reason** may contain the
1517/// bare `"-> "` and the bare `"; override: "` without disturbing the fields. That
1518/// anchoring is the whole reason the marker includes the paren — see
1519/// [`OVERRIDE_MARKER`].
1520///
1521/// ## Known limit: a column name containing `" -> "`
1522///
1523/// The two column names are separated by the first `" -> "`, and nothing escapes
1524/// it, so a column literally named `a -> b` would render as
1525/// `a -> b -> in progress (unstarted -> active)` and parse back with `from == "a"`.
1526/// The categories still parse correctly, and the *categories* are what an audit
1527/// reader wants, but a WP06 that recovers `to` from a file would recover `b`.
1528///
1529/// This is inherent to a delimiter-based grammar, not a bug in this
1530/// implementation: any unescaped separator has a name it cannot represent, and
1531/// escaping would put backslashes into a line a human reads. A board column named
1532/// with an embedded arrow is not a realistic configuration, so the limit is
1533/// documented and pinned by a test rather than designed around. WP06 reads `to`
1534/// from these records, so it inherits the assumption and should not be surprised
1535/// by it.
1536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1537pub struct TransitionDecision {
1538 /// When the transition was accepted, RFC 3339 — the same shape and clock as
1539 /// every other History entry.
1540 pub timestamp: String,
1541 /// Who performed it.
1542 ///
1543 /// A plain `String`, carried through to [`HistoryEvent::actor`] untouched, so
1544 /// identity handling is the History log's existing one: a bare handle or a full
1545 /// `Name <email>`, with a legacy `@alice` tolerated on read. This struct
1546 /// re-implements none of that and cannot drift from it.
1547 pub actor: String,
1548 /// The column the issue was in, as stored.
1549 pub from: String,
1550 /// The column it was moved to, as stored.
1551 pub to: String,
1552 /// The category `from` resolved to, recorded for auditability.
1553 ///
1554 /// **Derived, never stored on the issue and never re-derived on read** — that
1555 /// is the point. If a board's `category_map` is later edited, the historical
1556 /// record still says what the category *was* when the move was accepted, which
1557 /// is the only way the audit trail stays true.
1558 pub from_category: String,
1559 /// The category `to` resolved to, recorded for the same reason.
1560 pub to_category: String,
1561 /// Why a policy rule was overridden, when one was.
1562 ///
1563 /// Required to be `Some` for an override to exist at all: the service refuses a
1564 /// bare "I did it anyway" (see `TransitionOverride`), so this is never `Some`
1565 /// with an empty or blank reason.
1566 pub override_reason: Option<String>,
1567}
1568
1569/// The marker separating the transition record from its override reason.
1570///
1571/// **Includes the closing paren**, and that is load-bearing rather than
1572/// cosmetic. The obvious marker is `"; override: "`, split with `rsplit_once` so a
1573/// reason containing the marker survives — and it does not: the *last* occurrence
1574/// of a bare `"; override: "` inside a reason like `typo; override: actually…` is
1575/// the one that splits, which leaves the head ending in `; override: typo` and no
1576/// closing paren, so the parse fails outright. WP03's first version did exactly
1577/// that and its own test caught it.
1578///
1579/// Anchoring the marker to the `)` that closes the head makes the split
1580/// **unambiguous in both directions**: the head is the only `)` followed by the
1581/// marker, so `split_once` at the *first* occurrence always takes the right one.
1582/// A reason may therefore contain the bare marker, a bare `")"`, and even the full
1583/// anchored marker, and still round-trip.
1584///
1585/// That last case is worth spelling out because WP03's first two attempts both got
1586/// it wrong in opposite directions. `rsplit_once` on the *bare* marker took the
1587/// last occurrence, so a reason containing `"; override: "` truncated and the parse
1588/// failed outright. `rsplit_once` on the *anchored* marker would be no better: for
1589/// a reason containing `"); override: "` the last occurrence is the one inside the
1590/// reason. `split_once` on the anchored marker is the only combination of the two
1591/// that is correct, and `t012_a_reason_containing_the_anchored_marker_survives`
1592/// exists to keep it that way.
1593const OVERRIDE_MARKER: &str = "); override: ";
1594/// The marker opening the bracketed category pair.
1595const CATEGORY_OPEN: &str = " (";
1596/// The arrow between two names in the `detail` grammar.
1597const DETAIL_ARROW: &str = " -> ";
1598
1599impl TransitionDecision {
1600 /// Build a decision from an accepted transition.
1601 pub fn new(
1602 timestamp: impl Into<String>,
1603 actor: impl Into<String>,
1604 from: impl Into<String>,
1605 to: impl Into<String>,
1606 from_category: impl Into<String>,
1607 to_category: impl Into<String>,
1608 override_reason: Option<String>,
1609 ) -> Self {
1610 Self {
1611 timestamp: timestamp.into(),
1612 actor: actor.into(),
1613 from: from.into(),
1614 to: to.into(),
1615 from_category: from_category.into(),
1616 to_category: to_category.into(),
1617 override_reason,
1618 }
1619 }
1620
1621 /// Render to the single `detail` string the History line carries.
1622 pub fn detail(&self) -> String {
1623 let mut out = format!(
1624 "{from}{arrow}{to} ({fc}{arrow}{tc})",
1625 from = self.from,
1626 to = self.to,
1627 fc = self.from_category,
1628 tc = self.to_category,
1629 arrow = DETAIL_ARROW,
1630 );
1631 // A blank reason renders as no marker at all rather than as an empty field,
1632 // so `from_event` can never come back with `Some("")` — the same reasoning
1633 // that makes a blank actor render as an empty field rather than being
1634 // dropped, applied in the other direction.
1635 if let Some(reason) = self.override_reason.as_deref().map(str::trim) {
1636 if !reason.is_empty() {
1637 // The head already ends in the `)` the marker expects, so the
1638 // marker supplies the semicolon and the space only. Writing the
1639 // marker as a whole and *also* leaving the head's `)` is what
1640 // produced `...active)); override:` on the first pass.
1641 out.push_str(OVERRIDE_MARKER.trim_start_matches(')'));
1642 out.push_str(reason);
1643 }
1644 }
1645 out
1646 }
1647
1648 /// Recover a decision from a History entry.
1649 ///
1650 /// `Err` when `event` is not a `transition_accepted` entry or its `detail` does
1651 /// not match the grammar — never a partially-filled struct. A caller can
1652 /// therefore treat a successful parse as "this file records a transition to
1653 /// `to`", which is exactly the question WP06 asks.
1654 pub fn from_event(event: &HistoryEvent) -> Result<Self> {
1655 if event.kind != HistoryEventKind::TransitionAccepted {
1656 return Err(TixError::Parse(format!(
1657 "History entry is a `{}`, not a `transition_accepted`",
1658 event.kind
1659 )));
1660 }
1661 let malformed =
1662 |what: &str| TixError::Parse(format!("transition record {what}: `{}`", event.detail));
1663 // The reason is split off at the FIRST anchored marker: the head is the
1664 // only `)` followed by `; override: `, so a reason containing the bare
1665 // marker survives. See `OVERRIDE_MARKER`.
1666 let (head, override_reason) = match event.detail.split_once(OVERRIDE_MARKER) {
1667 Some((head, reason)) => (head, Some(reason.trim().to_string())),
1668 None => (event.detail.as_str(), None),
1669 };
1670 // The marker *includes* the head's closing paren, so put it back and let
1671 // the grammar below see one shape whether or not a reason was present. The
1672 // alternative — two separate shapes to parse — is how the two halves of a
1673 // grammar drift apart.
1674 let head = match &override_reason {
1675 Some(_) => format!("{head})"),
1676 None => head.to_string(),
1677 };
1678 let head = head.as_str();
1679 let (from, rest) = head
1680 .split_once(DETAIL_ARROW)
1681 .ok_or_else(|| malformed("has no `from -> to`"))?;
1682 let rest = rest
1683 .strip_suffix(')')
1684 .ok_or_else(|| malformed("has no closing `)` after the categories"))?;
1685 let (to, categories) = rest
1686 .split_once(CATEGORY_OPEN)
1687 .ok_or_else(|| malformed("has no ` (` before the categories"))?;
1688 let (from_category, to_category) = categories
1689 .split_once(DETAIL_ARROW)
1690 .ok_or_else(|| malformed("has no `from_category -> to_category`"))?;
1691 for (name, value) in [
1692 ("from", from),
1693 ("to", to),
1694 ("from_category", from_category),
1695 ("to_category", to_category),
1696 ] {
1697 if value.trim().is_empty() {
1698 return Err(malformed(name));
1699 }
1700 }
1701 Ok(Self {
1702 timestamp: event.timestamp.clone(),
1703 actor: event.actor.clone(),
1704 from: from.to_string(),
1705 to: to.to_string(),
1706 from_category: from_category.to_string(),
1707 to_category: to_category.to_string(),
1708 override_reason,
1709 })
1710 }
1711
1712 /// The History entry to append for this decision.
1713 pub fn to_event(&self) -> HistoryEvent {
1714 HistoryEvent::new(
1715 self.timestamp.clone(),
1716 self.actor.clone(),
1717 HistoryEventKind::TransitionAccepted,
1718 self.detail(),
1719 )
1720 }
1721
1722 /// Whether this decision is a same-column no-op record.
1723 ///
1724 /// The seam never appends one — idempotency is decided by
1725 /// `TransitionOutcome::Accepted { changed: false }` — but a **hand-edited** file
1726 /// can contain one, and WP06's `lint_direct_edits` needs to tell that apart
1727 /// from a real move rather than reading it as "status became todo".
1728 pub fn is_no_op(&self) -> bool {
1729 self.from == self.to
1730 }
1731}
1732
1733/// One recorded mutation, and one line of the `## History` section.
1734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1735pub struct HistoryEvent {
1736 /// When the mutation happened, RFC 3339.
1737 pub timestamp: String,
1738 /// Who performed it. Handled like [`Issue::assignee`]: a bare handle or a
1739 /// full identity, with a legacy `@alice` tolerated on read.
1740 pub actor: String,
1741 /// What kind of mutation this was.
1742 pub kind: HistoryEventKind,
1743 /// The field-aware description shown in detail views.
1744 pub detail: String,
1745}
1746
1747impl HistoryEvent {
1748 /// Build an event.
1749 pub fn new(
1750 timestamp: impl Into<String>,
1751 actor: impl Into<String>,
1752 kind: HistoryEventKind,
1753 detail: impl Into<String>,
1754 ) -> Self {
1755 Self {
1756 timestamp: timestamp.into(),
1757 actor: actor.into(),
1758 kind,
1759 detail: detail.into(),
1760 }
1761 }
1762
1763 /// The one-line form written to the file:
1764 /// `- <timestamp> — <actor> — <kind> — <detail>`.
1765 ///
1766 /// Four fields, not the three an earlier draft of `data-model.md` §8
1767 /// specified: a three-field form cannot carry `actor`, so the struct field
1768 /// would not survive a round trip and the audit trail would silently lose
1769 /// who performed each mutation. `data-model.md` §8 is corrected to match on
1770 /// the base and mission branches — it is a protected path, so the fix is not
1771 /// in this lane. **Read §8 there, not this comment**, if the two ever
1772 /// disagree.
1773 ///
1774 /// A blank actor renders as an empty field rather than being dropped, so
1775 /// the field count stays fixed and parsing never has to guess.
1776 fn render(&self) -> String {
1777 format!(
1778 "- {t} {h} {a} {h} {k} {h} {d}",
1779 t = self.timestamp.trim(),
1780 h = HISTORY_SEP,
1781 a = self.actor.trim(),
1782 k = self.kind.as_str(),
1783 d = self.detail
1784 )
1785 }
1786
1787 /// Parse one rendered list item.
1788 ///
1789 /// The four fields are split on the **first three** separators only, so a
1790 /// detail or actor containing an em dash survives intact.
1791 fn parse(line: &str) -> Result<Self> {
1792 let item = line
1793 .trim()
1794 .strip_prefix("- ")
1795 .ok_or_else(|| TixError::Parse(format!("History entry is not a list item: {line}")))?;
1796 // Split on the first three separators only, so a detail containing an
1797 // em dash survives intact. Fields are read **positionally** rather than
1798 // as "the next non-empty value": the actor may legitimately be blank,
1799 // which is exactly what `render` writes when no identity was recorded,
1800 // so demanding a non-empty value there would make the writer's own
1801 // output unparseable.
1802 let parts: Vec<&str> = item.splitn(4, HISTORY_SEP).collect();
1803 let missing =
1804 |what: &str| TixError::Parse(format!("History entry is missing `{what}`: {line}"));
1805 if parts.len() < 3 {
1806 return Err(missing("a field separator"));
1807 }
1808 let timestamp = parts[0].trim();
1809 if timestamp.is_empty() {
1810 return Err(missing("timestamp"));
1811 }
1812 let actor = parts[1].trim().to_string();
1813 let kind_text = parts[2].trim();
1814 if kind_text.is_empty() {
1815 return Err(missing("kind"));
1816 }
1817 let kind = kind_text.parse::<HistoryEventKind>()?;
1818 let detail = parts.get(3).copied().unwrap_or_default().trim().to_string();
1819 Ok(Self {
1820 timestamp: timestamp.to_string(),
1821 actor,
1822 kind,
1823 detail,
1824 })
1825 }
1826}
1827
1828/// Whether a line is a **recognised** History entry attempt.
1829/// Whether a line is **shaped like a markdown list item**, regardless of whether
1830/// it is a valid entry.
1831///
1832/// Used to decide where an unrecognised line belongs: a list item inside a
1833/// History section is far more likely a hand-edited entry than prose, and the two
1834/// are not told apart by anything else, because the section has no terminator.
1835///
1836/// All four common markers count, not just `- `. This is not a widening of the
1837/// entry grammar — recognition still requires U+2014, so nothing here can be read
1838/// as a relationship. With `- ` alone, an entry written
1839/// `* 2026-01-01T00:00:00Z — a — kind — d` was treated as prose, so the section
1840/// ended, the heading was deleted, and the next append was rendered above it.
1841fn is_list_item(item: &str) -> bool {
1842 for marker in ["- ", "* ", "+ "] {
1843 if item.starts_with(marker) {
1844 return true;
1845 }
1846 }
1847 // An ordered marker: digits then `.` or `)` then a space. Bounded to two
1848 // digits, or `2026. Q1 goals` reads as an ordered-list item and a paragraph
1849 // of ordinary body prose gets retained in the audit log.
1850 let Some(dot) = item.find(['.', ')']) else {
1851 return false;
1852 };
1853 dot > 0
1854 && dot <= 2
1855 && item[..dot].bytes().all(|b| b.is_ascii_digit())
1856 && item[dot + 1..].starts_with(' ')
1857}
1858
1859/// Whether a line is a **recognised** History entry attempt.
1860///
1861/// Recognition requires the U+2014 separator, and nothing else. That is
1862/// deliberately stricter than "looks like" one: a looser test here is not a
1863/// nicety, it decides whether a line is *moved into the audit log* or left in
1864/// the body. A timestamp-shaped heuristic accepted a body to-do item
1865/// (`- 2026-02-02 review the migration`) as an entry attempt, which emptied
1866/// `body` and made `tix lint` blame the author — the original M3 defect arriving
1867/// through a different door, while the documentation claimed the opposite.
1868///
1869/// A line that is genuinely an entry but was typed with ASCII hyphens is
1870/// therefore *not* recognised, and never becomes an event. It may still be
1871/// *retained* in the section; see [`looks_like_an_unrecognised_entry`].
1872pub fn looks_like_history_entry(line: &str) -> bool {
1873 let item = line.trim();
1874 let Some(rest) = item.strip_prefix("- ") else {
1875 return false;
1876 };
1877 rest.contains(HISTORY_SEP)
1878}
1879
1880/// Whether a line looks like a History entry that was **not** recognised — an
1881/// entry hand-typed with ASCII hyphens instead of U+2014.
1882///
1883/// This is the shape test behind two decisions, and they use it differently:
1884///
1885/// - `parse_front` asks whether an ambiguous line is a hand-edited *entry* rather
1886/// than body prose. Being wrong in that direction costs the author's bytes,
1887/// because a demoted line is body text and a later `tix edit --body` destroys
1888/// it with no trace.
1889/// - `tix lint` asks whether to *report* a line, where a false positive costs a
1890/// user-visible diagnostic on every run and a false negative costs a silent
1891/// typo.
1892///
1893/// A mistyped entry has the whole shape of one: a date, then three further
1894/// hyphen-separated fields — `<timestamp> - <actor> - <kind> - <detail>`. Both
1895/// halves are required. An earlier version accepted any 10-character run of
1896/// digits, dashes and punctuation, which flagged `- 10:00-11:00 sync with infra`,
1897/// `- 1234567890 is the upstream ticket`, `+49-30-12345678` and `1.2.3.4.5.6`; a
1898/// later one required only three fields, which flagged `- 2026-01-01 - Bob -
1899/// Done`. Four fields is what the format actually specifies, so that is what is
1900/// required.
1901pub fn looks_like_an_unrecognised_entry(line: &str) -> bool {
1902 let Some(rest) = strip_list_marker(line) else {
1903 return false;
1904 };
1905 if rest.contains(HISTORY_SEP) {
1906 return false; // genuinely recognised; not this function's business
1907 }
1908 entry_shaped_by_ascii_hyphens(rest, STRICT_TAIL_FIELDS)
1909}
1910
1911/// Tail fields required to call a line a mistyped entry when **reporting** it.
1912///
1913/// Four fields in total, because that is the form carrying a detail; the shorter
1914/// three-field form is indistinguishable from a dated changelog line
1915/// (`- 2026-01-01 - Bob - Done`), and `tix lint` exits non-zero on a finding.
1916const STRICT_TAIL_FIELDS: usize = 3;
1917
1918/// Tail fields required to call a line an entry **attempt** when deciding whether
1919/// to retain it.
1920///
1921/// Two, i.e. three fields in total, which is the shortest form `HistoryEvent::parse`
1922/// accepts — `detail` is optional there. A retention test stricter than the
1923/// recognition path is backwards: a valid three-field entry would be demoted to
1924/// body text and then destroyed by the next `tix edit --body`, while the
1925/// malformed four-field one survived. Cycle 6 found that inversion.
1926const RETENTION_TAIL_FIELDS: usize = 2;
1927
1928/// Whether a line is an **entry attempt**: a list item whose content is shaped
1929/// like an entry, under any accepted marker.
1930///
1931/// This is the parser's question, and it is deliberately looser than
1932/// [`looks_like_an_unrecognised_entry`]'s, because the two have opposite costs.
1933/// Retaining a line that turns out to be prose moves it into the audit log *and
1934/// says so*; demoting a line that turns out to be an entry puts it in the body,
1935/// where the next `tix edit --body` deletes it with no trace. Retention is the
1936/// recoverable direction, so the parser errs towards retaining and the
1937/// diagnostic errs towards silence.
1938/// Whether a line introduces the `## History` section, under the same
1939/// tolerance the parser applies: trailing spaces or tabs are ignored, leading
1940/// whitespace is not.
1941///
1942/// Public so `tix lint` can report a **misplaced** section using the parser's own
1943/// rule. A second copy of this test could disagree with the parser about which
1944/// lines are headings, and then lint would report violations that do not exist
1945/// while missing ones that do.
1946pub fn is_history_heading(line: &str) -> bool {
1947 line.trim_end_matches([' ', '\t']) == HISTORY_HEADING
1948}
1949
1950fn is_entry_attempt(item: &str) -> bool {
1951 let Some(rest) = strip_list_marker(item) else {
1952 return false;
1953 };
1954 if rest.contains(HISTORY_SEP) {
1955 // Correct separators. A `*`- or `1.`-marked entry is not *recognised* —
1956 // both `looks_like_history_entry` and `HistoryEvent::parse` require the
1957 // `- ` prefix — but it is unmistakably an entry attempt, and treating it
1958 // as prose cost a well-formed audit line. Cycle 6 found this.
1959 return true;
1960 }
1961 entry_shaped_by_ascii_hyphens(rest, RETENTION_TAIL_FIELDS)
1962}
1963
1964/// Whether `rest` is a plausible entry body whose separators were typed as ASCII
1965/// hyphens: a date, then at least `min_tail` further hyphen-separated fields.
1966///
1967/// The tail splits on hyphens with optional surrounding spaces rather than on the
1968/// literal `" - "`, because an entry typed with ASCII hyphens is often typed
1969/// without the spaces too: `- 2026-01-01T00:00:00Z-alice-kind-d`. An earlier
1970/// version required `" - "` and so missed the case this tool exists to catch,
1971/// while the recognition path trims after the marker and accepted the same
1972/// whitespace — the identical line was "not an entry" and never mentioned.
1973fn entry_shaped_by_ascii_hyphens(rest: &str, min_tail: usize) -> bool {
1974 let Some(tail) = strip_leading_timestamp(rest) else {
1975 return false;
1976 };
1977 let parts: Vec<&str> = tail
1978 .split('-')
1979 .map(str::trim)
1980 .filter(|p| !p.is_empty())
1981 .collect();
1982 if parts.len() < min_tail {
1983 return false;
1984 }
1985 // The field right after the timestamp is an actor or a kind, and neither is a
1986 // date. Rejecting a numeric one is what keeps a date *range* out:
1987 // `- 2026-01-01..2026-01-05 was the freeze` otherwise splits into three
1988 // parts and reads as a mistyped entry.
1989 !starts_like_a_date(parts[0])
1990}
1991
1992/// Whether a token opens with a run of four or more digits — a year, a serial
1993/// number, a date. Used only to reject prose that would otherwise split into
1994/// enough hyphen-separated fields to look like an entry.
1995fn starts_like_a_date(token: &str) -> bool {
1996 let digits = token.bytes().take_while(u8::is_ascii_digit).count();
1997 digits >= 4
1998}
1999
2000/// The text after any accepted list marker, with whatever whitespace a hand put
2001/// after it removed.
2002///
2003/// `HistoryEvent::parse` trims after the prefix too, so this must as well:
2004/// otherwise `- 2026-…—…` and `-\t2026-…—…` are recognised as real entries while
2005/// their ASCII-hyphen equivalents are silently unreported.
2006fn strip_list_marker(line: &str) -> Option<&str> {
2007 let item = line.trim();
2008 if is_list_item(item) {
2009 let space = item.find(char::is_whitespace).unwrap_or(item.len());
2010 return Some(item[space..].trim_start());
2011 }
2012 None
2013}
2014
2015/// The remainder of a line whose first field begins with a plausible ISO date, or
2016/// `None` if it does not.
2017///
2018/// A date is the only field shape that separates a mistyped entry from an
2019/// ordinary dated bullet, and requiring it is what keeps `tix lint` quiet on
2020/// prose. Plausibility is deliberately not checked: rejecting a hand-typed
2021/// offset like `+02:00` would create a false negative in the one tool whose job
2022/// is to catch exactly this mistake, so implausible shapes are reported rather
2023/// than filtered.
2024fn strip_leading_timestamp(rest: &str) -> Option<&str> {
2025 let b = rest.as_bytes();
2026 if b.len() < 10
2027 || !b[0..4].iter().all(u8::is_ascii_digit)
2028 || b[4] != b'-'
2029 || !b[5..7].iter().all(u8::is_ascii_digit)
2030 || b[7] != b'-'
2031 || !b[8..10].iter().all(u8::is_ascii_digit)
2032 {
2033 return None;
2034 }
2035 let mut i = 10;
2036 if b.get(i) == Some(&b'T') {
2037 i += 1;
2038 while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':' || b[i] == b'.') {
2039 i += 1;
2040 }
2041 match b.get(i) {
2042 Some(b'Z') => i += 1,
2043 // A `-` is a zone separator only when digits follow it; otherwise it
2044 // is the first field separator and the timestamp ended at the time.
2045 Some(&b'-') => {
2046 let mut j = i + 1;
2047 while j < b.len() && b[j].is_ascii_digit() {
2048 j += 1;
2049 }
2050 if j > i + 1 {
2051 i = j;
2052 }
2053 }
2054 Some(&b'+') => {
2055 i += 1;
2056 while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':') {
2057 i += 1;
2058 }
2059 }
2060 _ => {}
2061 }
2062 }
2063 // \`.\` too: a date *range* continues with \`..\`, and leaving it glued to
2064 // the next token makes \`- 2026-01-01..2026-01-05 was the freeze\` split into
2065 // three fields.
2066 Some(rest[i.min(rest.len())..].trim_start_matches(['-', ' ', '\t', '.']))
2067}
2068
2069/// One line of the `## History` section.
2070#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2071#[serde(untagged)]
2072pub enum HistoryEntry {
2073 /// A well-formed event.
2074 Event(HistoryEvent),
2075 /// A line that could not be parsed, preserved **verbatim**.
2076 ///
2077 /// The section is documented as human-editable, so a hand edit will
2078 /// eventually produce a line this parser cannot read — ASCII hyphens
2079 /// instead of the U+2014 separators is the obvious one. Such a line is
2080 /// never dropped, never coerced, and never auto-repaired: it is carried
2081 /// through a read/write cycle unchanged and reported by `tix lint` as a
2082 /// `history` finding on that issue only. It must never fail the
2083 /// parse of the whole file, because one mistyped line in one issue would
2084 /// otherwise make every issue in the repository unreadable.
2085 Unparsed(String),
2086}
2087
2088/// The append-only event log of an issue (`data-model.md` §8).
2089///
2090/// Append-only is a hard guarantee of this type: the only mutating operation
2091/// is [`History::append`]. There is deliberately no remove, no reorder, no
2092/// trim, and no compaction — those are out of scope, but the container is
2093/// shaped so a future retention policy has a seam.
2094#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2095pub struct History {
2096 /// Every line of the section, in file order, which is append order.
2097 ///
2098 /// Held as an ordered list of entries rather than a flat `Vec<HistoryEvent>`
2099 /// so an **unparseable** line keeps its position and its exact text. That
2100 /// is what lets the section round-trip byte-stably without ever dropping or
2101 /// repairing a line a human hand-edited.
2102 pub entries: Vec<HistoryEntry>,
2103}
2104
2105impl History {
2106 /// An empty log.
2107 pub fn new() -> Self {
2108 Self::default()
2109 }
2110
2111 /// Append one event. Never rewrites, reorders, or removes an existing line.
2112 pub fn append(&mut self, event: HistoryEvent) {
2113 self.entries.push(HistoryEntry::Event(event));
2114 }
2115
2116 /// Append with the current UTC timestamp.
2117 pub fn append_now(&mut self, actor: &str, kind: HistoryEventKind, detail: &str) {
2118 self.append(HistoryEvent::new(utc_now_rfc3339(), actor, kind, detail));
2119 }
2120
2121 /// The parsed events, in file order.
2122 ///
2123 /// Unparseable lines are excluded here; see [`History::unparsed`].
2124 pub fn events(&self) -> Vec<&HistoryEvent> {
2125 self.entries
2126 .iter()
2127 .filter_map(|e| match e {
2128 HistoryEntry::Event(event) => Some(event),
2129 HistoryEntry::Unparsed(_) => None,
2130 })
2131 .collect()
2132 }
2133
2134 /// Lines that could not be parsed, verbatim and in file order.
2135 ///
2136 /// Non-empty means the section was hand-edited into a shape this parser
2137 /// does not accept. `tix lint` reports each as a `history`
2138 /// finding; nothing else treats it as an error.
2139 pub fn unparsed(&self) -> Vec<&str> {
2140 self.entries
2141 .iter()
2142 .filter_map(|e| match e {
2143 HistoryEntry::Event(_) => None,
2144 HistoryEntry::Unparsed(line) => Some(line.as_str()),
2145 })
2146 .collect()
2147 }
2148
2149 /// Whether the section has no lines at all.
2150 pub fn is_empty(&self) -> bool {
2151 self.entries.is_empty()
2152 }
2153
2154 /// How many lines the section holds, parsed or not.
2155 pub fn len(&self) -> usize {
2156 self.entries.len()
2157 }
2158
2159 /// Render the section, including its heading and the blank line after it.
2160 ///
2161 /// An empty log renders to the empty string, so the section is *omitted*
2162 /// from the file entirely rather than written as an empty heading. An
2163 /// unparseable line is re-emitted exactly as it was read, in place, so a
2164 /// read/write cycle is byte-stable whether or not the section is clean.
2165 pub fn render(&self) -> String {
2166 if self.is_empty() {
2167 return String::new();
2168 }
2169 let mut out = String::from(HISTORY_HEADING);
2170 out.push_str("\n\n");
2171 for entry in &self.entries {
2172 match entry {
2173 HistoryEntry::Event(event) => {
2174 out.push_str(&event.render());
2175 out.push('\n');
2176 }
2177 HistoryEntry::Unparsed(line) => {
2178 out.push_str(line);
2179 out.push('\n');
2180 }
2181 }
2182 }
2183 out.push('\n');
2184 out
2185 }
2186
2187 /// Parse a leading `## History` section, returning the log and whatever body
2188 /// text followed it.
2189 ///
2190 /// Only a section at the *front* of the body is recognised, matching the
2191 /// position `data-model.md` §8 pins (immediately after the frontmatter). A
2192 /// `## History` heading appearing later in the prose is left alone as
2193 /// ordinary body text.
2194 ///
2195 /// The section ends at the first line that is neither blank nor a list item,
2196 /// so a **blank line between entries does not end it** — a blank line inside
2197 /// a list is the most ordinary thing a human or a Markdown formatter
2198 /// produces, and terminating there would silently demote audit entries to
2199 /// body prose and reorder the log. Unparseable list items are kept verbatim
2200 /// rather than failing the file.
2201 pub fn parse_front(body: &str) -> (Self, String) {
2202 let lead = body.len() - body.trim_start_matches('\n').len();
2203 let trimmed = &body[lead..];
2204 let Some(rest) = trimmed.strip_prefix(HISTORY_HEADING) else {
2205 return (History::new(), body.to_string());
2206 };
2207 // The heading must be alone on its line — but trailing spaces and tabs on
2208 // it are still that line. Requiring a bare `\n` meant `## History ` was
2209 // not recognised at all, and since the heading is then neither the
2210 // section nor body, the *first* relationship write rendered a second
2211 // `## History` above the original. Cycle 6 executed that; it had been
2212 // filed against #1299 as a CRLF-only consequence, which understated it —
2213 // a trailing space is a far likelier trigger than CRLF.
2214 let rest = rest.trim_start_matches([' ', '\t']);
2215 // #1299: a CRLF file spells the heading's line ending `\r\n`.
2216 let Some(after_heading) = rest
2217 .strip_prefix("\r\n")
2218 .or_else(|| rest.strip_prefix('\n'))
2219 else {
2220 return (History::new(), body.to_string());
2221 };
2222 let after_heading = after_heading.trim_start_matches(['\r', '\n']);
2223
2224 let mut history = History::new();
2225 let mut offset = 0usize;
2226 // Whether the section carried a list item that was **judged prose and
2227 // dropped by the `break` below**. That is the #1336 shape — a list line
2228 // above ordinary prose empties the section and deletes the heading — and
2229 // it is what the empty-history guard restores. A section of pure prose (a
2230 // bare `## History` heading above a paragraph) carries no list item, so
2231 // dropping the derived heading stays correct.
2232 let mut saw_rejected_list_item = false;
2233 for line in after_heading.split_inclusive('\n') {
2234 // `item` is the trimmed form used for *shape* decisions; `raw_line`
2235 // is what a preserved `Unparsed` entry stores, so a hand edit keeps
2236 // its own bytes.
2237 let raw_line = line;
2238 let item = raw_line.trim();
2239 if item.is_empty() {
2240 // A blank line *inside* the section is skipped, not treated as
2241 // the end of it. It is consumed, so the body that follows does
2242 // not start with stray blank lines.
2243 offset += line.len();
2244 continue;
2245 }
2246 if !looks_like_history_entry(item) {
2247 // The section has no end-of-section delimiter, so this line is
2248 // ambiguous: either body prose, or a hand-edited line *inside*
2249 // the section. The two are told apart by what follows.
2250 //
2251 // If every remaining non-blank line is entry-shaped, the
2252 // ambiguous line was a hand edit inside the section, so the
2253 // section continues and the line is kept verbatim as
2254 // `Unparsed` — nothing is dropped and the real events after it
2255 // stay events. If anything after it is not entry-shaped, this
2256 // is body prose and the section genuinely ends here.
2257 //
2258 // The alternative — absorbing any `- ` line — moved body content
2259 // into the audit log, deleted the blank line separating them on
2260 // the first write, and then had `tix lint` blaming the author's
2261 // prose as unparseable entries. Trigger was ordinary:
2262 // `tix edit --body "- step one\n- step two"`.
2263 // Collected rather than consumed with `next()` + `all()`: that
2264 // skips judging the *first* following line, and `all()` over an
2265 // empty remainder is vacuously true — so a section followed by a
2266 // single non-entry line was absorbed. Both mistakes are the same
2267 // one: judging nothing and concluding "all of it is entries".
2268 let following: Vec<&str> = after_heading[offset + line.len()..]
2269 .lines()
2270 .filter(|l| !l.trim().is_empty())
2271 .collect();
2272 // Two different questions, and conflating them is what broke this
2273 // twice.
2274 //
2275 // *Does anything after this disqualify it as body prose?* When
2276 // nothing follows, nothing can — `all()` is then vacuously true
2277 // and that is the **correct** answer, not a bug. A guard
2278 // requiring a non-empty remainder (added to stop an earlier
2279 // vacuous-truth defect) made the last line in a section always
2280 // terminate it, which deleted the `## History` heading, demoted
2281 // the author's line into the body, and rendered the next append
2282 // *above* it — file order stopped being append order, and
2283 // `tix lint` said nothing because a demoted line is body prose
2284 // and the unparsed loop no longer sees it.
2285 //
2286 // *Is this line shaped like a list item at all?* That still
2287 // decides, at end of section, whether keeping it is defensible.
2288 // `- step one` at EOF is far more likely a hand-edited entry than
2289 // the only body content of an issue, and keeping it *discloses*
2290 // the problem. A bare paragraph at EOF is prose, full stop, and
2291 // absorbing it would pollute the audit log.
2292 // Three inputs, and which ones matter depends on whether
2293 // anything follows. Cycle 5 found this half-fixed: consulting
2294 // only `is_list_item`, and only when nothing followed, left the
2295 // whole defect alive for any ambiguous line with body prose
2296 // after it.
2297 //
2298 // The asymmetry that decides the design: a *demoted* line is body
2299 // text, and `tix edit --body` replaces the body wholesale — so a
2300 // hand-edited entry that was demoted is destroyed by an ordinary
2301 // command, with lint silent. A *retained* line is kept verbatim
2302 // inside the section and reported. Given the choice, retaining is
2303 // the recoverable one, so the tie only goes to "this is prose"
2304 // when the line is not plausibly an entry at all.
2305 let plausible_entry = is_entry_attempt(item);
2306 let after_all_entries = following.iter().all(|l| looks_like_history_entry(l));
2307 let keep = if following.is_empty() {
2308 // Nothing after to judge by, so shape alone decides. A bare
2309 // paragraph at end of section is prose; anything list-shaped
2310 // is kept, because there is no following content that could
2311 // have been prose instead.
2312 is_list_item(item)
2313 } else {
2314 plausible_entry || after_all_entries
2315 };
2316 if !keep {
2317 // A list-shaped line is the one case where the `break`
2318 // discards something that could have been an audit entry, so
2319 // record it for the empty-history guard after the loop.
2320 saw_rejected_list_item |= is_list_item(item);
2321 break;
2322 }
2323 // The **raw** line, not the trimmed one: `Unparsed` promises a
2324 // hand edit is carried through a read/write cycle unchanged, and
2325 // trimming silently repaired leading indentation and trailing
2326 // whitespace — exactly the auto-repair data-model §8 forbids.
2327 history.entries.push(HistoryEntry::Unparsed(
2328 raw_line.trim_end_matches('\n').to_string(),
2329 ));
2330 offset += line.len();
2331 continue;
2332 }
2333 match HistoryEvent::parse(item) {
2334 Ok(event) => history.entries.push(HistoryEntry::Event(event)),
2335 // Kept verbatim. Diagnosed by `tix lint`, never repaired, never
2336 // dropped, and never fatal to the file.
2337 // Raw line, for the same reason as above.
2338 // Terminator only. `trim_end()` also eats trailing spaces and
2339 // tabs, which is the auto-repair data-model §8 forbids — and
2340 // `Err` is the arm a *recognised* entry lands in, so it is the
2341 // more likely one to carry real trailing whitespace.
2342 Err(_) => history.entries.push(HistoryEntry::Unparsed(
2343 raw_line.trim_end_matches('\n').to_string(),
2344 )),
2345 }
2346 offset += line.len();
2347 }
2348
2349 if history.entries.is_empty() && saw_rejected_list_item {
2350 // Nothing was retained and a list-shaped line was dropped. The
2351 // heading was consumed above, so the current return
2352 // (`after_heading[offset..]`) would delete it while leaving every
2353 // line after it as body prose: `tix view` loses the section and only
2354 // a later append renders a new heading above the demoted text. That
2355 // is #1336 — a list-shaped line judged prose empties the whole
2356 // section and deletes the audit trail with no diagnostic. Return the
2357 // original body verbatim (heading and all) so the file round-trips.
2358 //
2359 // A section of **pure prose** (no list item) is deliberately not
2360 // restored: the heading is derived from the log, and `storage`'s
2361 // `t010_an_empty_history_section_is_dropped_not_preserved` pins that
2362 // a heading with nothing to record is omitted rather than kept as a
2363 // bare heading. There is no audit entry to lose there.
2364 return (history, body.to_string());
2365 }
2366 (history, after_heading[offset..].to_string())
2367 }
2368}
2369
2370impl Issue {
2371 /// Parse a full issue file (`frontmatter` + `body`) from its text.
2372 ///
2373 /// The first line must be `---`; the closing `---` terminates the
2374 /// frontmatter. The body after it is preserved verbatim (the single
2375 /// structural separator blank line is consumed).
2376 pub fn parse(contents: &str) -> Result<Self> {
2377 // #1299: a CRLF file must parse. The delimiter scan tolerates a `\r`
2378 // before each `\n` rather than normalizing the whole file — a blanket
2379 // CRLF-to-LF rewrite would silently change a user's bytes, so the fix
2380 // stays in the scan that decides where the frontmatter ends.
2381 let rest = contents
2382 .strip_prefix("---\n")
2383 .or_else(|| contents.strip_prefix("---\r\n"))
2384 .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
2385 let mut current = rest;
2386 let mut fm_len = 0usize;
2387 loop {
2388 let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
2389 let line = ¤t[..line_end];
2390 let content = line.strip_suffix('\n').unwrap_or(line);
2391 let content = content.strip_suffix('\r').unwrap_or(content);
2392 if content == "---" {
2393 break;
2394 }
2395 fm_len += line_end;
2396 current = ¤t[line_end..];
2397 if current.is_empty() {
2398 return Err(TixError::Parse(
2399 "frontmatter is not terminated by a closing `---`".into(),
2400 ));
2401 }
2402 }
2403 let frontmatter = &rest[..fm_len];
2404 let body_raw = &rest[fm_len..];
2405 // #1299: the closing delimiter is CRLF too when the file is CRLF.
2406 let mut body = body_raw
2407 .strip_prefix("---\n")
2408 .or_else(|| body_raw.strip_prefix("---\r\n"))
2409 .unwrap_or(body_raw);
2410 if let Some(after) = body
2411 .strip_prefix("\r\n")
2412 .or_else(|| body.strip_prefix('\n'))
2413 {
2414 body = after;
2415 }
2416 let mut issue: Issue =
2417 serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
2418 // A legacy `links:` key is recorded, never read. The typed model does
2419 // not migrate it and does not reinterpret it as typed edges; the
2420 // service reports it as a `retired_links_key` validation result (C-001).
2421 // `relationships` stays as serde left it — empty.
2422 issue.has_retired_links_key = frontmatter_has_retired_links_key(frontmatter);
2423 // Unknown top-level keys are captured so the next write can put them back
2424 // (#1361). `links` is excluded on purpose — see [`capture_extra_frontmatter`].
2425 issue.extra_frontmatter = capture_extra_frontmatter(frontmatter);
2426 let (history, remaining_body) = History::parse_front(body);
2427 issue.history = history;
2428 issue.body = remaining_body;
2429 Ok(issue)
2430 }
2431
2432 /// Serialize just the frontmatter YAML (no delimiters), with `None`
2433 /// fields omitted except `created`/`updated` which are always present.
2434 pub fn frontmatter(&self) -> String {
2435 let fm =
2436 serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
2437 let mut fm = fm.trim_end_matches('\n').to_string();
2438 // Unknown keys are appended after the known ones, in `BTreeMap` order, so
2439 // the render is deterministic. A `HashMap` would reshuffle them on every
2440 // write and produce a diff in a file that had not changed (#1361).
2441 //
2442 // They go last rather than back in their original position because the
2443 // positions of the *known* keys move as optional fields come and go;
2444 // re-inserting at the original index would be a guess. `tix` has no
2445 // opinion about these keys, so where they sit carries no meaning.
2446 for (key, block) in &self.extra_frontmatter {
2447 fm.push('\n');
2448 fm.push_str(block);
2449 let _ = key;
2450 }
2451 fm.push('\n');
2452 fm
2453 }
2454
2455 /// Render the full issue file: `---`, frontmatter, closing `---`,
2456 /// blank separator and the raw body.
2457 pub fn to_file_contents(&self) -> String {
2458 // Frontmatter, then the History section (omitted when empty), then the
2459 // issue's own body — the order `data-model.md` §8 pins.
2460 let history = self.history.render();
2461 let mut out = format!("---\n{}---\n\n", self.frontmatter());
2462 out.push_str(&history);
2463 out.push_str(&self.body);
2464 out
2465 }
2466
2467 /// A borrowed [`IssueView`] of this issue, for the relationship service.
2468 ///
2469 /// Keeps the service off the full `Issue` surface: it needs only the
2470 /// number, title, status, relationships, and whether the legacy `links:`
2471 /// key is still present.
2472 pub fn issue_view(&self) -> IssueView<'_> {
2473 IssueView::new(
2474 self.number,
2475 &self.title,
2476 self.status.display(),
2477 &self.relationships,
2478 )
2479 .with_retired_links_key(self.has_retired_links_key)
2480 .with_parent(self.parent)
2481 }
2482
2483 /// The filename stem: `{created}-{id}-{slug}` when the issue has an id, or
2484 /// the legacy `{created}-{slug}` when it does not.
2485 ///
2486 /// The date leads so a lexicographic directory listing is creation order;
2487 /// the id follows so the name is globally unique across branches (#1587). A
2488 /// legacy issue keeps the date-leading name it was written with, because
2489 /// renaming it would be a migration this feature deliberately does not do.
2490 pub fn filename_stem(&self) -> String {
2491 match &self.id {
2492 Some(id) => format!("{}-{id}-{}", self.created, slugify(&self.title)),
2493 None => format!("{}-{}", self.created, slugify(&self.title)),
2494 }
2495 }
2496
2497 /// Record a mutation by stamping today's date into `updated`.
2498 pub fn bump_updated(&mut self) {
2499 self.updated = today();
2500 }
2501
2502 /// Set `start_date`/`due_date` together after atomic validation (FR-007):
2503 /// both must parse as calendar dates and `due_date >= start_date` when
2504 /// both are present (delegates to [`validate_issue_dates`]).
2505 pub fn set_dates(
2506 &mut self,
2507 start_date: Option<String>,
2508 due_date: Option<String>,
2509 ) -> Result<()> {
2510 validate_issue_dates(start_date.as_deref(), due_date.as_deref())?;
2511 self.start_date = start_date;
2512 self.due_date = due_date;
2513 Ok(())
2514 }
2515
2516 /// The byte offset of the first top-level `## Comments` line in `body`,
2517 /// if any (C-001, `contracts/comments-format.md` rule 6).
2518 ///
2519 /// A line counts as the boundary only when it is *not* inside a fenced
2520 /// code block, so a literal `## Comments` in prose (or a code block) is
2521 /// never mistaken for the real section head.
2522 fn comments_boundary(body: &str) -> Option<usize> {
2523 let mut in_fence = false;
2524 let mut offset = 0usize;
2525 for raw in body.split_inclusive('\n') {
2526 let line = raw.strip_suffix('\n').unwrap_or(raw);
2527 if line.starts_with("```") || line.starts_with("~~~") {
2528 in_fence = !in_fence;
2529 } else if !in_fence && line == "## Comments" {
2530 return Some(offset);
2531 }
2532 offset += raw.len();
2533 }
2534 None
2535 }
2536
2537 /// The description: everything above the first top-level `## Comments`
2538 /// heading, trimmed of trailing whitespace. With no section, the whole
2539 /// body is the description.
2540 pub fn description(&self) -> &str {
2541 match Self::comments_boundary(&self.body) {
2542 Some(idx) => self.body[..idx].trim_end(),
2543 None => self.body.trim_end(),
2544 }
2545 }
2546
2547 /// The comments parsed from the `## Comments` section, in file order
2548 /// (NFR-005). With no section — or a section with no `###` heads —
2549 /// returns an empty vec.
2550 pub fn comments(&self) -> Vec<Comment> {
2551 let Some(boundary) = Self::comments_boundary(&self.body) else {
2552 return Vec::new();
2553 };
2554 let section = &self.body[boundary..];
2555 let mut comments = Vec::new();
2556 let mut offset = 0usize;
2557 for raw in section.split_inclusive('\n') {
2558 let line = raw.strip_suffix('\n').unwrap_or(raw);
2559 if let Some(after) = line.strip_prefix("### ") {
2560 let (author, date) = parse_comment_head(after);
2561 let start = offset + raw.len();
2562 let mut text = String::new();
2563 for next in section[start..].split_inclusive('\n') {
2564 if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
2565 break;
2566 }
2567 text.push_str(next);
2568 }
2569 comments.push(Comment {
2570 author,
2571 date,
2572 text: text.trim_end_matches('\n').to_string(),
2573 });
2574 }
2575 offset += raw.len();
2576 }
2577 comments
2578 }
2579
2580 /// Append a comment to the `## Comments` section.
2581 ///
2582 /// Whitespace-only text is discarded and the body is left untouched
2583 /// (FR-004 case 4). Otherwise the section is created if absent and the
2584 /// comment block is appended after the last existing comment. The
2585 /// description and any existing comments are never rewritten.
2586 pub fn add_comment(&mut self, author: &str, text: &str) {
2587 if text.trim().is_empty() {
2588 return;
2589 }
2590 if Self::comments_boundary(&self.body).is_none() {
2591 if !self.body.trim().is_empty() {
2592 if !self.body.ends_with('\n') {
2593 self.body.push('\n');
2594 }
2595 self.body.push('\n');
2596 }
2597 self.body.push_str("## Comments\n");
2598 }
2599 if !self.body.is_empty() && !self.body.ends_with('\n') {
2600 self.body.push('\n');
2601 }
2602 if !self.body.ends_with("\n\n") {
2603 self.body.push('\n');
2604 }
2605 self.body
2606 .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
2607 self.bump_updated();
2608 }
2609}
2610
2611// ---------------------------------------------------------------------------
2612// The hierarchy service's view of an `Issue`
2613// ---------------------------------------------------------------------------
2614
2615/// Read in place, rather than through a borrowed projection.
2616///
2617/// Implemented here because `hierarchy` cannot import `model` without a cycle, and
2618/// because it is `Issue` — not [`IssueView`] — that every store-level caller already
2619/// holds. Without this each of them builds a `Vec<IssueView>` first, which is a
2620/// projection nobody asked for and one more place for two services to disagree about
2621/// the same snapshot.
2622impl crate::hierarchy::HierarchyNode for Issue {
2623 fn number(&self) -> u32 {
2624 self.number
2625 }
2626 fn title(&self) -> &str {
2627 &self.title
2628 }
2629 fn status(&self) -> &str {
2630 self.status.display()
2631 }
2632 fn parent_number(&self) -> Option<u32> {
2633 self.parent
2634 }
2635}
2636
2637// ---------------------------------------------------------------------------
2638// The project service's view of an `Issue`
2639// ---------------------------------------------------------------------------
2640
2641/// Read in place, for the same reason as [`crate::hierarchy::HierarchyNode`]:
2642/// `projects` cannot import `model` without a cycle, and `Issue` is what every
2643/// store-level caller already holds. Implementing it here rather than building a
2644/// projection keeps one snapshot shared by the service that decides and the storage
2645/// layer that writes.
2646///
2647/// `project_ids` borrows the stored list; an absent field is the empty slice, so the
2648/// service sees "no associations" without a second representation.
2649impl crate::projects::ProjectNode for Issue {
2650 fn number(&self) -> u32 {
2651 self.number
2652 }
2653 fn title(&self) -> &str {
2654 &self.title
2655 }
2656 fn status(&self) -> &str {
2657 self.status.display()
2658 }
2659 fn project_ids(&self) -> &[String] {
2660 self.projects.as_deref().unwrap_or(&[])
2661 }
2662}
2663
2664// ---------------------------------------------------------------------------
2665// The epic service's view of an `Issue`
2666// ---------------------------------------------------------------------------
2667
2668/// Read in place, for the same reason as [`crate::projects::ProjectNode`]:
2669/// `epics` cannot import `model` without a cycle, and `Issue` is what every
2670/// store-level caller already holds. Implementing it here rather than building a
2671/// projection keeps one snapshot shared by the service that decides and the storage
2672/// layer that writes.
2673///
2674/// `epic_ids` borrows the stored list; an absent field is the empty slice, so the
2675/// service sees "no references" without a second representation and never panics
2676/// on a legacy file.
2677impl crate::epics::EpicNode for Issue {
2678 fn number(&self) -> u32 {
2679 self.number
2680 }
2681 fn title(&self) -> &str {
2682 &self.title
2683 }
2684 fn status(&self) -> &str {
2685 self.status.display()
2686 }
2687 fn epic_ids(&self) -> &[String] {
2688 self.epics.as_deref().unwrap_or(&[])
2689 }
2690}
2691
2692#[cfg(test)]
2693mod tests {
2694 use super::*;
2695 use crate::relationships::RelationKind;
2696 use crate::time_tracking::{TimeEntry, TimeEntryExtensions};
2697
2698 #[test]
2699 fn status_helpers() {
2700 let s = Status::new("in progress");
2701 assert!(s.is("in progress"));
2702 assert!(!s.is("in_progress"));
2703 assert_eq!(s.display(), "in progress");
2704 assert_eq!(Status::default(), Status::new("todo"));
2705 }
2706
2707 #[test]
2708 fn status_survives_unknown_column_name() {
2709 let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
2710 assert_eq!(parsed.display(), "renamed-column");
2711 }
2712
2713 #[test]
2714 fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
2715 // An unset priority round-trips as the empty string and is *not* the
2716 // default: `priority: ''` says "no priority", which is a different
2717 // statement from the `medium` a missing field yields.
2718 let undefined: Priority = "".parse().unwrap();
2719 assert!(undefined.is_undefined());
2720 assert_eq!(undefined.to_string(), "", "and it writes back as ''");
2721 assert!(
2722 !Priority::default().is_undefined(),
2723 "the default is medium, not unset"
2724 );
2725 assert!(
2726 !Priority::Other("urgent".into()).is_undefined(),
2727 "only the empty string is unset"
2728 );
2729 assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
2730 assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
2731 assert_eq!(Priority::default(), Priority::Medium);
2732 assert_eq!(Priority::Low.to_string(), "low");
2733 }
2734
2735 #[test]
2736 fn unknown_priority_is_preserved_not_normalized() {
2737 let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
2738 assert_eq!(urgent.to_string(), "urgent");
2739 let yaml = serde_yaml::to_string(&urgent).unwrap();
2740 assert!(yaml.contains("urgent"));
2741 }
2742
2743 #[test]
2744 fn issue_roundtrip_preserves_every_field_including_number_and_body() {
2745 let mut issue = Issue {
2746 projects: None,
2747 id: None,
2748 number: 7,
2749 title: "Add documentation for the feature".into(),
2750 status: Status::new("todo"),
2751 labels: vec!["docs".into(), "onboarding".into()],
2752 priority: Priority::High,
2753 assignee: Some("alice".into()),
2754 estimate: Some("2".into()),
2755 commit: Some("abc123".into()),
2756 gitlab_id: Some("gl-9".into()),
2757 linear_id: Some("LIN-9".into()),
2758 mission: None,
2759 task: None,
2760 relationships: vec![],
2761 related_issues: vec![],
2762 history: crate::model::History::new(),
2763 has_retired_links_key: false,
2764 time_entries: vec![],
2765 files: vec![],
2766 cycles: None,
2767 milestones: None,
2768 reviewer: None,
2769 start_date: None,
2770 due_date: None,
2771 created: "2026-05-10".into(),
2772 type_: None,
2773 updated: "2026-05-10".into(),
2774 filename: String::new(),
2775 body: "Describe the issue here.\n\n".into(),
2776 epics: None,
2777 extra_frontmatter: std::collections::BTreeMap::new(),
2778 parent: None,
2779 series: None,
2780 health: None,
2781 archived: false,
2782 entered_at: None,
2783 source: None,
2784 };
2785 let contents = issue.to_file_contents();
2786 let parsed = Issue::parse(&contents).unwrap();
2787 assert_eq!(parsed, issue);
2788 issue.body.clear();
2789 assert_ne!(parsed, issue);
2790 }
2791
2792 #[test]
2793 fn typed_relationships_round_trip_preserves_entries_verbatim() {
2794 let mut issue = Issue {
2795 projects: None,
2796 id: None,
2797 number: 9,
2798 title: "T".into(),
2799 status: Status::default(),
2800 labels: vec![],
2801 priority: Priority::default(),
2802 assignee: None,
2803 estimate: None,
2804 commit: None,
2805 gitlab_id: None,
2806 linear_id: None,
2807 mission: None,
2808 task: None,
2809 relationships: vec![
2810 Relationship::new(9, 12, RelationKind::Blocks, "Twelve"),
2811 Relationship::new(9, 47, RelationKind::Related, "Forty seven"),
2812 ],
2813 related_issues: vec![],
2814 history: History::new(),
2815 has_retired_links_key: false,
2816 time_entries: vec![],
2817 files: vec!["docs/x.md".into(), "specs/y/spec.md".into()],
2818 cycles: None,
2819 milestones: None,
2820 reviewer: None,
2821 start_date: None,
2822 due_date: None,
2823 created: "2026-01-01".into(),
2824 type_: None,
2825 updated: "2026-01-01".into(),
2826 filename: String::new(),
2827 body: String::new(),
2828 epics: None,
2829 extra_frontmatter: std::collections::BTreeMap::new(),
2830 parent: None,
2831 series: None,
2832 health: None,
2833 archived: false,
2834 entered_at: None,
2835 source: None,
2836 };
2837 let contents = issue.to_file_contents();
2838 let parsed = Issue::parse(&contents).unwrap();
2839 assert_eq!(parsed.relationships.len(), 2);
2840 assert_eq!(parsed.relationships[0].kind, RelationKind::Blocks);
2841 assert_eq!(parsed.relationships[0].display, "blocks #12 Twelve");
2842 // The stable id survives the round trip, which is what makes an edit
2843 // addressable after a rewrite.
2844 assert_eq!(parsed.relationships[0].id, issue.relationships[0].id);
2845 assert_eq!(parsed.files, ["docs/x.md", "specs/y/spec.md"]);
2846 // Byte-stable through read -> write -> read.
2847 assert_eq!(parsed.to_file_contents(), contents);
2848 // A stored edge never carries the input-only `blocked_by` kind.
2849 assert!(
2850 parsed
2851 .relationships
2852 .iter()
2853 .all(|r| r.kind != RelationKind::BlockedBy)
2854 );
2855 issue.relationships.clear();
2856 }
2857
2858 #[test]
2859 fn related_issues_round_trip_verbatim_and_omit_when_empty() {
2860 // The key is modelled, so it is parsed into the field (never captured as
2861 // unknown) and re-emitted exactly once, in struct position. Entries are
2862 // byte-verbatim: a path must not be re-quoted or re-ordered.
2863 let original = "---\nnumber: 3\ntitle: T\nstatus: todo\nlabels: []\n\
2864priority: medium\nfiles: []\nrelated_issues:\n- '42'\n\
2865- '../other/2026-01-01-x.md'\n- 'https://gitlab.com/foo/bar#7'\n\
2866created: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
2867 let issue = Issue::parse(original).unwrap();
2868 assert_eq!(
2869 issue.related_issues,
2870 vec![
2871 "42".to_string(),
2872 "../other/2026-01-01-x.md".to_string(),
2873 "https://gitlab.com/foo/bar#7".to_string(),
2874 ]
2875 );
2876 assert!(
2877 capture_extra_frontmatter(&issue.frontmatter()).is_empty(),
2878 "a modelled key must not also be captured as unknown"
2879 );
2880 let rendered = issue.to_file_contents();
2881 assert_eq!(
2882 rendered.matches("\nrelated_issues:").count(),
2883 1,
2884 "written exactly once:\n{rendered}"
2885 );
2886 assert_eq!(Issue::parse(&rendered).unwrap(), issue);
2887 // Empty is omitted, so a file without the key round-trips byte-identically.
2888 let mut empty = issue.clone();
2889 empty.related_issues.clear();
2890 assert!(!empty.frontmatter().contains("related_issues"));
2891 }
2892
2893 #[test]
2894 fn type_round_trips_verbatim_and_omits_when_absent() {
2895 let original = "---\nnumber: 3\ntitle: T\nstatus: todo\nlabels: []\n\
2896type: bug\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
2897 let issue = Issue::parse(original).unwrap();
2898 assert_eq!(issue.type_.as_deref(), Some("bug"));
2899 assert!(
2900 capture_extra_frontmatter(&issue.frontmatter()).is_empty(),
2901 "a modelled key must not also be captured as unknown"
2902 );
2903 let rendered = issue.to_file_contents();
2904 assert_eq!(rendered.matches("\ntype:").count(), 1, "{rendered}");
2905 assert_eq!(Issue::parse(&rendered).unwrap(), issue);
2906 // Absent is omitted.
2907 let mut none = issue.clone();
2908 none.type_ = None;
2909 assert!(!none.frontmatter().contains("type:"));
2910 }
2911
2912 #[test]
2913 fn empty_relationships_is_omitted_while_files_keeps_its_inline_empty_list() {
2914 // The empty-list shape is decided once and pinned here: the typed field
2915 // is **omitted** when empty, because it is new and a meaningless
2916 // `relationships: []` would otherwise appear on every issue file. The
2917 // pre-existing `files: []` convention is untouched.
2918 let issue = Issue {
2919 projects: None,
2920 id: None,
2921 number: 4,
2922 title: "T".into(),
2923 status: Status::default(),
2924 labels: vec![],
2925 priority: Priority::default(),
2926 assignee: None,
2927 estimate: None,
2928 commit: None,
2929 gitlab_id: None,
2930 linear_id: None,
2931 mission: None,
2932 task: None,
2933 relationships: vec![],
2934 related_issues: vec![],
2935 history: History::new(),
2936 has_retired_links_key: false,
2937 time_entries: vec![],
2938 files: vec![],
2939 cycles: None,
2940 milestones: None,
2941 reviewer: None,
2942 start_date: None,
2943 due_date: None,
2944 created: "2026-01-01".into(),
2945 type_: None,
2946 updated: "2026-01-01".into(),
2947 filename: String::new(),
2948 body: String::new(),
2949 epics: None,
2950 extra_frontmatter: std::collections::BTreeMap::new(),
2951 parent: None,
2952 series: None,
2953 health: None,
2954 archived: false,
2955 entered_at: None,
2956 source: None,
2957 };
2958 let fm = issue.frontmatter();
2959 assert!(!fm.contains("relationships"), "got:\n{fm}");
2960 assert!(fm.contains("files: []"), "got:\n{fm}");
2961 }
2962
2963 #[test]
2964 fn an_unknown_relation_kind_in_frontmatter_is_rejected() {
2965 // The old field tolerated any string, so a non-numeric entry parsed
2966 // happily. The typed vocabulary is closed, so an unknown kind is a
2967 // parse error rather than a coerced default (C-001).
2968 let yaml = "\
2969number: 1
2970title: T
2971status: todo
2972relationships:
2973- id: rel_1
2974 from: 1
2975 to: 2
2976 kind: depends_on
2977created: '2026-01-01'
2978updated: '2026-01-01'
2979";
2980 let err = serde_yaml::from_str::<Issue>(yaml).unwrap_err();
2981 assert!(
2982 err.to_string().contains("depends_on"),
2983 "the unknown kind is named: {err}"
2984 );
2985 }
2986
2987 #[test]
2988 fn a_legacy_links_key_parses_but_sets_the_retired_flag() {
2989 // Tolerant on read so existing repositories still load, and flagged so
2990 // the service can report it. The value is never read as typed data.
2991 let yaml = "\
2992number: 1
2993title: T
2994status: todo
2995links:
2996- '12'
2997- 47
2998created: '2026-01-01'
2999updated: '2026-01-01'
3000";
3001 let issue = Issue::parse(&format!("---\n{yaml}---\n\nbody\n")).unwrap();
3002 assert!(issue.has_retired_links_key);
3003 assert!(
3004 issue.relationships.is_empty(),
3005 "C-001: legacy values are never reinterpreted as typed edges"
3006 );
3007 }
3008
3009 #[test]
3010 fn missing_dates_default_to_today() {
3011 let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
3012 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
3013 assert_eq!(issue.created, today());
3014 assert_eq!(issue.updated, today());
3015 }
3016
3017 #[test]
3018 fn body_is_preserved_verbatim() {
3019 let issue = Issue {
3020 projects: None,
3021 id: None,
3022 number: 1,
3023 title: "T".into(),
3024 status: Status::default(),
3025 labels: vec![],
3026 priority: Priority::default(),
3027 assignee: None,
3028 estimate: None,
3029 commit: None,
3030 gitlab_id: None,
3031 linear_id: None,
3032 mission: None,
3033 task: None,
3034 relationships: vec![],
3035 related_issues: vec![],
3036 history: crate::model::History::new(),
3037 has_retired_links_key: false,
3038 time_entries: vec![],
3039 files: vec![],
3040 cycles: None,
3041 milestones: None,
3042 reviewer: None,
3043 start_date: None,
3044 due_date: None,
3045 created: "2026-01-02".into(),
3046 type_: None,
3047 updated: "2026-01-02".into(),
3048 filename: String::new(),
3049 body: "line one\n\nline two\n```\ncode\n```".into(),
3050 epics: None,
3051 extra_frontmatter: std::collections::BTreeMap::new(),
3052 parent: None,
3053 series: None,
3054 health: None,
3055 archived: false,
3056 entered_at: None,
3057 source: None,
3058 };
3059 let contents = issue.to_file_contents();
3060 let parsed = Issue::parse(&contents).unwrap();
3061 assert_eq!(parsed.body, issue.body);
3062 }
3063
3064 #[test]
3065 fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
3066 let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
3067 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
3068 assert_eq!(issue.assignee.as_deref(), Some("alice"));
3069 let rerendered = issue.frontmatter();
3070 assert!(
3071 rerendered.contains("assignee: \"@alice\"")
3072 || rerendered.contains("assignee: '@alice'")
3073 );
3074 }
3075
3076 #[test]
3077 fn full_identity_assignee_round_trips_verbatim() {
3078 let issue = Issue {
3079 projects: None,
3080 id: None,
3081 number: 5,
3082 title: "T".into(),
3083 status: Status::default(),
3084 labels: vec![],
3085 priority: Priority::default(),
3086 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
3087 estimate: None,
3088 commit: None,
3089 gitlab_id: None,
3090 linear_id: None,
3091 mission: None,
3092 task: None,
3093 relationships: vec![],
3094 related_issues: vec![],
3095 files: vec![],
3096 cycles: None,
3097 milestones: None,
3098 reviewer: None,
3099 start_date: None,
3100 due_date: None,
3101 created: "2026-01-01".into(),
3102 type_: None,
3103 updated: "2026-01-01".into(),
3104 filename: String::new(),
3105 body: String::new(),
3106 history: crate::model::History::new(),
3107 has_retired_links_key: false,
3108 time_entries: vec![],
3109 epics: None,
3110 extra_frontmatter: std::collections::BTreeMap::new(),
3111 parent: None,
3112 series: None,
3113 health: None,
3114 archived: false,
3115 entered_at: None,
3116 source: None,
3117 };
3118 let fm = issue.frontmatter();
3119 assert!(
3120 fm.contains("Herbert Maschke <thyseus@pm.me>"),
3121 "identity must be stored verbatim, got:\n{fm}"
3122 );
3123 assert!(
3124 !fm.contains("@Herbert Maschke"),
3125 "identity must not be @-prefixed, got:\n{fm}"
3126 );
3127 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
3128 assert_eq!(parsed, issue);
3129 }
3130
3131 #[test]
3132 fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
3133 let issue = Issue {
3134 projects: None,
3135 id: None,
3136 number: 6,
3137 title: "T".into(),
3138 status: Status::default(),
3139 labels: vec![],
3140 priority: Priority::default(),
3141 assignee: Some("alice".into()),
3142 estimate: None,
3143 commit: None,
3144 gitlab_id: None,
3145 linear_id: None,
3146 mission: None,
3147 task: None,
3148 relationships: vec![],
3149 related_issues: vec![],
3150 files: vec![],
3151 cycles: None,
3152 milestones: None,
3153 reviewer: None,
3154 start_date: None,
3155 due_date: None,
3156 created: "2026-01-01".into(),
3157 type_: None,
3158 updated: "2026-01-01".into(),
3159 filename: String::new(),
3160 body: String::new(),
3161 history: crate::model::History::new(),
3162 has_retired_links_key: false,
3163 time_entries: vec![],
3164 epics: None,
3165 extra_frontmatter: std::collections::BTreeMap::new(),
3166 parent: None,
3167 series: None,
3168 health: None,
3169 archived: false,
3170 entered_at: None,
3171 source: None,
3172 };
3173 let fm = issue.frontmatter();
3174 assert!(
3175 fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
3176 "legacy @-prefixed bytes expected, got:\n{fm}"
3177 );
3178 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
3179 assert_eq!(parsed.assignee.as_deref(), Some("alice"));
3180 }
3181
3182 #[test]
3183 fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
3184 let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
3185 let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
3186 let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
3187 let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
3188 assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
3189 assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
3190 }
3191
3192 #[test]
3193 fn angle_bracket_identity_without_name_written_verbatim() {
3194 let issue = Issue {
3195 projects: None,
3196 id: None,
3197 number: 8,
3198 title: "T".into(),
3199 status: Status::default(),
3200 labels: vec![],
3201 priority: Priority::default(),
3202 assignee: Some("<thyseus@pm.me>".into()),
3203 estimate: None,
3204 commit: None,
3205 gitlab_id: None,
3206 linear_id: None,
3207 mission: None,
3208 task: None,
3209 relationships: vec![],
3210 related_issues: vec![],
3211 files: vec![],
3212 cycles: None,
3213 milestones: None,
3214 reviewer: None,
3215 start_date: None,
3216 due_date: None,
3217 created: "2026-01-01".into(),
3218 type_: None,
3219 updated: "2026-01-01".into(),
3220 filename: String::new(),
3221 body: String::new(),
3222 history: crate::model::History::new(),
3223 has_retired_links_key: false,
3224 time_entries: vec![],
3225 epics: None,
3226 extra_frontmatter: std::collections::BTreeMap::new(),
3227 parent: None,
3228 series: None,
3229 health: None,
3230 archived: false,
3231 entered_at: None,
3232 source: None,
3233 };
3234 let fm = issue.frontmatter();
3235 assert!(
3236 fm.contains("<thyseus@pm.me>"),
3237 "identity-like value must be verbatim, got:\n{fm}"
3238 );
3239 assert!(
3240 !fm.contains("@<thyseus"),
3241 "identity-like value must not be @-prefixed, got:\n{fm}"
3242 );
3243 }
3244
3245 #[test]
3246 fn all_none_frontmatter_only_contains_required_keys() {
3247 let issue = Issue {
3248 projects: None,
3249 id: None,
3250 number: 4,
3251 title: "T".into(),
3252 status: Status::default(),
3253 labels: vec![],
3254 priority: Priority::default(),
3255 assignee: None,
3256 estimate: None,
3257 commit: None,
3258 gitlab_id: None,
3259 linear_id: None,
3260 mission: None,
3261 task: None,
3262 relationships: vec![],
3263 related_issues: vec![],
3264 files: vec![],
3265 cycles: None,
3266 milestones: None,
3267 reviewer: None,
3268 start_date: None,
3269 due_date: None,
3270 created: "2026-01-01".into(),
3271 type_: None,
3272 updated: "2026-01-01".into(),
3273 filename: String::new(),
3274 body: String::new(),
3275 history: crate::model::History::new(),
3276 has_retired_links_key: false,
3277 time_entries: vec![],
3278 epics: None,
3279 extra_frontmatter: std::collections::BTreeMap::new(),
3280 parent: None,
3281 series: None,
3282 health: None,
3283 archived: false,
3284 entered_at: None,
3285 source: None,
3286 };
3287 let fm = issue.frontmatter();
3288 assert!(fm.contains("number:"));
3289 assert!(fm.contains("title:"));
3290 assert!(fm.contains("status:"));
3291 assert!(fm.contains("labels:"));
3292 assert!(fm.contains("priority:"));
3293 assert!(fm.contains("created:"));
3294 assert!(fm.contains("updated:"));
3295 for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
3296 assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
3297 }
3298 }
3299
3300 #[test]
3301 fn filename_stem_matches_contract_format() {
3302 let mut issue = Issue {
3303 projects: None,
3304 id: None,
3305 number: 1,
3306 title: "Fix !Login crash?".into(),
3307 status: Status::default(),
3308 labels: vec![],
3309 priority: Priority::default(),
3310 assignee: None,
3311 estimate: None,
3312 commit: None,
3313 gitlab_id: None,
3314 linear_id: None,
3315 mission: None,
3316 task: None,
3317 relationships: vec![],
3318 related_issues: vec![],
3319 files: vec![],
3320 cycles: None,
3321 milestones: None,
3322 reviewer: None,
3323 start_date: None,
3324 due_date: None,
3325 created: "2026-05-10".into(),
3326 type_: None,
3327 updated: "2026-05-10".into(),
3328 filename: String::new(),
3329 body: String::new(),
3330 history: crate::model::History::new(),
3331 has_retired_links_key: false,
3332 time_entries: vec![],
3333 epics: None,
3334 extra_frontmatter: std::collections::BTreeMap::new(),
3335 parent: None,
3336 series: None,
3337 health: None,
3338 archived: false,
3339 entered_at: None,
3340 source: None,
3341 };
3342 assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
3343 // With an id, the id follows the date (#1587).
3344 issue.id = Some("A1B2C3D4".into());
3345 assert_eq!(issue.filename_stem(), "2026-05-10-A1B2C3D4-fix-login-crash");
3346 }
3347
3348 #[test]
3349 fn bump_updated_stamps_today() {
3350 let mut issue = Issue {
3351 projects: None,
3352 id: None,
3353 number: 1,
3354 title: "T".into(),
3355 status: Status::default(),
3356 labels: vec![],
3357 priority: Priority::default(),
3358 assignee: None,
3359 estimate: None,
3360 commit: None,
3361 gitlab_id: None,
3362 linear_id: None,
3363 mission: None,
3364 task: None,
3365 relationships: vec![],
3366 related_issues: vec![],
3367 files: vec![],
3368 cycles: None,
3369 milestones: None,
3370 reviewer: None,
3371 start_date: None,
3372 due_date: None,
3373 created: "2020-01-01".into(),
3374 type_: None,
3375 updated: "2020-01-01".into(),
3376 filename: String::new(),
3377 body: String::new(),
3378 history: crate::model::History::new(),
3379 has_retired_links_key: false,
3380 time_entries: vec![],
3381 epics: None,
3382 extra_frontmatter: std::collections::BTreeMap::new(),
3383 parent: None,
3384 series: None,
3385 health: None,
3386 archived: false,
3387 entered_at: None,
3388 source: None,
3389 };
3390 issue.bump_updated();
3391 assert_eq!(issue.updated, today());
3392 assert_ne!(issue.updated, "2020-01-01");
3393 }
3394
3395 #[test]
3396 fn parse_rejects_malformed_frontmatter() {
3397 assert!(Issue::parse("no delimiter\n").is_err());
3398 assert!(Issue::parse("---\na: 1\n").is_err()); // unterminated
3399 assert!(Issue::parse("---\nbroken: [").is_err()); // bad yaml, unterminated
3400 }
3401
3402 #[test]
3403 fn a_crlf_issue_file_parses() {
3404 // #1299: a file checked out with `core.autocrlf=true` must not be reported
3405 // as unparseable, and its History section must be recognised as a section
3406 // rather than left in the body where the next write appends a second one.
3407 let crlf = "---\r\nnumber: 7\r\ntitle: T\r\nstatus: todo\r\ncreated: 2026-01-01\r\nupdated: 2026-01-01\r\n---\r\n\r\n## History\r\n\r\n- 2026-01-01T00:00:00Z - alice - relationship_added\r\n";
3408 let issue = Issue::parse(crlf).unwrap();
3409 assert_eq!(issue.number, 7);
3410 assert_eq!(issue.title, "T");
3411 assert_eq!(issue.history.len(), 1, "the CRLF History section is parsed");
3412 assert!(issue.body.trim().is_empty(), "body: {:?}", issue.body);
3413 }
3414
3415 #[test]
3416 fn an_lf_issue_is_unaffected_by_the_crlf_tolerance() {
3417 let lf = "---\nnumber: 7\ntitle: T\nstatus: todo\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
3418 let issue = Issue::parse(lf).unwrap();
3419 assert_eq!(issue.number, 7);
3420 assert_eq!(issue.body.trim(), "body");
3421 }
3422
3423 #[test]
3424 fn today_is_an_iso_calendar_date() {
3425 let t = today();
3426 assert_eq!(t.len(), 10);
3427 let parts: Vec<&str> = t.split('-').collect();
3428 assert_eq!(parts.len(), 3);
3429 assert_eq!(parts[0].len(), 4);
3430 assert_eq!(parts[1].len(), 2);
3431 assert_eq!(parts[2].len(), 2);
3432 }
3433
3434 #[test]
3435 fn civil_from_days_epoch_is_1970_01_01() {
3436 assert_eq!(civil_from_days(0), (1970, 1, 1));
3437 }
3438
3439 // ------------------------------------------------------------------
3440 // WP01 — Comment data model & body lifecycle
3441 // ------------------------------------------------------------------
3442
3443 fn issue(body: &str) -> Issue {
3444 Issue {
3445 projects: None,
3446 id: None,
3447 number: 1,
3448 title: "T".into(),
3449 status: Status::default(),
3450 labels: vec![],
3451 priority: Priority::default(),
3452 assignee: None,
3453 estimate: None,
3454 commit: None,
3455 gitlab_id: None,
3456 linear_id: None,
3457 mission: None,
3458 task: None,
3459 relationships: vec![],
3460 related_issues: vec![],
3461 history: History::new(),
3462 has_retired_links_key: false,
3463 time_entries: vec![],
3464 files: vec![],
3465 cycles: None,
3466 milestones: None,
3467 reviewer: None,
3468 start_date: None,
3469 due_date: None,
3470 created: "2026-01-01".into(),
3471 type_: None,
3472 updated: "2026-01-01".into(),
3473 filename: String::new(),
3474 body: body.to_string(),
3475 epics: None,
3476 extra_frontmatter: std::collections::BTreeMap::new(),
3477 parent: None,
3478 series: None,
3479 health: None,
3480 archived: false,
3481 entered_at: None,
3482 source: None,
3483 }
3484 }
3485
3486 // --- T001: parse body → description + comments ---
3487
3488 #[test]
3489 fn comments_empty_when_no_section() {
3490 let i = issue("Some description text.\n");
3491 assert_eq!(i.description(), "Some description text.");
3492 assert_eq!(i.comments(), [] as [crate::model::Comment; 0]);
3493 }
3494
3495 #[test]
3496 fn comments_parsed_from_section() {
3497 let i = issue(
3498 "Hello\n\n## Comments\n\n\
3499 ### @alice (2026-09-13)\n\n\
3500 First comment.\n\n\
3501 ### bob (2026-09-14)\n\n\
3502 Second comment.\n",
3503 );
3504 assert_eq!(i.description(), "Hello");
3505 let cs = i.comments();
3506 assert_eq!(cs.len(), 2);
3507 assert_eq!(cs[0].author, "@alice");
3508 assert_eq!(cs[0].date, "2026-09-13");
3509 assert_eq!(cs[1].author, "bob");
3510 assert_eq!(cs[1].date, "2026-09-14");
3511 }
3512
3513 #[test]
3514 fn code_fence_boundary_not_treated_as_boundary() {
3515 let body = "Description\n\n\
3516 ```\n\
3517 ## Comments\n\
3518 ```\n\n\
3519 ## Comments\n\n\
3520 ### eve (2026-10-01)\n\n\
3521 real comment\n";
3522 let i = issue(body);
3523 let cs = i.comments();
3524 assert_eq!(cs.len(), 1);
3525 assert_eq!(cs[0].author, "eve");
3526 assert_eq!(cs[0].date, "2026-10-01");
3527 assert_eq!(cs[0].text, "\nreal comment");
3528 assert!(
3529 i.description().contains("## Comments"),
3530 "code-blocked heading must be in description"
3531 );
3532 }
3533
3534 #[test]
3535 fn blank_lines_preserved_in_comment_text() {
3536 let i = issue(
3537 "## Comments\n\n\
3538 ### @alice (2026-09-13)\n\n\
3539 line one\n\nline two\n\n\n\
3540 line three\n",
3541 );
3542 let cs = i.comments();
3543 assert_eq!(cs.len(), 1);
3544 let text = &cs[0].text;
3545 assert!(
3546 text.contains("\nline two"),
3547 "internal blank lines must be preserved: got {text:?}"
3548 );
3549 assert!(
3550 text.contains("line three"),
3551 "must contain all lines: got {text:?}"
3552 );
3553 }
3554
3555 #[test]
3556 fn comments_empty_when_section_has_no_heads() {
3557 let i = issue("## Comments\n\n\n");
3558 assert_eq!(i.comments(), [] as [crate::model::Comment; 0]);
3559 }
3560
3561 // --- T002: add_comment ---
3562
3563 #[test]
3564 fn add_first_comment_to_empty_body() {
3565 let mut i = issue("");
3566 i.add_comment("@alice", "Hello world");
3567 assert_eq!(i.description(), "");
3568 let cs = i.comments();
3569 assert_eq!(cs.len(), 1);
3570 assert_eq!(cs[0].author, "@alice");
3571 assert_eq!(cs[0].text, "\nHello world");
3572 assert!(i.body.contains("## Comments"));
3573 }
3574
3575 #[test]
3576 fn second_comment_appended_after_first() {
3577 let mut i = issue("");
3578 i.add_comment("@alice", "first");
3579 i.add_comment("bob", "second");
3580 let cs = i.comments();
3581 assert_eq!(cs.len(), 2);
3582 let pos_a = i.body.find("### @alice");
3583 let pos_b = i.body.find("### bob");
3584 assert!(pos_a < pos_b, "first comment must precede second");
3585 }
3586
3587 #[test]
3588 fn whitespace_only_comment_discarded() {
3589 let mut i = issue("Hello\n");
3590 let before = i.body.clone();
3591 i.add_comment("@alice", " \n ");
3592 assert_eq!(i.body, before, "whitespace-only text must not change body");
3593 }
3594
3595 #[test]
3596 fn description_unchanged_after_add_comment() {
3597 let mut i = issue("Description text.\n");
3598 i.add_comment("@alice", "a comment");
3599 assert_eq!(i.description(), "Description text.");
3600 }
3601
3602 #[test]
3603 fn literal_comments_heading_in_code_block_not_touched() {
3604 let mut i = issue("```\n## Comments\n```\n");
3605 i.add_comment("@alice", "new");
3606 assert!(
3607 i.body.contains("```\n## Comments\n```"),
3608 "code-block heading must be preserved: {0:?}",
3609 i.body
3610 );
3611 assert!(i.body.contains("### @alice"), "must contain new comment");
3612 }
3613
3614 // --- T003: roundtrip / NFR-005 ---
3615
3616 #[test]
3617 fn roundtrip_preserves_comment_data() {
3618 let mut i = issue(
3619 "## Comments\n\n\
3620 ### @alice (2026-09-13)\n\n\
3621 Original comment.\n",
3622 );
3623 i.add_comment("bob", "Second comment");
3624 let file = i.to_file_contents();
3625 let parsed = Issue::parse(&file).unwrap();
3626 assert_eq!(parsed.description(), i.description());
3627 let cs = parsed.comments();
3628 assert_eq!(cs.len(), 2);
3629 assert_eq!(cs[0].author, "@alice");
3630 assert_eq!(cs[1].author, "bob");
3631 assert_eq!(cs[1].text, "\nSecond comment");
3632 }
3633
3634 #[test]
3635 fn three_comments_in_file_order() {
3636 let mut i = issue("");
3637 i.add_comment("a", "one");
3638 i.add_comment("b", "two");
3639 i.add_comment("c", "three");
3640 let cs = i.comments();
3641 assert_eq!(
3642 cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
3643 ["a", "b", "c"]
3644 );
3645 assert_eq!(
3646 i.body,
3647 format!(
3648 "## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
3649 today()
3650 )
3651 );
3652 }
3653
3654 #[test]
3655 fn preserves_hand_authored_comment_markdown() {
3656 let body = "## Comments\n\n\
3657 ### @alice (2026-09-13)\n\n\
3658 **bold** and _italic_\n\n\
3659 - item 1\n- item 2\n\n";
3660 let i = issue(body);
3661 let cs = i.comments();
3662 assert_eq!(cs.len(), 1);
3663 assert!(cs[0].text.contains("**bold**"));
3664 assert!(cs[0].text.contains("- item 1"));
3665 }
3666
3667 #[test]
3668 fn description_code_block_heading_not_boundary() {
3669 let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
3670 let before = i.body.clone();
3671 i.add_comment("eve", "real");
3672 assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
3673 let cs = i.comments();
3674 assert_eq!(cs.len(), 1);
3675 assert_eq!(cs[0].author, "eve");
3676 assert!(
3677 i.body.starts_with(&before),
3678 "code-block heading must be preserved"
3679 );
3680 }
3681
3682 // ------------------------------------------------------------------
3683 // WP02 — Planning fields, dates & file format
3684 // ------------------------------------------------------------------
3685
3686 #[test]
3687 fn planning_fields_round_trip() {
3688 let issue = Issue {
3689 projects: None,
3690 id: None,
3691 number: 12,
3692 title: "Ship cycle one".into(),
3693 status: Status::new("in progress"),
3694 labels: vec!["release".into()],
3695 priority: Priority::High,
3696 assignee: Some("alice".into()),
3697 estimate: None,
3698 commit: None,
3699 gitlab_id: None,
3700 linear_id: None,
3701 mission: None,
3702 task: None,
3703 relationships: vec![],
3704 related_issues: vec![],
3705 history: crate::model::History::new(),
3706 has_retired_links_key: false,
3707 time_entries: vec![],
3708 files: vec!["specs/p/spec.md".into()],
3709 cycles: Some(vec!["Cycle 1".into(), "Sprint hardening".into()]),
3710 milestones: Some(vec!["v1.0".into()]),
3711 reviewer: Some("Bob <bob@example.com>".into()),
3712 start_date: Some("2026-09-07".into()),
3713 due_date: Some("2026-09-18".into()),
3714 created: "2026-09-07".into(),
3715 type_: None,
3716 updated: "2026-09-07".into(),
3717 filename: String::new(),
3718 body: "body".into(),
3719 epics: None,
3720 extra_frontmatter: std::collections::BTreeMap::new(),
3721 parent: None,
3722 series: None,
3723 health: None,
3724 archived: false,
3725 entered_at: None,
3726 source: None,
3727 };
3728 let contents = issue.to_file_contents();
3729 let parsed = Issue::parse(&contents).unwrap();
3730 assert_eq!(parsed, issue);
3731 assert_eq!(
3732 parsed.cycles.clone().unwrap(),
3733 ["Cycle 1", "Sprint hardening"]
3734 );
3735 assert_eq!(parsed.milestones.clone().unwrap(), ["v1.0"]);
3736 assert_eq!(parsed.reviewer.as_deref(), Some("Bob <bob@example.com>"));
3737 assert_eq!(parsed.start_date.as_deref(), Some("2026-09-07"));
3738 assert_eq!(parsed.due_date.as_deref(), Some("2026-09-18"));
3739 }
3740
3741 #[test]
3742 fn planning_fields_absent_on_legacy_serialize_nothing() {
3743 let issue = Issue {
3744 projects: None,
3745 id: None,
3746 number: 3,
3747 title: "T".into(),
3748 status: Status::default(),
3749 labels: vec![],
3750 priority: Priority::default(),
3751 assignee: None,
3752 estimate: None,
3753 commit: None,
3754 gitlab_id: None,
3755 linear_id: None,
3756 mission: None,
3757 task: None,
3758 relationships: vec![],
3759 related_issues: vec![],
3760 history: crate::model::History::new(),
3761 has_retired_links_key: false,
3762 time_entries: vec![],
3763 files: vec![],
3764 cycles: None,
3765 milestones: None,
3766 reviewer: None,
3767 start_date: None,
3768 due_date: None,
3769 created: "2026-01-01".into(),
3770 type_: None,
3771 updated: "2026-01-01".into(),
3772 filename: String::new(),
3773 body: "body".into(),
3774 epics: None,
3775 extra_frontmatter: std::collections::BTreeMap::new(),
3776 parent: None,
3777 series: None,
3778 health: None,
3779 archived: false,
3780 entered_at: None,
3781 source: None,
3782 };
3783 let fm = issue.frontmatter();
3784 for needle in ["cycles", "milestones", "reviewer", "start_date", "due_date"] {
3785 assert!(!fm.contains(needle), "{needle} must be omitted, got:\n{fm}");
3786 }
3787 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
3788 assert!(parsed.cycles.is_none());
3789 assert!(parsed.milestones.is_none());
3790 assert!(parsed.reviewer.is_none());
3791 assert!(parsed.start_date.is_none());
3792 assert!(parsed.due_date.is_none());
3793 }
3794
3795 #[test]
3796 fn empty_planning_lists_serialize_as_omitted() {
3797 let issue = Issue {
3798 projects: None,
3799 id: None,
3800 number: 3,
3801 title: "T".into(),
3802 status: Status::default(),
3803 labels: vec![],
3804 priority: Priority::default(),
3805 assignee: None,
3806 estimate: None,
3807 commit: None,
3808 gitlab_id: None,
3809 linear_id: None,
3810 mission: None,
3811 task: None,
3812 relationships: vec![],
3813 related_issues: vec![],
3814 history: crate::model::History::new(),
3815 has_retired_links_key: false,
3816 time_entries: vec![],
3817 files: vec![],
3818 cycles: Some(vec![]),
3819 milestones: Some(vec![]),
3820 reviewer: None,
3821 start_date: None,
3822 due_date: None,
3823 created: "2026-01-01".into(),
3824 type_: None,
3825 updated: "2026-01-01".into(),
3826 filename: String::new(),
3827 body: "body".into(),
3828 epics: None,
3829 extra_frontmatter: std::collections::BTreeMap::new(),
3830 parent: None,
3831 series: None,
3832 health: None,
3833 archived: false,
3834 entered_at: None,
3835 source: None,
3836 };
3837 let fm = issue.frontmatter();
3838 assert!(!fm.contains("cycles"), "{fm}");
3839 assert!(!fm.contains("milestones"), "{fm}");
3840 }
3841
3842 #[test]
3843 fn duplicate_planning_list_entries_collapse_on_write() {
3844 let issue = Issue {
3845 projects: None,
3846 id: None,
3847 number: 3,
3848 title: "T".into(),
3849 status: Status::default(),
3850 labels: vec![],
3851 priority: Priority::default(),
3852 assignee: None,
3853 estimate: None,
3854 commit: None,
3855 gitlab_id: None,
3856 linear_id: None,
3857 mission: None,
3858 task: None,
3859 relationships: vec![],
3860 related_issues: vec![],
3861 history: crate::model::History::new(),
3862 has_retired_links_key: false,
3863 time_entries: vec![],
3864 files: vec![],
3865 cycles: Some(vec!["A".into(), "A".into(), "B".into()]),
3866 milestones: Some(vec!["m".into(), "m".into()]),
3867 reviewer: None,
3868 start_date: None,
3869 due_date: None,
3870 created: "2026-01-01".into(),
3871 type_: None,
3872 updated: "2026-01-01".into(),
3873 filename: String::new(),
3874 body: "body".into(),
3875 epics: None,
3876 extra_frontmatter: std::collections::BTreeMap::new(),
3877 parent: None,
3878 series: None,
3879 health: None,
3880 archived: false,
3881 entered_at: None,
3882 source: None,
3883 };
3884 let fm = issue.frontmatter();
3885 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
3886 assert_eq!(parsed.cycles.clone().unwrap(), ["A", "B"]);
3887 assert_eq!(parsed.milestones.clone().unwrap(), ["m"]);
3888 assert_eq!(fm.matches("- A").count(), 1, "{fm}");
3889 assert_eq!(fm.matches("- m").count(), 1, "{fm}");
3890 }
3891
3892 #[test]
3893 fn legacy_fixture_round_trips_with_only_the_retired_key_removed() {
3894 // Canonical fixtures captured from the real corpus before WP02
3895 // (tests/fixtures/legacy-01.md, legacy-02.md).
3896 //
3897 // Each file carries one `links: []` line, and that line is the
3898 // **approved breaking change** (C-001): the typed model replaces the
3899 // generic bag and does not re-emit the retired key. So the round trip
3900 // for these particular fixtures is deliberately not byte-identical, and
3901 // must not pretend to be. The exact, intended delta is the one removed
3902 // line and nothing else: every other byte, including the
3903 // `## Mission Progress` and `## Comments` body sections, survives.
3904 //
3905 // The fixtures carry the key **empty**, so it is not *reported* — an
3906 // empty value has nothing to lose, and reporting it made `tix lint`
3907 // reject the whole corpus. The key is still never re-emitted, which is
3908 // what this test is about, and that is unchanged.
3909 for name in ["legacy-01.md", "legacy-02.md"] {
3910 let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
3911 let original = std::fs::read_to_string(&path).unwrap();
3912 let issue = Issue::parse(&original).unwrap();
3913 assert!(
3914 !issue.has_retired_links_key,
3915 "{name} carries an *empty* retired key, so there is nothing to report"
3916 );
3917 assert!(
3918 issue.relationships.is_empty(),
3919 "C-001: the legacy value is never read as typed edges"
3920 );
3921
3922 let kept: Vec<&str> = original
3923 .lines()
3924 .filter(|line| !line.starts_with("links:"))
3925 .collect();
3926 let expected = format!("{}\n", kept.join("\n"));
3927
3928 let round = issue.to_file_contents();
3929 assert_eq!(
3930 round, expected,
3931 "{name} must differ from the legacy file by exactly the retired `links:` line"
3932 );
3933 assert!(
3934 !round.contains("links:"),
3935 "the retired key is never re-emitted"
3936 );
3937 // Byte-stable from here on: the new form round-trips exactly.
3938 assert_eq!(
3939 Issue::parse(&round).unwrap().to_file_contents(),
3940 round,
3941 "{name} must be byte-stable once in the new format"
3942 );
3943 assert!(issue.cycles.is_none() && issue.milestones.is_none());
3944 assert!(issue.reviewer.is_none());
3945 assert!(issue.start_date.is_none() && issue.due_date.is_none());
3946 }
3947 }
3948
3949 #[test]
3950 fn a_file_in_the_current_format_round_trips_byte_identically() {
3951 // NFR-003: a file the current code writes must survive read -> write
3952 // -> read with no change at all. This is the guarantee the legacy
3953 // fixtures cannot make, because they predate the format change.
3954 let original = concat!(
3955 "---\n",
3956 "number: 7\n",
3957 "title: Current\n",
3958 "status: todo\n",
3959 "labels: []\n",
3960 "priority: medium\n",
3961 "created: '2026-01-01'\n",
3962 "updated: '2026-01-01'\n",
3963 "---\n",
3964 "\n",
3965 "## History\n",
3966 "\n",
3967 "- 2026-01-02T03:04:05Z \u{2014} alice \u{2014} relationship_added \u{2014} created blocks: blocks #9 Nine\n",
3968 "\n",
3969 "Prose that follows the section.\n",
3970 );
3971 let issue = Issue::parse(original).unwrap();
3972 assert!(!issue.has_retired_links_key);
3973 assert_eq!(issue.history.len(), 1, "the History section is parsed");
3974 assert_eq!(issue.history.events()[0].actor, "alice");
3975 assert_eq!(
3976 issue.history.events()[0].kind,
3977 HistoryEventKind::RelationshipAdded
3978 );
3979 assert!(
3980 issue.body.contains("Prose that follows"),
3981 "prose after the section is preserved in the body: {:?}",
3982 issue.body
3983 );
3984
3985 let rendered = issue.to_file_contents();
3986 assert!(rendered.contains("## History"), "{rendered}");
3987 assert!(
3988 rendered.contains(" \u{2014} alice \u{2014} relationship_added \u{2014} "),
3989 "the rendered entry uses the documented spaced form: {rendered}"
3990 );
3991 // Idempotent: rendering the re-parsed form changes nothing. Asserting
3992 // idempotence rather than equality with the hand-written fixture keeps
3993 // the guarantee honest about what it covers.
3994 assert_eq!(
3995 Issue::parse(&rendered).unwrap().to_file_contents(),
3996 rendered,
3997 "a file in the current format round-trips byte-identically"
3998 );
3999 }
4000
4001 #[test]
4002 fn validate_date_accepts_real_calendar_dates() {
4003 assert!(validate_date("2026-09-18"));
4004 assert!(validate_date("1970-01-01"));
4005 assert!(validate_date("2024-02-29")); // leap year
4006 assert!(!validate_date("2023-02-29")); // not leap
4007 assert!(!validate_date("2026-02-30"));
4008 assert!(!validate_date("2026-13-01"));
4009 assert!(!validate_date("2026-00-01"));
4010 assert!(!validate_date("2026-01-00"));
4011 assert!(!validate_date("2026-1-01"));
4012 assert!(!validate_date("2026-01-1"));
4013 assert!(!validate_date("1969-12-31")); // pre-1970 rejected
4014 assert!(!validate_date("2026/01/01"));
4015 assert!(!validate_date("today"));
4016 assert_eq!(days_in_month(2024, 2), 29);
4017 assert_eq!(days_in_month(2023, 2), 28);
4018 assert_eq!(days_in_month(2026, 4), 30);
4019 assert_eq!(days_in_month(2026, 1), 31);
4020 }
4021
4022 #[test]
4023 fn lexicographic_order_equals_chronological_order() {
4024 assert!(date_lte("2026-01-05", "2026-01-12"));
4025 assert!(date_lte("2026-01-05", "2026-01-05"));
4026 assert!(!date_lte("2026-01-12", "2026-01-05"));
4027 assert!(date_lte("2025-12-31", "2026-01-01"));
4028 }
4029
4030 #[test]
4031 fn validate_issue_dates_enforces_format_and_ordering() {
4032 assert!(validate_issue_dates(None, None).is_ok());
4033 assert!(validate_issue_dates(Some("2026-09-07"), None).is_ok());
4034 assert!(validate_issue_dates(None, Some("2026-09-18")).is_ok());
4035 assert!(validate_issue_dates(Some("2026-09-07"), Some("2026-09-18")).is_ok());
4036 assert!(validate_issue_dates(Some("2026-09-18"), Some("2026-09-18")).is_ok());
4037 let e = validate_issue_dates(Some("2026-09-18"), Some("2026-09-07")).unwrap_err();
4038 assert!(e.to_string().contains("due_date"), "naming both: {e}");
4039 assert!(e.to_string().contains("start_date"), "naming both: {e}");
4040 assert!(validate_issue_dates(Some("2026-13-01"), None).is_err());
4041 assert!(validate_issue_dates(None, Some("2026-02-30")).is_err());
4042 }
4043
4044 #[test]
4045 fn malformed_planning_fields_load_without_panic() {
4046 // Storage is tolerant (T007): garbage in the new optional fields must
4047 // parse without panic and be preserved as-is on the parsed value;
4048 // validation is a separate, explicit concern.
4049 let invalid = "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nstart_date: not-a-date\ndue_date: \"2026-02-30\"\nreviewer: nobody\ncycles:\n- \"\"\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
4050 let issue = Issue::parse(invalid).unwrap();
4051 assert_eq!(issue.start_date.as_deref(), Some("not-a-date"));
4052 assert_eq!(issue.due_date.as_deref(), Some("2026-02-30"));
4053 assert_eq!(issue.reviewer.as_deref(), Some("nobody"));
4054 assert_eq!(issue.cycles.clone().unwrap(), [""]);
4055 }
4056
4057 #[test]
4058 fn set_dates_validates_before_mutating() {
4059 let mut issue = issue("body");
4060 issue
4061 .set_dates(Some("2026-09-07".into()), Some("2026-09-18".into()))
4062 .unwrap();
4063 assert_eq!(issue.start_date.as_deref(), Some("2026-09-07"));
4064 assert_eq!(issue.due_date.as_deref(), Some("2026-09-18"));
4065 let before = issue.clone();
4066 let err = issue.set_dates(Some("2026-09-18".into()), Some("2026-09-07".into()));
4067 assert!(err.is_err());
4068 assert_eq!(issue, before, "failed set_dates must not partially mutate");
4069 }
4070 // ------------------------------------------------------------------
4071 // Final-review probes, promoted to tests where they pin real behavior
4072 // ------------------------------------------------------------------
4073
4074 /// Frontmatter with one extra line inserted before the closing delimiter.
4075 fn frontmatter_with(extra: &str) -> String {
4076 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n{extra}---\n\nBody.\n")
4077 }
4078
4079 #[test]
4080 fn retired_key_detection_matches_only_the_exact_key_name() {
4081 // The detector parses the frontmatter and looks for a top-level `links`
4082 // key, because the key is not part of the model and so cannot be seen
4083 // after deserialization. A key that merely *contains* `links` is not the
4084 // retired key, and a line inside a block scalar is content, not a key.
4085 for (extra, expect) in [
4086 // Populated: reported, because the value is no longer readable.
4087 ("links:\n- '12'\n", true),
4088 ("related_links: []\n", false),
4089 ("links_count: 3\n", false),
4090 ("# links: []\n", false),
4091 // A quoted key is still the same key, so it IS the retired key —
4092 // the earlier line scan missed these entirely.
4093 ("\"links\":\n- '12'\n", true),
4094 // Empty: present, but nothing to lose, so not reported. Reporting it
4095 // rejected every issue file this repository has ever written.
4096 ("links: []\n", false),
4097 ("links: ~\n", false),
4098 ("priority: medium\n", false),
4099 // A nested `links:` is not the retired top-level key.
4100 ("meta:\n links: []\n", false),
4101 // A line inside a block scalar is content, not a key.
4102 ("notes: |\n links: see the docs\n", false),
4103 ] {
4104 let issue = Issue::parse(&frontmatter_with(extra)).unwrap();
4105 assert_eq!(
4106 issue.has_retired_links_key, expect,
4107 "frontmatter line {extra:?}"
4108 );
4109 }
4110 }
4111
4112 #[test]
4113 fn retired_key_detection_agrees_with_itself_on_the_fallback_path() {
4114 // The line-scan fallback is reachable: `serde_yaml` rejects duplicate
4115 // keys when building a `Mapping` but `Issue` tolerates a duplicate
4116 // *unknown* key, so such a file parses and lands on the fallback. Both
4117 // paths must agree that a nested `links:` is not the retired key — the
4118 // fallback used to match it at any indentation and disagree with the
4119 // primary path, so the same file was reported or not depending on which
4120 // path ran.
4121 for (extra, expect) in [
4122 ("meta:\n links: []\n", false),
4123 ("meta:\n links: []\nzzz: 1\nzzz: 2\n", false),
4124 ("links: []\nzzz: 1\nzzz: 2\n", true),
4125 ] {
4126 let issue = Issue::parse(&frontmatter_with(extra)).unwrap();
4127 assert_eq!(
4128 issue.has_retired_links_key, expect,
4129 "frontmatter {extra:?} must be judged the same way on both paths"
4130 );
4131 }
4132 }
4133
4134 #[test]
4135 fn a_history_heading_inside_prose_stays_prose() {
4136 // Only a section at the *front* of the body is the History section, the
4137 // position data-model §8 pins. A heading further down is ordinary prose
4138 // and must survive a read/write round trip untouched.
4139 let raw = "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\nProse first.\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d\n";
4140 let issue = Issue::parse(raw).unwrap();
4141 assert!(
4142 issue.history.is_empty(),
4143 "a mid-body heading is not History"
4144 );
4145 assert!(
4146 issue.body.contains("## History"),
4147 "and it stays in the body: {:?}",
4148 issue.body
4149 );
4150 // And it round-trips as prose, not as a section.
4151 let rendered = issue.to_file_contents();
4152 assert!(rendered.contains("## History"));
4153 assert_eq!(
4154 Issue::parse(&rendered).unwrap().to_file_contents(),
4155 rendered
4156 );
4157 }
4158
4159 /// The three heading cases the cycle-7 review found **unpinned**: a trailing
4160 /// tab, leading whitespace, and extra words after the heading. All three
4161 /// behave correctly — they are now pinned so the next change to the heading
4162 /// rule has to move a test rather than a comment.
4163 ///
4164 /// The pair that matter is the tab against the leading space, because they sit
4165 /// on opposite sides of the stated rule ("trailing whitespace is ignored;
4166 /// leading whitespace is not") and nothing tested it.
4167 #[test]
4168 fn the_heading_rule_ignores_trailing_whitespace_and_nothing_else() {
4169 let event = "2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d";
4170 let with = |heading: &str| {
4171 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n{heading}\n\n- {event}\n")
4172 };
4173
4174 for (heading, recognised, why) in [
4175 ("## History", true, "the exact heading"),
4176 (
4177 "## History\t",
4178 true,
4179 "a trailing tab is trailing whitespace",
4180 ),
4181 ("## History ", true, "so are trailing spaces"),
4182 (
4183 " ## History",
4184 false,
4185 "leading whitespace is explicitly not ignored",
4186 ),
4187 (
4188 "## History extra words",
4189 false,
4190 "nothing else may introduce the section",
4191 ),
4192 ] {
4193 let issue = Issue::parse(&with(heading)).unwrap();
4194 assert_eq!(issue.history.is_empty(), !recognised, "{heading:?} — {why}");
4195 if recognised {
4196 assert_eq!(issue.history.len(), 1, "{heading:?} holds one event");
4197 } else {
4198 assert!(
4199 issue.body.contains("History"),
4200 "{heading:?} stays body prose: {:?}",
4201 issue.body
4202 );
4203 }
4204 }
4205 }
4206
4207 /// The two non-recognised heading shapes above are **undiagnosed**: `tix lint`
4208 /// reports a mispositioned `## History` only when the heading matches exactly,
4209 /// so ` ## History` and `## History extra words` fall through both checks
4210 /// silently.
4211 ///
4212 /// That is defensible rather than a defect — neither is a section, so there is
4213 /// no audit trail to lose, and §8 already says a heading appearing in an
4214 /// issue's prose is ordinary text. It is pinned here so the silence is a
4215 /// recorded decision: if a future change makes either shape *recognised*, this
4216 /// test is where that has to be noticed, because `tix lint` will not notice.
4217 #[test]
4218 fn a_non_section_history_heading_is_undiagnosed() {
4219 for heading in [" ## History", "## History extra words"] {
4220 let raw =
4221 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n{heading}\n\nBody.\n");
4222 let issue = Issue::parse(&raw).unwrap();
4223 assert!(issue.history.is_empty(), "{heading:?} is not a section");
4224 // The misposition diagnostic keys on the exact heading, so it does not
4225 // fire for either of these.
4226 assert!(
4227 !is_history_heading(&format!(
4228 "{heading}
4229"
4230 )),
4231 "{heading:?} is not the History heading at all"
4232 );
4233 }
4234 }
4235
4236 /// A front-position section whose first list-shaped line is followed by prose
4237 /// retains **no** entries, and the parser must not silently delete the
4238 /// heading while demoting every line to body prose.
4239 ///
4240 /// This is #1336, found while applying the §8 corrections for #1309. The
4241 /// entries loop kept an ambiguous line when `plausible_entry ||
4242 /// after_all_entries` and broke otherwise; `- step one` is list-shaped but
4243 /// not an entry and the line after it is prose, so both disjuncts were false
4244 /// and the section ended — with the heading already consumed. `tix view`
4245 /// showed no `## History`, every line was prose, and `tix lint` was silent
4246 /// because the heading *is* at the front. An ordinary to-do bullet above
4247 /// ordinary prose silently deleted an audit trail.
4248 ///
4249 /// The fix: when nothing is retained, return the body verbatim, heading and
4250 /// all, so a parse → render → parse round trip is lossless.
4251 #[test]
4252 fn a_front_position_section_survives_when_nothing_is_retained() {
4253 let raw = "---\nnumber: 9\ntitle: T\nstatus: todo\n---\n\n## History\n\n- step one\n\nNow the real body text, with no entry shape at all.\n\n- 2026-01-01T00:00:00Z \u{2014} Bob \u{2014} transition_accepted \u{2014} todo -> done\n";
4254 let issue = Issue::parse(raw).unwrap();
4255 assert!(
4256 issue.history.is_empty(),
4257 "no entry was recognised: {:?}",
4258 issue.history
4259 );
4260 // Nothing was retained, so the section is handed back whole as body.
4261 for line in [
4262 "## History",
4263 "- step one",
4264 "Now the real body text, with no entry shape at all.",
4265 "- 2026-01-01T00:00:00Z \u{2014} Bob \u{2014} transition_accepted \u{2014} todo -> done",
4266 ] {
4267 assert!(
4268 issue.body.contains(line),
4269 "the body must keep {line:?}: {:?}",
4270 issue.body
4271 );
4272 }
4273
4274 // Round trip: render then reparse. The second parse sees the same
4275 // ambiguous section and must land on the same body, so the bytes are a
4276 // fixed point rather than a one-shot rescue.
4277 let rendered = issue.to_file_contents();
4278 assert!(
4279 rendered.contains("## History"),
4280 "the heading survives rendering:\n{rendered}"
4281 );
4282 let reparsed = Issue::parse(&rendered).unwrap();
4283 assert!(reparsed.history.is_empty());
4284 assert_eq!(reparsed.body, issue.body, "body survives the round trip");
4285 }
4286
4287 #[test]
4288 fn a_blank_actor_round_trips_because_its_position_is_fixed() {
4289 // `render` writes an empty actor field when no identity was recorded. If
4290 // `parse` demanded a non-empty field it would reject the writer's own
4291 // output — and before the parse became non-fatal, that made the whole
4292 // file, and therefore every issue in the repository, unreadable.
4293 let event = HistoryEvent::new(
4294 "2026-01-01T00:00:00Z",
4295 "",
4296 HistoryEventKind::RelationshipAdded,
4297 "d",
4298 );
4299 let line = event.render();
4300 assert!(
4301 line.contains("\u{2014} \u{2014}"),
4302 "render writes a blank, fixed-position actor field: {line}"
4303 );
4304 assert_eq!(
4305 HistoryEvent::parse(&line).expect("the writer's own output must parse"),
4306 event
4307 );
4308 }
4309
4310 #[test]
4311 fn a_blank_line_inside_the_section_does_not_end_it() {
4312 // A blank line between list entries is the most ordinary thing a human
4313 // or a Markdown formatter produces. Terminating the section there would
4314 // silently demote audit entries to body prose — they would vanish from
4315 // `history`, reappear as the issue's description, and the next append
4316 // would write *before* them, so file order would stop being append
4317 // order.
4318 let raw = concat!(
4319 "---\n",
4320 "number: 1\n",
4321 "title: T\n",
4322 "status: todo\n",
4323 "---\n",
4324 "\n",
4325 "## History\n",
4326 "\n",
4327 "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} first\n",
4328 "\n",
4329 "- 2026-01-01T00:00:01Z \u{2014} a \u{2014} relationship_added \u{2014} second\n",
4330 "\n",
4331 "Tail prose.\n",
4332 );
4333 let issue = Issue::parse(raw).unwrap();
4334 assert_eq!(
4335 issue.history.events().len(),
4336 2,
4337 "both entries are History events, not one event plus prose"
4338 );
4339 assert_eq!(issue.history.unparsed().len(), 0);
4340 assert_eq!(
4341 issue.body, "Tail prose.\n",
4342 "the prose after the section is still the body"
4343 );
4344 // Append order is preserved: a new event lands last, not first.
4345 let mut issue = issue;
4346 issue
4347 .history
4348 .append_now("a", HistoryEventKind::RelationshipRemoved, "third");
4349 let rendered = issue.to_file_contents();
4350 let first = rendered.find("first").unwrap();
4351 let second = rendered.find("second").unwrap();
4352 let third = rendered.find("third").unwrap();
4353 assert!(
4354 first < second && second < third,
4355 "file order is append order"
4356 );
4357 assert_eq!(
4358 Issue::parse(&rendered).unwrap().to_file_contents(),
4359 rendered
4360 );
4361 }
4362
4363 #[test]
4364 fn m3_body_bullets_after_the_section_stay_body() {
4365 // M3. The section has no end-of-section delimiter, and an earlier rule
4366 // accepted *any* `- ` line as an entry attempt. So ordinary prose
4367 // bullets were absorbed into the audit log: `body` became empty, the
4368 // blank line separating the section from the body was deleted on the
4369 // first write, and `tix lint` then blamed the author for "unparseable
4370 // entries" they never wrote. Trigger was a plain
4371 // `tix edit --body "- step one\n- step two"`.
4372 let raw = concat!(
4373 "---\n",
4374 "number: 1\n",
4375 "title: T\n",
4376 "status: todo\n",
4377 "---\n",
4378 "\n",
4379 "## History\n",
4380 "\n",
4381 "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d1\n",
4382 "\n",
4383 "- Reproduction: run it\n",
4384 "- Expected: it works\n",
4385 );
4386 let issue = Issue::parse(raw).unwrap();
4387 assert_eq!(issue.history.events().len(), 1, "only the real event");
4388 assert!(
4389 issue.history.unparsed().is_empty(),
4390 "prose bullets are not mistaken for entries: {:?}",
4391 issue.history.unparsed()
4392 );
4393 assert_eq!(
4394 issue.body, "- Reproduction: run it\n- Expected: it works\n",
4395 "the bullets stay body prose"
4396 );
4397 // And the write does not restructure the file.
4398 let rendered = issue.to_file_contents();
4399 assert!(rendered.contains("- Reproduction: run it"));
4400 assert_eq!(
4401 Issue::parse(&rendered).unwrap().to_file_contents(),
4402 rendered
4403 );
4404 }
4405
4406 #[test]
4407 fn m3_a_stray_line_inside_the_section_keeps_the_events_after_it() {
4408 // The mirror image of the case above, and the reason the boundary is
4409 // resolved by looking ahead rather than by shape alone. A hand-edited
4410 // non-entry line *inside* the section must not demote the real events
4411 // after it: they would vanish from the audit trail, reappear as the
4412 // issue's description, and the next append would be written *before*
4413 // them, so file order would stop being append order.
4414 let raw = concat!(
4415 "---\n",
4416 "number: 1\n",
4417 "title: T\n",
4418 "status: todo\n",
4419 "---\n",
4420 "\n",
4421 "## History\n",
4422 "\n",
4423 "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d1\n",
4424 " garbage hand edit \n",
4425 "- 2026-01-01T00:00:01Z \u{2014} a \u{2014} relationship_added \u{2014} d2\n",
4426 );
4427 let mut issue = Issue::parse(raw).unwrap();
4428 assert_eq!(
4429 issue.history.events().len(),
4430 2,
4431 "the event after the stray line is still an event"
4432 );
4433 let unparsed = issue.history.unparsed();
4434 assert_eq!(unparsed.len(), 1, "and the stray line is kept, not dropped");
4435 // Compared **raw**. The earlier version trimmed before comparing, which
4436 // is why it could not see either the injected blank line or the loss of
4437 // the leading indentation and trailing spaces — the exact properties it
4438 // claimed to check. The fixture carries both on purpose.
4439 assert_eq!(
4440 unparsed[0], " garbage hand edit ",
4441 "preserved byte-for-byte, including indentation and trailing space"
4442 );
4443 // And the render must not have inserted a blank line of its own.
4444 let rendered = issue.to_file_contents();
4445 assert!(
4446 rendered.contains(" garbage hand edit \n"),
4447 "re-emitted exactly: {:?}",
4448 rendered
4449 );
4450 assert!(
4451 !rendered.contains("garbage hand edit \n\n"),
4452 "and no blank line is injected after it: {:?}",
4453 rendered
4454 );
4455 issue
4456 .history
4457 .append_now("bob", HistoryEventKind::RelationshipAdded, "d3");
4458 let rendered = issue.to_file_contents();
4459 let d1 = rendered.find("d1").unwrap();
4460 let d2 = rendered.find("d2").unwrap();
4461 let d3 = rendered.find("d3").unwrap();
4462 assert!(d1 < d2 && d2 < d3, "file order is still append order");
4463 }
4464
4465 #[test]
4466 fn m3_a_bullet_that_looks_like_an_entry_is_recognised_for_diagnosis() {
4467 // An entry hand-typed with ASCII hyphens has no U+2014 separator, so it
4468 // cannot be recognised as an entry and must not be absorbed into the
4469 // body silently. `looks_like_history_entry` is what lets `tix lint`
4470 // report it.
4471 assert!(looks_like_history_entry(
4472 "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d"
4473 ));
4474 // Recognition requires the separator. An entry typed with ASCII hyphens
4475 // is **not** recognised, deliberately: this predicate decides whether a
4476 // line is moved into the audit log, and the looser timestamp heuristic
4477 // absorbed ordinary body to-do items.
4478 assert!(
4479 !looks_like_history_entry("- 2026-01-01T00:00:00Z - a - relationship_added - d"),
4480 "an ASCII-hyphen line is not recognised, so it stays in the body"
4481 );
4482 assert!(
4483 !looks_like_history_entry("- Reproduction: run it"),
4484 "ordinary prose is not an entry"
4485 );
4486 assert!(!looks_like_history_entry("just a paragraph"));
4487 assert!(!looks_like_history_entry("-no space after the dash"));
4488
4489 // The *reporting* predicate is separate and deliberately looser: it only
4490 // makes `tix lint` say something, it never moves a line.
4491 assert!(looks_like_an_unrecognised_entry(
4492 "- 2026-01-01T00:00:00Z - a - relationship_added - d"
4493 ));
4494 assert!(!looks_like_an_unrecognised_entry(
4495 "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d"
4496 ));
4497 assert!(
4498 !looks_like_an_unrecognised_entry("- Reproduction: run it"),
4499 "and ordinary prose is still not accused"
4500 );
4501 }
4502
4503 #[test]
4504 fn f5_every_list_marker_is_recognised_and_reported() {
4505 // Cycle-4, on the `*`/`1.` accept that cycle 3 recorded as "undiagnosed".
4506 // It was the *same* defect as F1 through a different marker: with only
4507 // `- ` treated as a list item, an entry written as
4508 // `* 2026-01-01T00:00:00Z — a — kind — d` was called prose, the section
4509 // ended, the heading was deleted, and the next append was rendered above
4510 // it. Silent, because a demoted line is body prose.
4511 //
4512 // This is not a widening of the entry grammar: `looks_like_history_entry`
4513 // still requires U+2014, so none of these can be read as a relationship.
4514 // It is about which lines get a fair hearing at end-of-section.
4515 // With the separator present but a non-`-` marker, the line is retained
4516 // verbatim and reported rather than parsed. Parsing it would re-emit it
4517 // with a `- ` marker, and rewriting the author's bytes is exactly the
4518 // auto-repair data-model §8 forbids. Retained-and-reported is the third
4519 // option between "parse and rewrite" and "demote and say nothing", and
4520 // it is the one that loses nothing.
4521 for marker in ["* ", "+ ", "1. ", "2) "] {
4522 let line = format!(
4523 "{marker}2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d"
4524 );
4525 assert!(
4526 !looks_like_history_entry(&line),
4527 "{marker:?} is not the canonical form, so it is not an event"
4528 );
4529 let raw =
4530 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{line}\n");
4531 let issue = Issue::parse(&raw).unwrap();
4532 assert!(
4533 issue.history.events().is_empty(),
4534 "{marker:?}: no event is fabricated"
4535 );
4536 assert_eq!(
4537 issue.history.unparsed(),
4538 vec![line.clone()],
4539 "{marker:?}: retained byte-for-byte, marker intact"
4540 );
4541 assert_eq!(issue.body, "", "{marker:?}: and not demoted to the body");
4542 }
4543
4544 // The canonical marker with the separator is a real event, as before.
4545 let canonical =
4546 "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d";
4547 assert!(looks_like_history_entry(canonical));
4548 let canonical_raw =
4549 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{canonical}\n");
4550 assert_eq!(
4551 Issue::parse(&canonical_raw).unwrap().history.events().len(),
4552 1,
4553 "the canonical form is an event"
4554 );
4555
4556 // And every marker *without* the separator still gets a fair hearing
4557 // rather than being silently demoted.
4558 for marker in ["- ", "* ", "+ ", "1. "] {
4559 let line = format!("{marker}hand edited note");
4560 let raw =
4561 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{line}\n");
4562 let issue = Issue::parse(&raw).unwrap();
4563 assert!(
4564 issue.history.events().is_empty(),
4565 "{marker:?}: no event is fabricated"
4566 );
4567 assert_eq!(
4568 issue.history.unparsed(),
4569 vec![line.clone()],
4570 "{marker:?}: retained verbatim in the section"
4571 );
4572 assert!(
4573 issue.body.is_empty(),
4574 "{marker:?}: and not demoted into the body, got {:?}",
4575 issue.body
4576 );
4577 }
4578
4579 // The "and reported" half of this test's name, which the retention
4580 // assertions above did not check. Reporting for a line *inside* a section
4581 // goes through the unparsed loop; reporting for one in the *body* goes
4582 // through `looks_like_an_unrecognised_entry`. Both are asserted here so
4583 // the name is earned — cycle 5 found the retention half asserted and the
4584 // reporting half assumed.
4585 for marker in ["- ", "* ", "+ ", "1. "] {
4586 let line = format!("{marker}2026-01-01T00:00:00Z - alice - relationship_added - d");
4587 assert!(
4588 looks_like_an_unrecognised_entry(&line),
4589 "{marker:?}: reported as a mistyped entry"
4590 );
4591 // And the same line with a *different* marker shape is not, so the
4592 // assertion above is discriminating rather than trivially true.
4593 assert!(
4594 !looks_like_an_unrecognised_entry(&format!("2026. {line}")),
4595 "a bare sentence is never an entry"
4596 );
4597 }
4598
4599 // A bare paragraph at end of section is still prose. That is the other
4600 // half of the rule and the reason `is_list_item` is not simply "true".
4601 // When *nothing* is retained the heading is handed back with the body
4602 // (see `a_front_position_section_survives_when_nothing_is_retained`),
4603 // so the paragraph is body prose *after* the preserved heading rather
4604 // than the whole body.
4605 for prose in [
4606 "just a closing paragraph",
4607 "Reproduction: run it",
4608 "see the thread for context",
4609 ] {
4610 let raw =
4611 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n");
4612 let issue = Issue::parse(&raw).unwrap();
4613 assert!(issue.history.is_empty(), "{prose:?}: no section content");
4614 assert!(
4615 issue.body.contains(prose),
4616 "{prose:?}: it is body prose: {:?}",
4617 issue.body
4618 );
4619 }
4620 }
4621
4622 #[test]
4623 fn an_unparseable_entry_never_fails_the_parse() {
4624 // The single most important property of the History section: it is
4625 // advertised as human-editable, so a hand edit will eventually produce a
4626 // line this parser cannot read. That must cost the *author* a lint
4627 // finding on their own issue, not the whole repository its readability.
4628 // A recognised entry attempt whose kind is unknown: kept verbatim inside
4629 // the section, so `tix lint` can report it.
4630 let bad_kind = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d";
4631 let raw =
4632 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{bad_kind}\n");
4633 let issue = Issue::parse(&raw).expect("one bad line must not fail the file");
4634 assert!(issue.history.events().is_empty(), "no event was fabricated");
4635 assert_eq!(issue.history.unparsed(), vec![bad_kind], "kept verbatim");
4636 assert_eq!(issue.body, "", "nothing leaked into the body");
4637
4638 // An entry typed with ASCII hyphens is not *recognised*, but it is still a
4639 // list item at the end of the section, so it is retained verbatim and
4640 // reported rather than demoted. The earlier version of this test
4641 // asserted it stayed body text; cycle-4 F1 showed the alternative deletes
4642 // the `## History` heading and reorders the log around it, silently.
4643 // Retention is the disclosed failure mode, so it is the one chosen — and
4644 // `looks_like_history_entry` still returns false for it, so no event is
4645 // ever fabricated from it either.
4646 let ascii = "- 2026-01-01T00:00:00Z - alice - relationship_added - ascii";
4647 let raw2 =
4648 format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{ascii}\n");
4649 let issue2 = Issue::parse(&raw2).expect("and it must not fail the file either");
4650 assert!(issue2.history.events().is_empty(), "no event is fabricated");
4651 assert_eq!(issue2.history.unparsed(), vec![ascii], "kept verbatim");
4652 assert_eq!(issue2.body, "", "and not demoted into the body");
4653
4654 let rendered = issue.to_file_contents();
4655 assert_eq!(
4656 Issue::parse(&rendered).unwrap().to_file_contents(),
4657 rendered,
4658 "and the file round-trips unchanged"
4659 );
4660 }
4661
4662 #[test]
4663 fn a_history_entry_keeps_an_em_dash_inside_its_detail() {
4664 // The line is split on the first three separators only, so a detail
4665 // containing an em dash survives rather than being truncated.
4666 let raw = "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} note \u{2014} dash inside\n\nTail.\n";
4667 let issue = Issue::parse(raw).unwrap();
4668 assert_eq!(issue.history.len(), 1);
4669 let event = &issue.history.events()[0];
4670 assert_eq!(event.timestamp, "2026-01-01T00:00:00Z");
4671 assert_eq!(event.actor, "alice");
4672 assert_eq!(event.kind, HistoryEventKind::RelationshipAdded);
4673 assert_eq!(event.detail, "note \u{2014} dash inside");
4674 // Idempotence rather than equality with the hand-written input: the
4675 // input omits keys serde always emits, so only the rendered form is a
4676 // meaningful byte-stability assertion.
4677 let rendered = issue.to_file_contents();
4678 assert_eq!(
4679 Issue::parse(&rendered).unwrap().to_file_contents(),
4680 rendered,
4681 "the entry round-trips byte-identically"
4682 );
4683 assert_eq!(
4684 Issue::parse(&rendered).unwrap().history.events()[0].detail,
4685 "note \u{2014} dash inside",
4686 "and the detail is stable across the round trip"
4687 );
4688 }
4689
4690 // -----------------------------------------------------------------------
4691 // WP02 / T006 + T007 — the `parent` field and its three History kinds
4692 // -----------------------------------------------------------------------
4693 //
4694 // Requirement ids in the test names: C-001 (at most one parent), C-003 (no
4695 // child data on the parent), C-004 (every change audited), C-007 (a parent is
4696 // not a dependency edge), SC-004 (a rejected change writes nothing).
4697
4698 /// A file body, so a test's only variable is the frontmatter under test.
4699 fn wp02_file(fm: &str) -> String {
4700 format!("---\n{fm}---\n\nBody text.")
4701 }
4702
4703 /// The frontmatter every legacy file in the repository carries: no `parent:`
4704 /// key at all. This is the pinned pre-mission byte form.
4705 ///
4706 /// In the **canonical rendered form**, not a merely plausible one. The writer
4707 /// unquotes strings and always emits `files: []`, so a hand-written fixture
4708 /// with quotes in it is not byte-stable for reasons that have nothing to do
4709 /// with `parent` — the first version of these tests failed for exactly that
4710 /// reason and was measuring the renderer rather than the field.
4711 const WP02_LEGACY_FM: &str = "number: 3\ntitle: Legacy issue\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n";
4712
4713 /// The same file, with a parent, canonical.
4714 const WP02_PARENT_FM: &str = "number: 3\ntitle: Child\nstatus: todo\nlabels: []\npriority: medium\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n";
4715
4716 #[test]
4717 fn t006_a_file_with_a_parent_round_trips_byte_stably() {
4718 let original = wp02_file(WP02_PARENT_FM);
4719 let issue = Issue::parse(&original).expect("a parent parses");
4720 assert_eq!(issue.parent, Some(7), "the parent number is read as data");
4721
4722 // load -> save -> load, byte-identical at every hop.
4723 let once = issue.to_file_contents();
4724 assert_eq!(
4725 once, original,
4726 "the parent key is written where it was read"
4727 );
4728 let twice = Issue::parse(&once).unwrap().to_file_contents();
4729 assert_eq!(twice, once, "a second round trip changes nothing");
4730 }
4731
4732 #[test]
4733 fn t006_a_legacy_file_with_no_parent_key_serialises_to_its_pre_mission_bytes() {
4734 // The pinned legacy gate. If this fails, the field is leaking into files
4735 // that never had one.
4736 let original = wp02_file(WP02_LEGACY_FM);
4737 let issue = Issue::parse(&original).unwrap();
4738 assert_eq!(issue.parent, None, "an absent key is no parent");
4739 let rendered = issue.to_file_contents();
4740 assert_eq!(rendered, original);
4741 assert!(
4742 !rendered.contains("parent:"),
4743 "a file that never had a parent must not grow a `parent:` key"
4744 );
4745 assert_eq!(
4746 Issue::parse(&rendered).unwrap().to_file_contents(),
4747 rendered
4748 );
4749 }
4750
4751 #[test]
4752 fn t006_the_parent_key_is_written_exactly_once() {
4753 // Regression: `parent` was added to [`Issue`] but not to
4754 // [`KNOWN_FRONTMATTER_KEYS`], so `capture_extra_frontmatter` also took it
4755 // for an unknown key and the writer emitted the key twice in one file. The
4756 // file still parsed. A count assertion is what notices.
4757 let rendered = Issue::parse(&wp02_file(WP02_PARENT_FM))
4758 .unwrap()
4759 .to_file_contents();
4760 assert_eq!(
4761 rendered.matches("\nparent:").count(),
4762 1,
4763 "`parent:` must be written once, got:\n{rendered}"
4764 );
4765 let reparsed = Issue::parse(&rendered).unwrap();
4766 assert_eq!(reparsed.to_file_contents(), rendered);
4767 }
4768
4769 #[test]
4770 fn known_frontmatter_keys_cover_every_written_key() {
4771 // The guard for `KNOWN_FRONTMATTER_KEYS`, which is a hand-maintained mirror
4772 // of the frontmatter field set — no macro can read a struct's own field
4773 // names. Both of its failure modes are silent, and in opposite directions:
4774 //
4775 // * a key **listed but not modelled** is dropped on write (data loss, the
4776 // bug #1361 exists to remove);
4777 // * a key **modelled but not listed** is also captured as unknown and emitted
4778 // a *second* time, so the file carries the same key twice. That is what
4779 // `parent` did.
4780 //
4781 // The first version of this test was a one-directional subset check with a
4782 // fixture that left `relationships`, `cycles` and `milestones` unpopulated.
4783 // Deleting `"relationships"` from the list left it **green** while the
4784 // double-write defect was live, and so did adding a brand-new unregistered
4785 // field — both measured and confirmed. A test that cannot go red in the
4786 // states it claims to rule out is worse than none, because it is read as
4787 // proof.
4788 //
4789 // So this asserts **both directions**, populates every optional field so
4790 // the key set is complete, and adds two direct checks that do not consult
4791 // the list at all — the symptom, not the cause.
4792 let mut issue = Issue::parse(&wp02_file(
4793 "number: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n",
4794 ))
4795 .unwrap();
4796 issue.id = Some("A1B2C3D4".into());
4797 issue.assignee = Some("@alice".into());
4798 issue.estimate = Some("1h".into());
4799 issue.commit = Some("abc1234".into());
4800 issue.gitlab_id = Some("7".into());
4801 issue.linear_id = Some("9".into());
4802 issue.mission = Some("m".into());
4803 issue.task = Some("T001".into());
4804 issue.reviewer = Some("@bob".into());
4805 issue.start_date = Some("2026-01-01".into());
4806 issue.due_date = Some("2026-02-01".into());
4807 issue.entered_at = Some("2026-03-01T10:00:00Z".into());
4808 issue.source = Some("import".into());
4809 issue.parent = Some(2);
4810 issue.cycles = Some(vec!["A".into()]);
4811 issue.milestones = Some(vec!["m".into()]);
4812 // WP03: the project association list, populated so the key set is complete.
4813 issue.projects = Some(vec!["p-1".into()]);
4814 // WP03 of `epics-work-items-01M3BYW4`: the epic reference list, populated
4815 // so the key set is complete (and so this guard goes red if a later field
4816 // is added without registering its key).
4817 issue.epics = Some(vec!["e-7c4a91".into()]);
4818 issue.files = vec!["docs/a.md".into()];
4819 issue.labels = vec!["one".into(), "two".into()];
4820 issue.relationships = vec![Relationship::new(1, 2, RelationKind::Blocks, "T")];
4821 issue.related_issues = vec!["#1".into()];
4822 issue.type_ = Some("bug".into());
4823 // WP02 of `project-issue-health-status-01M3C8HX`: the health projection,
4824 // populated so the key set stays complete (and so this guard goes red if a
4825 // later field is added without registering its key).
4826 issue.health = Some(HealthRef {
4827 state: Some("on_track".into()),
4828 source: Some("current".into()),
4829 policy_version: Some("health-v1".into()),
4830 updated_at: Some("2026-03-01T10:00:00Z".into()),
4831 });
4832 // WP02: the recurrence table, populated so the key set stays complete.
4833 issue.series = Some(SeriesRef {
4834 id: "s-abc".into(),
4835 name: Some("Nightly".into()),
4836 rule: Some(SeriesRule {
4837 kind: Some("daily".into()),
4838 anchor: Some("2026-01-01".into()),
4839 ..SeriesRule::default()
4840 }),
4841 ..SeriesRef::default()
4842 });
4843
4844 // WP01 of `per-issue-time-tracking-01M3C9A4`: the current time entries,
4845 // populated so this fixture exercises every known key (and so the guard
4846 // goes red if the registration is ever dropped).
4847 issue.time_entries = vec![TimeEntry {
4848 id: "e-1a2b3c4d".into(),
4849 duration_minutes: Some(90),
4850 date: Some("2026-03-01".into()),
4851 author: "@alice".into(),
4852 note: Some("reviewed the parser".into()),
4853 extensions: TimeEntryExtensions::default(),
4854 }];
4855
4856 let frontmatter = issue.frontmatter();
4857 let written: Vec<String> = frontmatter.lines().filter_map(top_level_key).collect();
4858
4859 // -- The direct checks first ------------------------------------------
4860 //
4861 // These do not consult `KNOWN_FRONTMATTER_KEYS` at all, so they hold
4862 // whatever the list says, and they are what actually catches the defect:
4863 // the symptom rather than the cause. Ordered first so a regression reports
4864 // the real problem instead of a completeness count.
4865 //
4866 // A fully-populated canonical file must have **no unknown keys**. A key the
4867 // model owns but the list omits is captured as unknown and will be emitted
4868 // a second time.
4869 assert_eq!(
4870 capture_extra_frontmatter(&frontmatter),
4871 BTreeMap::new(),
4872 "a fully-populated canonical file must have NO unknown keys: a key the \
4873 model owns is being captured as unknown and will be emitted twice"
4874 );
4875
4876 // And no top-level key may appear twice in a rendered file. This is the
4877 // defect stated as an assertion, with nothing to maintain but the writer.
4878 let mut sorted = written.clone();
4879 sorted.sort();
4880 let before = sorted.len();
4881 sorted.dedup();
4882 assert_eq!(
4883 before,
4884 sorted.len(),
4885 "a key is written more than once in:\n{frontmatter}"
4886 );
4887
4888 // -- Direction 1: every written key is registered -----------------------
4889 for key in &written {
4890 assert!(
4891 KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()),
4892 "`{key}` is written by the model but absent from KNOWN_FRONTMATTER_KEYS, \
4893 so capture_extra_frontmatter treats it as unknown and the writer emits \
4894 it twice. Add it to the list."
4895 );
4896 }
4897
4898 // -- Direction 2: every registered key is one the model can write -------
4899 //
4900 // The opposite silent failure: a key listed but not modelled is dropped on
4901 // write, which is data loss.
4902 for key in KNOWN_FRONTMATTER_KEYS {
4903 if *key == "links" {
4904 continue; // Recognised but never re-emitted, by design (that C-001).
4905 }
4906 assert!(
4907 written.iter().any(|k| k == key),
4908 "`{key}` is listed as known but no field writes it, so the entry is \
4909 wrong in the other direction: a key wrongly listed as known is \
4910 dropped on write. Either model it or remove it from the list."
4911 );
4912 }
4913
4914 // -- Completeness floor -------------------------------------------------
4915 //
4916 // Last, because it is a statement about the *fixture*, not about the code:
4917 // if a field is added and this test stops populating it, the two assertions
4918 // above silently cover less. Asserted so that cannot pass unnoticed.
4919 assert_eq!(
4920 written.len(),
4921 KNOWN_FRONTMATTER_KEYS.len() - 1,
4922 "the fixture must exercise every known key except the retired one; \
4923 got {written:?} against {KNOWN_FRONTMATTER_KEYS:?}"
4924 );
4925
4926 for key in KNOWN_FRONTMATTER_KEYS {
4927 assert!(
4928 !key.is_empty() && !key.contains(char::is_whitespace),
4929 "`{key}` is not a usable top-level key"
4930 );
4931 }
4932 }
4933
4934 #[test]
4935 fn t006_an_explicitly_null_parent_is_the_absent_state() {
4936 // `parent:` with no value is the "no parent" state. It is dropped on
4937 // write, matching every other optional field in this frontmatter
4938 // (`assignee:`, `commit:`). Pinned rather than assumed, because the
4939 // legacy gate above is what would catch the blast radius if it changed.
4940 let issue =
4941 Issue::parse(&wp02_file("number: 3\ntitle: T\nstatus: todo\nparent:\n")).unwrap();
4942 assert_eq!(issue.parent, None);
4943 assert!(!issue.to_file_contents().contains("parent:"));
4944 }
4945
4946 #[test]
4947 fn t006_an_empty_string_parent_is_rejected_rather_than_read_as_a_reference() {
4948 // A YAML `""` is a *string*, not a number. Coercing it to `None` would
4949 // erase a real reference that a template or an editor emptied by
4950 // mistake, and the file would then read as a deliberate "no parent".
4951 let err = Issue::parse(&wp02_file("number: 3\ntitle: T\nparent: \"\"\n"))
4952 .expect_err("an empty-string parent is malformed, not a reference");
4953 assert!(
4954 err.to_string().contains("parent"),
4955 "the error must name the offending field, got: {err}"
4956 );
4957 }
4958
4959 #[test]
4960 fn t006_a_malformed_parent_value_is_rejected() {
4961 // The spellings that are genuinely rejected. Every one of these would be a
4962 // reference the user did not write if the parser were lenient about them.
4963 for (label, value) in [
4964 ("an empty string", "\"\""),
4965 ("a single-quoted number", "'7'"),
4966 ("a zero-padded number", "007"),
4967 ("an underscore-separated number", "1_000"),
4968 ("a float", "7.5"),
4969 ("a float written as an integer", "7.0"),
4970 ("scientific notation", "1e2"),
4971 ("a list", "[7]"),
4972 ("a map", "{a: 1}"),
4973 ("a bool", "true"),
4974 ("a negative", "-1"),
4975 ("a value beyond u32", "4294967296"),
4976 ] {
4977 let parsed = Issue::parse(&wp02_file(&format!(
4978 "number: 3\ntitle: T\nparent: {value}\n"
4979 )));
4980 assert!(
4981 parsed.is_err(),
4982 "{label} (`parent: {value}`) must be rejected, got {parsed:?}"
4983 );
4984 }
4985 }
4986
4987 #[test]
4988 fn t006_the_accepted_scalar_forms_are_pinned_because_they_are_surprising() {
4989 // An earlier version of the malformed-value test claimed "none may become
4990 // `Some(_)`" and listed seven spellings. It was **false**: measured, five
4991 // more scalar forms are read as numbers.
4992 //
4993 // Pinned here rather than left unstated, so the behaviour is a recorded
4994 // decision instead of a YAML accident. Whether these should be *accepted*
4995 // is an open question (filed as #1372): tightening the accepted set is a
4996 // parse-semantics change and belongs in a contract clause, not in a test
4997 // edit made quietly inside WP02.
4998 //
4999 // `!!str 7` is the sharp one. A value explicitly tagged as a *string* is read
5000 // as a number — the same class the empty-string test refuses. `0x1F`, `0o17`
5001 // and `0b111` are alternative radices, and `+7` is a leading sign.
5002 //
5003 // `parent: 0` deserves a note, because an earlier version of this comment
5004 // claimed "`0` is accepted although issue numbers start at 1" and a later
5005 // one said the reverse. The history: the claim was *wrong* when written —
5006 // `Store::resolve_next_number` returned `Ok(0)` for an empty directory, so
5007 // the first issue in every fresh repository was `#0`. Found by running the
5008 // binary, not by reading the code (tix #1376).
5009 //
5010 // `resolve_next_number` now starts at `1`, so the original comment is
5011 // finally accurate. **`0` still parses, and must.** The parser accepts any
5012 // `u32`; rejecting `0` here would contradict it, and an existing repository
5013 // can already contain an issue numbered `0` (this one does — it was written
5014 // before the fix, and renumbering it is a migration). Rejecting the value
5015 // would also refuse to *read* such a file, turning a numbering convention
5016 // into data loss.
5017 //
5018 // The general rule this test pins: the parser is not the allocator. What
5019 // `resolve_next_number` may *produce* is a separate question with a
5020 // separate answer, and this list asserts only that both `0` and `7` are
5021 // numbers.
5022 for (value, expected) in [
5023 ("0x1F", 31),
5024 ("0o17", 15),
5025 ("0b111", 7),
5026 ("+7", 7),
5027 ("!!str 7", 7),
5028 ("0", 0),
5029 ] {
5030 let issue = Issue::parse(&wp02_file(&format!(
5031 "number: 3\ntitle: T\nparent: {value}\n"
5032 )))
5033 .unwrap_or_else(|e| panic!("`parent: {value}` is expected to parse: {e}"));
5034 assert_eq!(
5035 issue.parent,
5036 Some(expected),
5037 "`parent: {value}` is pinned to {expected}"
5038 );
5039 }
5040
5041 // And the two spellings of "no parent" that are not a null literal.
5042 for value in ["null", "~"] {
5043 let issue = Issue::parse(&wp02_file(&format!(
5044 "number: 3\ntitle: T\nparent: {value}\n"
5045 )))
5046 .unwrap();
5047 assert_eq!(issue.parent, None, "`parent: {value}` is the absent state");
5048 }
5049 }
5050
5051 #[test]
5052 fn t006_at_most_one_parent_is_expressible_because_the_field_is_not_a_list() {
5053 // C-001 is a *shape* guarantee, so it is asserted as a shape: a second
5054 // `parent:` key in the file is a duplicate-mapping error, not a silent
5055 // last-one-wins.
5056 let err = Issue::parse(&wp02_file("number: 3\ntitle: T\nparent: 7\nparent: 8\n"))
5057 .expect_err("two parents are not representable");
5058 assert!(err.to_string().contains("parent"), "got: {err}");
5059 }
5060
5061 #[test]
5062 fn t006_the_parent_key_is_distinct_from_mission_and_task() {
5063 // C-007 / research R2: a decomposition parent is not the Spec Kitty
5064 // governance pointer, and not a typed dependency edge. Asserted on the
5065 // *rendered keys*, not the struct fields, because only the keys are the
5066 // contract.
5067 let mut issue = Issue::parse(&wp02_file(
5068 "number: 3\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nmission: some-mission\ntask: T003\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n",
5069 ))
5070 .unwrap();
5071 // Set after parsing, because a typed relationship carries a generated id
5072 // that a hand-written fixture cannot supply. Without this the rendered file
5073 // has no `relationships:` key at all — an empty list is skipped — and the
5074 // assertion below would pass for the wrong reason.
5075 issue.relationships = vec![Relationship::new(3, 8, RelationKind::Blocks, "Eight")];
5076 assert!(
5077 issue.relationships.len() == 1,
5078 "the fixture must carry a relationship under test"
5079 );
5080 let fm = issue.frontmatter();
5081 // `relationships:` is in this list on purpose. WP02's own review guidance
5082 // asks whether the parent key is distinct from `mission`, `task` **and the
5083 // typed relationship key**; the first version of this test named only the
5084 // first two.
5085 for key in ["mission:", "task:", "relationships:", "parent:"] {
5086 assert_eq!(
5087 fm.matches(key).count(),
5088 1,
5089 "`{key}` must appear exactly once, got:\n{fm}"
5090 );
5091 }
5092 assert_eq!(issue.mission.as_deref(), Some("some-mission"));
5093 assert_eq!(issue.task.as_deref(), Some("T003"));
5094 assert_eq!(issue.parent, Some(7));
5095
5096 // And clearing one leaves the others alone: they are not aliases.
5097 issue.parent = None;
5098 let fm = issue.frontmatter();
5099 assert!(!fm.contains("parent:"));
5100 assert!(fm.contains("mission: some-mission"));
5101 assert!(fm.contains("task: T003"));
5102 }
5103
5104 #[test]
5105 fn t007_the_three_parent_event_kinds_round_trip_through_the_file() {
5106 // C-004: the audit trail is only an audit trail if the kinds survive it.
5107 for (kind, spelling) in [
5108 (HistoryEventKind::ParentSet, "parent_set"),
5109 (HistoryEventKind::ParentChanged, "parent_changed"),
5110 (HistoryEventKind::ParentCleared, "parent_cleared"),
5111 ] {
5112 assert_eq!(kind.as_str(), spelling);
5113 assert_eq!(
5114 HistoryEventKind::from_str(spelling).unwrap(),
5115 kind,
5116 "`{spelling}` must parse back to the same kind"
5117 );
5118 }
5119 // An unknown kind is rejected rather than coerced (this module's own
5120 // rule), so a typo in a hand-edited log cannot be quietly reclassified.
5121 assert!(HistoryEventKind::from_str("parent_changed_to").is_err());
5122
5123 // Pin the *serde* spelling too. The enum derives `Serialize`/`Deserialize`
5124 // under `rename_all = "snake_case"` **and** carries `as_str`/`from_str`, so
5125 // there are two independent sources of the wire spelling and nothing tied
5126 // them together. `History` is `#[serde(skip)]` on `Issue`, so the derive is
5127 // currently unused on the file path — which is exactly why it could drift
5128 // unnoticed if the history ever became serialised.
5129 for (kind, spelling) in [
5130 (HistoryEventKind::ParentSet, "parent_set"),
5131 (HistoryEventKind::ParentChanged, "parent_changed"),
5132 (HistoryEventKind::ParentCleared, "parent_cleared"),
5133 ] {
5134 let yaml = serde_yaml::to_string(&kind).unwrap();
5135 assert_eq!(
5136 yaml.trim(),
5137 spelling,
5138 "the derived serde spelling must match `as_str`"
5139 );
5140 let back: HistoryEventKind = serde_yaml::from_str(&yaml).unwrap();
5141 assert_eq!(back, kind, "`{spelling}` must round trip through serde");
5142 }
5143 }
5144
5145 #[test]
5146 fn t007_parent_events_survive_a_history_section_round_trip() {
5147 let original = "---\nnumber: 3\ntitle: Child\nstatus: todo\nlabels: []\npriority: medium\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-02T03:04:05Z \u{2014} alice \u{2014} parent_set \u{2014} parent none -> #7\n\nProse after the section.\n";
5148 let issue = Issue::parse(original).unwrap();
5149 assert_eq!(issue.history.len(), 1);
5150 assert_eq!(issue.history.events()[0].kind, HistoryEventKind::ParentSet);
5151 assert_eq!(issue.to_file_contents(), original, "byte-stable");
5152 }
5153
5154 #[test]
5155 fn t007_issue_view_carries_the_parent_so_both_services_read_one_projection() {
5156 // The hierarchy service and the relationship service must not each build
5157 // their own view of an issue, or they can disagree about the same
5158 // snapshot.
5159 let issue = Issue::parse(&wp02_file(WP02_PARENT_FM)).unwrap();
5160 let view = issue.issue_view();
5161 assert_eq!(view.parent, Some(7));
5162 assert_eq!(
5163 crate::hierarchy::HierarchyNode::parent_number(&view),
5164 Some(7)
5165 );
5166 }
5167
5168 // ------------------------------------------------------------------
5169 // WP03 T012 — the TransitionDecision record and its detail grammar
5170 // ------------------------------------------------------------------
5171
5172 fn a_decision() -> TransitionDecision {
5173 TransitionDecision::new(
5174 "2026-09-29T12:00:00Z",
5175 "Herbert Maschke <thyseus@pm.me>",
5176 "todo",
5177 "in progress",
5178 "unstarted",
5179 "active",
5180 None,
5181 )
5182 }
5183
5184 #[test]
5185 fn t012_the_kind_spells_parses_and_renders() {
5186 assert_eq!(
5187 HistoryEventKind::TransitionAccepted.as_str(),
5188 "transition_accepted"
5189 );
5190 assert_eq!(
5191 "transition_accepted".parse::<HistoryEventKind>().unwrap(),
5192 HistoryEventKind::TransitionAccepted
5193 );
5194 assert_eq!(
5195 HistoryEventKind::TransitionAccepted.to_string(),
5196 "transition_accepted"
5197 );
5198 }
5199
5200 #[test]
5201 fn t012_an_unknown_kind_is_rejected_rather_than_coerced() {
5202 // T012 step 6. A silently reclassified audit entry is worse than a parse
5203 // failure, so this is the same contract the other kinds have — pinned
5204 // because the new kind is the newest addition to a closed set and the set
5205 // is exactly where a `catch-all => Ok(..)` would be introduced.
5206 let err = "transition_attempted"
5207 .parse::<HistoryEventKind>()
5208 .unwrap_err();
5209 assert!(
5210 err.to_string().contains("unknown History event kind"),
5211 "{err}"
5212 );
5213 }
5214
5215 #[test]
5216 fn t012_the_detail_grammar_renders_and_recovers_every_field() {
5217 let decision = a_decision();
5218 assert_eq!(
5219 decision.detail(),
5220 "todo -> in progress (unstarted -> active)",
5221 "the rendered form is the contract, so it is asserted literally"
5222 );
5223 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5224 assert_eq!(recovered, decision, "a lossless round trip");
5225 }
5226
5227 #[test]
5228 fn t012_an_override_reason_round_trips() {
5229 let mut decision = a_decision();
5230 decision.override_reason = Some("reopening: filed against the wrong epic".into());
5231 assert_eq!(
5232 decision.detail(),
5233 "todo -> in progress (unstarted -> active); override: reopening: filed \
5234 against the wrong epic"
5235 );
5236 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5237 assert_eq!(
5238 recovered.override_reason.as_deref(),
5239 Some("reopening: filed against the wrong epic")
5240 );
5241 }
5242
5243 #[test]
5244 fn t012_a_reason_containing_the_bare_marker_survives() {
5245 // The reason is split off at the first `") ; override: "`, so a reason may
5246 // contain the bare `"; override: "` without being truncated. This is the
5247 // test that caught the first implementation, which used `rsplit_once` on
5248 // the *bare* marker: the last occurrence inside the reason is the one that
5249 // split, leaving a head with no closing paren and failing to parse at all.
5250 let mut decision = a_decision();
5251 decision.override_reason = Some("typo; override: actually the right one".into());
5252 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5253 assert_eq!(
5254 recovered.override_reason.as_deref(),
5255 Some("typo; override: actually the right one")
5256 );
5257 assert_eq!(recovered.to, "in progress", "and the fields still line up");
5258 }
5259
5260 #[test]
5261 fn t012_a_reason_containing_the_anchored_marker_survives() {
5262 // The discriminating test. `split_once` and `rsplit_once` agree on every
5263 // other input — the anchored marker appears exactly once unless the reason
5264 // contains it — so nothing else in this module can tell them apart, and the
5265 // first version of `t012_a_reason_containing_the_bare_marker_survives`
5266 // passed against both. This one cannot.
5267 let mut decision = a_decision();
5268 decision.override_reason = Some("quoting it: ); override: and again".into());
5269 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5270 assert_eq!(
5271 recovered.override_reason.as_deref(),
5272 Some("quoting it: ); override: and again"),
5273 "the FIRST anchored marker is the split point, and the reason is the rest"
5274 );
5275 assert_eq!(recovered.from, "todo");
5276 assert_eq!(recovered.to, "in progress");
5277 }
5278
5279 #[test]
5280 fn t012_a_reason_containing_an_arrow_survives() {
5281 let mut decision = a_decision();
5282 decision.override_reason = Some("note: a -> b was meant to be a -> c".into());
5283 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5284 assert_eq!(
5285 recovered.override_reason.as_deref(),
5286 Some("note: a -> b was meant to be a -> c")
5287 );
5288 assert_eq!(
5289 recovered.from, "todo",
5290 "the head is unaffected by the reason"
5291 );
5292 assert_eq!(recovered.to, "in progress");
5293 }
5294
5295 #[test]
5296 fn t012_a_blank_reason_renders_as_no_marker_at_all() {
5297 // A blank reason must not come back as `Some("")`, which would make
5298 // `from_event` and `detail()` disagree about whether an override exists.
5299 let mut decision = a_decision();
5300 decision.override_reason = Some(" ".into());
5301 assert_eq!(
5302 decision.detail(),
5303 "todo -> in progress (unstarted -> active)"
5304 );
5305 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5306 assert!(recovered.override_reason.is_none());
5307 }
5308
5309 #[test]
5310 fn t012_a_column_named_with_an_arrow_is_a_documented_limit_not_a_silent_mis_split() {
5311 // WP03's doc comment originally claimed "a column named `a -> b` cannot
5312 // desynchronise the fields". It **can**, and this test is why that claim
5313 // was struck: the grammar is delimiter-based with no escaping, so `from`
5314 // parses as `"a"`. The limit is inherent — any unescaped separator has a
5315 // name it cannot represent — and is now documented on the type.
5316 //
5317 // What matters downstream is pinned here: the **categories** still parse
5318 // correctly, because they come from the bracketed tail rather than from
5319 // the arrow-separated head.
5320 let mut decision = a_decision();
5321 decision.from = "a -> b".into();
5322 decision.to = "in progress".into();
5323 let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
5324 assert_eq!(recovered.from, "a", "the documented mis-split, pinned");
5325 assert_eq!(recovered.to_category, "active", "categories stay intact");
5326 assert_eq!(recovered.from_category, "unstarted");
5327 }
5328
5329 #[test]
5330 fn t012_a_malformed_detail_is_an_error_not_a_partial_record() {
5331 for bad in [
5332 "no arrows here",
5333 "todo -> in progress", // no bracketed categories
5334 "todo -> in progress (unstarted)", // no category arrow
5335 "todo -> in progress (unstarted -> active", // no closing paren
5336 " -> in progress (unstarted -> active)", // empty from
5337 ] {
5338 let event = HistoryEvent::new(
5339 "2026-09-29T12:00:00Z",
5340 "alice",
5341 HistoryEventKind::TransitionAccepted,
5342 bad,
5343 );
5344 assert!(
5345 TransitionDecision::from_event(&event).is_err(),
5346 "`{bad}` must not parse into a partial record"
5347 );
5348 }
5349 }
5350
5351 #[test]
5352 fn t012_from_event_refuses_a_different_kind() {
5353 let event = HistoryEvent::new(
5354 "2026-09-29T12:00:00Z",
5355 "alice",
5356 HistoryEventKind::ParentSet,
5357 "todo -> in progress (unstarted -> active)",
5358 );
5359 let err = TransitionDecision::from_event(&event).unwrap_err();
5360 assert!(
5361 err.to_string().contains("not a `transition_accepted`"),
5362 "a parent event is not a transition, however well-formed its detail: {err}"
5363 );
5364 }
5365
5366 #[test]
5367 fn t012_the_decision_survives_a_full_file_round_trip_byte_for_byte() {
5368 let mut decision = a_decision();
5369 decision.override_reason = Some("reopening".into());
5370 const FILE: &str = "---\nnumber: 1\ntitle: \"T\"\nstatus: \"in progress\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.";
5371 let mut issue = Issue::parse(FILE).unwrap();
5372 issue.history.append(decision.to_event());
5373 let rendered = issue.to_file_contents();
5374 let reparsed = Issue::parse(&rendered).unwrap();
5375 assert_eq!(
5376 reparsed.to_file_contents(),
5377 rendered,
5378 "byte-stable through the file, like every other History kind"
5379 );
5380 let events = reparsed.history.events();
5381 assert_eq!(events.len(), 1);
5382 assert_eq!(
5383 TransitionDecision::from_event(events[0]).unwrap(),
5384 decision,
5385 "and every field survives the file, not just the rendered text"
5386 );
5387 }
5388
5389 #[test]
5390 fn t012_a_same_column_record_is_recognisable_as_a_no_op() {
5391 // A hand-edited file can contain one of these even though the seam never
5392 // writes one; WP06 must be able to tell that apart from a real move.
5393 let mut decision = a_decision();
5394 decision.to = "todo".into();
5395 assert!(decision.is_no_op());
5396 let mut real = decision.clone();
5397 real.to = "in progress".into();
5398 assert!(!real.is_no_op());
5399 }
5400
5401 // ------------------------------------------------------------------
5402 // WP03 — project associations in the model (mission
5403 // first-class-projects-01M3BYBW)
5404 // ------------------------------------------------------------------
5405
5406 /// A minimal issue file whose only interesting key is `projects`.
5407 fn project_file(projects_block: &str) -> String {
5408 format!(
5409 "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n{projects_block}created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
5410 )
5411 }
5412
5413 #[test]
5414 fn t011_projects_is_an_optional_id_list_that_round_trips_two_associations() {
5415 // C-003: multiple membership, shaped like `cycles`/`milestones`.
5416 let original = project_file("projects:\n- p-3f9a2c\n- p-7b1e04\n");
5417 let issue = Issue::parse(&original).unwrap();
5418 assert_eq!(
5419 issue.projects,
5420 Some(vec!["p-3f9a2c".to_string(), "p-7b1e04".to_string()]),
5421 "the field holds several project ids"
5422 );
5423 let rendered = issue.to_file_contents();
5424 assert_eq!(
5425 rendered, original,
5426 "two associations round-trip byte-stably"
5427 );
5428 assert_eq!(
5429 Issue::parse(&rendered).unwrap().projects,
5430 issue.projects,
5431 "and survive a second read"
5432 );
5433 }
5434
5435 #[test]
5436 fn t011_an_issue_with_no_projects_serializes_with_no_key() {
5437 let no_key = project_file("");
5438 assert!(!no_key.contains("projects:"));
5439 let issue = Issue::parse(&no_key).unwrap();
5440 assert_eq!(issue.projects, None, "an absent key reads as absent");
5441 let rendered = issue.to_file_contents();
5442 assert_eq!(
5443 rendered, no_key,
5444 "a file that never had the field is untouched by a round trip"
5445 );
5446 assert!(!rendered.contains("projects:"));
5447 }
5448
5449 #[test]
5450 fn t011_an_empty_projects_list_is_written_as_absent() {
5451 let issue = Issue::parse(&project_file("projects: []\n")).unwrap();
5452 let rendered = issue.to_file_contents();
5453 assert!(
5454 !rendered.contains("projects:"),
5455 "the key is omitted when empty, exactly like cycles/milestones:\n{rendered}"
5456 );
5457 assert_eq!(Issue::parse(&rendered).unwrap().projects, None);
5458 }
5459
5460 #[test]
5461 fn t011_a_malformed_projects_value_is_rejected_not_coerced() {
5462 // A scalar where a list belongs.
5463 let scalar = project_file("projects: p-3f9a2c\n");
5464 assert!(
5465 Issue::parse(&scalar).is_err(),
5466 "a bare scalar is not a list of ids"
5467 );
5468 // A mapping where a string belongs.
5469 let mapping = project_file("projects:\n- id: p-1\n");
5470 assert!(
5471 Issue::parse(&mapping).is_err(),
5472 "each entry must be an id string, not a map"
5473 );
5474 }
5475
5476 #[test]
5477 fn t011_projects_is_distinct_from_cycles_milestones_mission_and_task() {
5478 // Four reference fields on one struct is exactly where an alias hides, so
5479 // the keys and the slots are both pinned.
5480 let mut issue = issue("Body.");
5481 issue.cycles = Some(vec!["c-one".into()]);
5482 issue.milestones = Some(vec!["m-one".into()]);
5483 issue.mission = Some("mission-x".into());
5484 issue.task = Some("WP03".into());
5485 issue.projects = Some(vec!["p-one".into()]);
5486
5487 let rendered = issue.to_file_contents();
5488 for key in ["projects:", "cycles:", "milestones:", "mission:", "task:"] {
5489 assert_eq!(
5490 rendered.matches(key).count(),
5491 1,
5492 "`{key}` must appear exactly once, never aliased onto another field:\n{rendered}"
5493 );
5494 }
5495
5496 let back = Issue::parse(&rendered).unwrap();
5497 assert_eq!(back.projects, Some(vec!["p-one".to_string()]));
5498 assert_eq!(back.cycles, Some(vec!["c-one".to_string()]));
5499 assert_eq!(back.milestones, Some(vec!["m-one".to_string()]));
5500 assert_eq!(back.mission, Some("mission-x".to_string()));
5501 assert_eq!(back.task, Some("WP03".to_string()));
5502 }
5503
5504 #[test]
5505 fn t011_an_issue_exposes_the_project_node_view_the_service_reads() {
5506 use crate::projects::ProjectNode;
5507 let mut issue = issue("Body.");
5508 issue.number = 9;
5509 issue.title = "Nine".into();
5510 issue.status = Status::new("doing");
5511 issue.projects = Some(vec!["p-1".into()]);
5512 assert_eq!(ProjectNode::number(&issue), 9);
5513 assert_eq!(ProjectNode::title(&issue), "Nine");
5514 assert_eq!(ProjectNode::status(&issue), "doing");
5515 assert_eq!(ProjectNode::project_ids(&issue), &["p-1".to_string()][..]);
5516 issue.projects = None;
5517 assert!(
5518 ProjectNode::project_ids(&issue).is_empty(),
5519 "absent is the empty slice, not a panic"
5520 );
5521 }
5522
5523 #[test]
5524 fn t012_project_kinds_render_and_parse() {
5525 assert_eq!(
5526 HistoryEventKind::ProjectAssociated.as_str(),
5527 "project_associated"
5528 );
5529 assert_eq!(
5530 HistoryEventKind::ProjectAssociated.to_string(),
5531 "project_associated"
5532 );
5533 assert_eq!(
5534 "project_associated".parse::<HistoryEventKind>().unwrap(),
5535 HistoryEventKind::ProjectAssociated
5536 );
5537 assert_eq!(
5538 HistoryEventKind::ProjectDissociated.as_str(),
5539 "project_dissociated"
5540 );
5541 assert_eq!(
5542 "project_dissociated".parse::<HistoryEventKind>().unwrap(),
5543 HistoryEventKind::ProjectDissociated
5544 );
5545 }
5546
5547 #[test]
5548 fn t012_from_project_maps_both_service_kinds() {
5549 use crate::projects::ProjectEventKind;
5550 assert_eq!(
5551 HistoryEventKind::from_project(ProjectEventKind::ProjectAssociated),
5552 HistoryEventKind::ProjectAssociated
5553 );
5554 assert_eq!(
5555 HistoryEventKind::from_project(ProjectEventKind::ProjectDissociated),
5556 HistoryEventKind::ProjectDissociated
5557 );
5558 }
5559
5560 #[test]
5561 fn t012_a_project_event_round_trips_through_the_file() {
5562 let mut issue = issue("Body.");
5563 issue.history.append(HistoryEvent::new(
5564 "2026-01-01T00:00:00Z",
5565 "alice",
5566 HistoryEventKind::ProjectAssociated,
5567 "project `p-1` associated",
5568 ));
5569 let rendered = issue.to_file_contents();
5570 let back = Issue::parse(&rendered).unwrap();
5571 let events = back.history.events();
5572 assert_eq!(events.len(), 1);
5573 assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
5574 assert_eq!(events[0].detail, "project `p-1` associated");
5575 assert_eq!(
5576 back.to_file_contents(),
5577 rendered,
5578 "byte-stable through the file, like every other History kind"
5579 );
5580 }
5581
5582 #[test]
5583 fn t012_an_unknown_project_style_kind_is_rejected() {
5584 assert!(
5585 "projects_changed".parse::<HistoryEventKind>().is_err(),
5586 "an unknown kind must be rejected, never coerced to a default"
5587 );
5588 }
5589
5590 // ------------------------------------------------------------------
5591 // WP03 — epic references in the model (mission
5592 // epics-work-items-01M3BYW4)
5593 // ------------------------------------------------------------------
5594
5595 #[test]
5596 fn t013_epics_is_an_optional_id_list_that_round_trips_two_references() {
5597 // C-003: several references, shaped like `projects`/`cycles`/`milestones`.
5598 let original = project_file("epics:\n- e-7c4a91\n- e-2b8f30\n");
5599 let issue = Issue::parse(&original).unwrap();
5600 assert_eq!(
5601 issue.epics,
5602 Some(vec!["e-7c4a91".to_string(), "e-2b8f30".to_string()]),
5603 "the field holds several epic ids"
5604 );
5605 let rendered = issue.to_file_contents();
5606 assert_eq!(rendered, original, "two references round-trip byte-stably");
5607 assert_eq!(
5608 Issue::parse(&rendered).unwrap().epics,
5609 issue.epics,
5610 "and survive a second read"
5611 );
5612 }
5613
5614 #[test]
5615 fn t013_an_issue_with_no_epics_serializes_with_no_key() {
5616 let no_key = project_file("");
5617 assert!(!no_key.contains("epics:"));
5618 let issue = Issue::parse(&no_key).unwrap();
5619 assert_eq!(issue.epics, None, "an absent key reads as absent");
5620 let rendered = issue.to_file_contents();
5621 assert_eq!(
5622 rendered, no_key,
5623 "a file that never had the field is untouched by a round trip"
5624 );
5625 assert!(!rendered.contains("epics:"));
5626 }
5627
5628 #[test]
5629 fn t013_an_empty_epics_list_is_written_as_absent() {
5630 let issue = Issue::parse(&project_file("epics: []\n")).unwrap();
5631 let rendered = issue.to_file_contents();
5632 assert!(
5633 !rendered.contains("epics:"),
5634 "the key is omitted when empty, exactly like projects/cycles/milestones:\n{rendered}"
5635 );
5636 assert_eq!(Issue::parse(&rendered).unwrap().epics, None);
5637 }
5638
5639 #[test]
5640 fn t013_a_malformed_epics_value_is_rejected_not_coerced() {
5641 // A scalar where a list belongs.
5642 let scalar = project_file("epics: e-7c4a91\n");
5643 assert!(
5644 Issue::parse(&scalar).is_err(),
5645 "a bare scalar is not a list of ids"
5646 );
5647 // A mapping where a string belongs.
5648 let mapping = project_file("epics:\n- id: e-1\n");
5649 assert!(
5650 Issue::parse(&mapping).is_err(),
5651 "each entry must be an id string, not a map"
5652 );
5653 }
5654
5655 #[test]
5656 fn t013_epics_is_distinct_from_projects_cycles_milestones_mission_task_and_files() {
5657 // Five list-valued reference fields on one struct is exactly where an alias
5658 // hides, so the keys and the slots are both pinned. `task` is the C-001
5659 // guard: an epic reference must never read as a task reference.
5660 let mut issue = issue("Body.");
5661 issue.cycles = Some(vec!["c-one".into()]);
5662 issue.milestones = Some(vec!["m-one".into()]);
5663 issue.mission = Some("mission-x".into());
5664 issue.task = Some("WP03".into());
5665 issue.projects = Some(vec!["p-one".into()]);
5666 issue.epics = Some(vec!["e-one".into()]);
5667 issue.files = vec!["docs/a.md".into()];
5668
5669 let rendered = issue.to_file_contents();
5670 for key in [
5671 "epics:",
5672 "projects:",
5673 "cycles:",
5674 "milestones:",
5675 "mission:",
5676 "task:",
5677 "files:",
5678 ] {
5679 assert_eq!(
5680 rendered.matches(key).count(),
5681 1,
5682 "`{key}` must appear exactly once, never aliased onto another field:\n{rendered}"
5683 );
5684 }
5685
5686 let back = Issue::parse(&rendered).unwrap();
5687 assert_eq!(back.epics, Some(vec!["e-one".to_string()]));
5688 assert_eq!(back.projects, Some(vec!["p-one".to_string()]));
5689 assert_eq!(back.cycles, Some(vec!["c-one".to_string()]));
5690 assert_eq!(back.milestones, Some(vec!["m-one".to_string()]));
5691 assert_eq!(back.mission, Some("mission-x".to_string()));
5692 assert_eq!(back.task, Some("WP03".to_string()));
5693 assert_eq!(back.files, vec!["docs/a.md".to_string()]);
5694 }
5695
5696 #[test]
5697 fn t013_an_issue_exposes_the_epic_node_view_the_service_reads() {
5698 use crate::epics::EpicNode;
5699 let mut issue = issue("Body.");
5700 issue.number = 9;
5701 issue.title = "Nine".into();
5702 issue.status = Status::new("doing");
5703 issue.epics = Some(vec!["e-1".into()]);
5704 assert_eq!(EpicNode::number(&issue), 9);
5705 assert_eq!(EpicNode::title(&issue), "Nine");
5706 assert_eq!(EpicNode::status(&issue), "doing");
5707 assert_eq!(EpicNode::epic_ids(&issue), &["e-1".to_string()][..]);
5708 issue.epics = None;
5709 assert!(
5710 EpicNode::epic_ids(&issue).is_empty(),
5711 "absent is the empty slice, not a panic"
5712 );
5713 }
5714
5715 #[test]
5716 fn t014_epic_kinds_render_and_parse() {
5717 assert_eq!(HistoryEventKind::EpicReferenced.as_str(), "epic_referenced");
5718 assert_eq!(
5719 HistoryEventKind::EpicReferenced.to_string(),
5720 "epic_referenced"
5721 );
5722 assert_eq!(
5723 "epic_referenced".parse::<HistoryEventKind>().unwrap(),
5724 HistoryEventKind::EpicReferenced
5725 );
5726 assert_eq!(
5727 HistoryEventKind::EpicDereferenced.as_str(),
5728 "epic_dereferenced"
5729 );
5730 assert_eq!(
5731 "epic_dereferenced".parse::<HistoryEventKind>().unwrap(),
5732 HistoryEventKind::EpicDereferenced
5733 );
5734 }
5735
5736 #[test]
5737 fn t014_from_epic_maps_both_service_kinds() {
5738 use crate::epics::EpicEventKind;
5739 assert_eq!(
5740 HistoryEventKind::from_epic(EpicEventKind::EpicReferenced),
5741 HistoryEventKind::EpicReferenced
5742 );
5743 assert_eq!(
5744 HistoryEventKind::from_epic(EpicEventKind::EpicDereferenced),
5745 HistoryEventKind::EpicDereferenced
5746 );
5747 }
5748
5749 #[test]
5750 fn t014_an_epic_event_round_trips_through_the_file() {
5751 let mut issue = issue("Body.");
5752 issue.history.append(HistoryEvent::new(
5753 "2026-01-01T00:00:00Z",
5754 "alice",
5755 HistoryEventKind::EpicReferenced,
5756 "epic `e-7c4a91` referenced",
5757 ));
5758 let rendered = issue.to_file_contents();
5759 let back = Issue::parse(&rendered).unwrap();
5760 let events = back.history.events();
5761 assert_eq!(events.len(), 1);
5762 assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
5763 assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
5764 assert_eq!(
5765 back.to_file_contents(),
5766 rendered,
5767 "byte-stable through the file, like every other History kind"
5768 );
5769 }
5770
5771 #[test]
5772 fn t014_an_unknown_epic_style_kind_is_rejected() {
5773 assert!(
5774 "epics_changed".parse::<HistoryEventKind>().is_err(),
5775 "an unknown kind must be rejected, never coerced to a default"
5776 );
5777 }
5778
5779 #[test]
5780 fn t046_the_two_new_event_kinds_round_trip_through_their_spellings() {
5781 // FR-002's classes fold these two kinds, so a missing `as_str` entry or a
5782 // missing `FromStr` arm would write a line no reader could classify back.
5783 for (kind, spelling) in [
5784 (HistoryEventKind::EstimateChanged, "estimate_changed"),
5785 (
5786 HistoryEventKind::CycleMembershipChanged,
5787 "cycle_membership_changed",
5788 ),
5789 ] {
5790 assert_eq!(kind.as_str(), spelling);
5791 assert_eq!(
5792 HistoryEventKind::from_str(spelling).unwrap(),
5793 kind,
5794 "`{spelling}` must parse back to the same kind"
5795 );
5796 let yaml = serde_yaml::to_string(&kind).unwrap();
5797 assert_eq!(
5798 yaml.trim(),
5799 spelling,
5800 "the derived serde spelling must match `as_str`"
5801 );
5802 }
5803 }
5804
5805 #[test]
5806 fn t046_the_new_kinds_survive_a_full_file_round_trip() {
5807 let mut issue = issue("Body.");
5808 issue.history.append(HistoryEvent::new(
5809 "2026-01-01T00:00:00Z",
5810 "alice",
5811 HistoryEventKind::EstimateChanged,
5812 "estimate: none -> 3",
5813 ));
5814 issue.history.append(HistoryEvent::new(
5815 "2026-01-02T00:00:00Z",
5816 "alice",
5817 HistoryEventKind::CycleMembershipChanged,
5818 "cycles: none -> Default",
5819 ));
5820 let rendered = issue.to_file_contents();
5821 let back = Issue::parse(&rendered).unwrap();
5822 let events = back.history.events();
5823 assert_eq!(events.len(), 2);
5824 assert_eq!(events[0].kind, HistoryEventKind::EstimateChanged);
5825 assert_eq!(events[1].kind, HistoryEventKind::CycleMembershipChanged);
5826 assert_eq!(
5827 back.to_file_contents(),
5828 rendered,
5829 "byte-stable through the file, like every other History kind"
5830 );
5831 }
5832}