Skip to main content

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 = &current[..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 = &current[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![diagram](./assets/diagram.png)".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}