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    "title",
307    "status",
308    "labels",
309    "priority",
310    "assignee",
311    "estimate",
312    "commit",
313    "gitlab_id",
314    "linear_id",
315    "mission",
316    "task",
317    "relationships",
318    "parent",
319    "files",
320    "cycles",
321    "milestones",
322    "reviewer",
323    "start_date",
324    "due_date",
325    "entered_at",
326    "source",
327    "created",
328    "updated",
329    "links",
330];
331
332/// Capture every top-level frontmatter key this model does not know, as
333/// `key -> raw source block` (#1361).
334///
335/// **Raw block, not a parsed value.** Re-serialising a parsed value would re-quote
336/// and re-indent it, so a key written one way would come back another way and every
337/// write would produce a diff. Splicing the original bytes keeps the round trip
338/// byte-stable for content this model has no opinion about — which is the whole
339/// point of preserving it.
340///
341/// A block is the key's own line plus every following line that continues it: an
342/// indented line, or a block-sequence item. The scan is textual on purpose. A YAML
343/// parse would be tidier, but the raw text is what has to be reproduced, and
344/// re-deriving block boundaries from a parse is how a nested list ends up half
345/// captured.
346///
347/// `links` is skipped. See [`KNOWN_FRONTMATTER_KEYS`].
348pub fn capture_extra_frontmatter(frontmatter: &str) -> BTreeMap<String, String> {
349    let mut out = BTreeMap::new();
350    let mut current: Option<(String, String)> = None;
351    for line in frontmatter.lines() {
352        match top_level_key(line) {
353            Some(key) => {
354                if let Some((name, block)) = current.take() {
355                    out.insert(name, block);
356                }
357                if KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()) {
358                    continue;
359                }
360                current = Some((key, line.to_string()));
361            }
362            None => {
363                // A continuation line: indented, or a block-sequence item. A blank
364                // line ends the block, because YAML does too.
365                if let Some((_, block)) = current.as_mut() {
366                    if line.trim().is_empty() {
367                        current = None;
368                    } else {
369                        block.push('\n');
370                        block.push_str(line);
371                    }
372                }
373            }
374        }
375    }
376    if let Some((name, block)) = current.take() {
377        out.insert(name, block);
378    }
379    out
380}
381
382/// The key of a top-level YAML mapping entry, or `None` for a continuation line.
383///
384/// Only column 0 counts. An indented `key:` is part of a nested mapping, and a
385/// `- item` is a sequence entry; treating either as a top-level key would end the
386/// previous block early and capture a fragment.
387fn top_level_key(line: &str) -> Option<String> {
388    if !line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
389        return None;
390    }
391    let colon = line.find(':')?;
392    let key = &line[..colon];
393    if key.is_empty()
394        || !key
395            .chars()
396            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
397    {
398        return None;
399    }
400    Some(key.to_string())
401}
402
403fn frontmatter_has_retired_links_key(frontmatter: &str) -> bool {
404    if let Ok(mapping) = serde_yaml::from_str::<serde_yaml::Mapping>(frontmatter) {
405        let key = serde_yaml::Value::String("links".into());
406        return match mapping.get(&key) {
407            Some(value) => !links_value_is_empty(value),
408            None => false,
409        };
410    }
411    frontmatter.lines().any(|line| {
412        if line.starts_with(' ') || line.starts_with('\t') {
413            return false;
414        }
415        line.split_once(':')
416            .map(|(key, _)| key.trim() == "links")
417            .unwrap_or(false)
418    })
419}
420
421/// Whether a `links:` value carries no entries, in the shapes the old model
422/// wrote and the shapes a hand-edit can produce: `[]`, `~`/`null`, an empty
423/// scalar, and a block sequence with no items (`links:\n` followed by the next
424/// key). Anything else is populated and therefore a finding.
425fn links_value_is_empty(value: &serde_yaml::Value) -> bool {
426    match value {
427        serde_yaml::Value::Null => true,
428        serde_yaml::Value::Sequence(items) => items.is_empty(),
429        serde_yaml::Value::String(s) => s.trim().is_empty() || s.trim() == "[]",
430        serde_yaml::Value::Mapping(m) => m.is_empty(),
431        serde_yaml::Value::Tagged(t) => links_value_is_empty(&t.value),
432        _ => false,
433    }
434}
435
436/// A strict `YYYY-MM-DD` calendar-date check (shape + real month/day with
437/// per-month day counts and leap years, `year >= 1970`), so `2026-13-99`,
438/// `2026-02-30` and `1969-12-31` are all flagged as malformed. The shared
439/// validation path for date-bearing config and frontmatter fields (FR-007).
440pub fn validate_date(s: &str) -> bool {
441    let b = s.as_bytes();
442    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
443        return false;
444    }
445    for &c in &b[0..4] {
446        if !c.is_ascii_digit() {
447            return false;
448        }
449    }
450    for &c in &[b[5], b[6], b[8], b[9]] {
451        if !c.is_ascii_digit() {
452            return false;
453        }
454    }
455    let year = u32::from(b[0] - b'0') * 1000
456        + u32::from(b[1] - b'0') * 100
457        + u32::from(b[2] - b'0') * 10
458        + u32::from(b[3] - b'0');
459    let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
460    let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
461    if year < 1970 || !(1..=12).contains(&month) {
462        return false;
463    }
464    day >= 1 && day <= days_in_month(year, month)
465}
466
467/// Real day count for a Gregorian month (leap-aware).
468fn days_in_month(year: u32, month: u32) -> u32 {
469    match month {
470        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
471        4 | 6 | 9 | 11 => 30,
472        2 => {
473            let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
474            if leap { 29 } else { 28 }
475        }
476        _ => 0,
477    }
478}
479
480/// Message-bearing [`validate_date`] for surfaces that want to explain what
481/// was wrong. The error names the offending value; callers add field context.
482fn validate_date_msg(s: &str) -> std::result::Result<(), String> {
483    if validate_date(s) {
484        Ok(())
485    } else {
486        Err(format!(
487            "`{s}` is not a `YYYY-MM-DD` calendar date (year >= 1970)"
488        ))
489    }
490}
491
492/// Validate a `(start_date, due_date)` pair for an issue (FR-007, C-002).
493///
494/// Each present date must parse as a calendar date; when both are present,
495/// `due_date >= start_date` must hold. Returns a config-class error naming
496/// both fields on an ordering violation, matching
497/// `contracts/file-format-planning.md`.
498pub fn validate_issue_dates(start_date: Option<&str>, due_date: Option<&str>) -> Result<()> {
499    if let Some(start) = start_date
500        && validate_date_msg(start).is_err()
501    {
502        return Err(TixError::Config(format!(
503            "start_date {start:?} must be a `YYYY-MM-DD` calendar date"
504        )));
505    }
506    if let Some(due) = due_date {
507        if validate_date_msg(due).is_err() {
508            return Err(TixError::Config(format!(
509                "due_date {due:?} must be a `YYYY-MM-DD` calendar date"
510            )));
511        }
512        if let Some(start) = start_date
513            && !date_lte(start, due)
514        {
515            return Err(TixError::Config(format!(
516                "due_date {due:?} must be `>= start_date` {start:?}"
517            )));
518        }
519    }
520    Ok(())
521}
522
523/// Chronological order for two validated `YYYY-MM-DD` strings.
524///
525/// Plain lexicographic comparison equals chronological order for the
526/// formats [`validate_date`] accepts (fixed-width zero-padded fields), so
527/// later WPs (overdue, filters, cascade) reuse identical ordering logic.
528pub fn date_lte(a: &str, b: &str) -> bool {
529    a <= b
530}
531
532/// Convert days since the Unix epoch to a `(year, month, day)` civil date
533/// (Howard Hinnant's `civil_from_days`).
534fn civil_from_days(z: i64) -> (i64, u32, u32) {
535    let z = z + 719_468;
536    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
537    let doe = z - era * 146_097; // [0, 146096]
538    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
539    let y = yoe + era * 400;
540    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
541    let mp = (5 * doy + 2) / 153; // [0, 11]
542    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
543    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
544    (if m <= 2 { y + 1 } else { y }, m, d)
545}
546
547/// Parse a `### <author> (<YYYY-MM-DD>)` comment head into its parts.
548///
549/// The date is the trailing `(<YYYY-MM-DD>)` group; the author is everything
550/// before it. A head without a well-formed trailing date still yields the
551/// author (the date defaults to `""`), so malformed heads survive the parse
552/// without crashing (`contracts/comments-format.md` rule "preserve verbatim").
553fn parse_comment_head(after: &str) -> (String, String) {
554    let (author, date) = match after.len() {
555        len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
556            let close = len - 1;
557            let open = after[..close].rfind(" (");
558            match open {
559                Some(open) => {
560                    let maybe_date = &after[open + 2..close];
561                    if maybe_date.len() == 10
562                        && maybe_date.as_bytes()[4] == b'-'
563                        && maybe_date.as_bytes()[7] == b'-'
564                    {
565                        (&after[..open], maybe_date)
566                    } else {
567                        (after, "")
568                    }
569                }
570                None => (after, ""),
571            }
572        }
573        _ => (after, ""),
574    };
575    (author.trim_end().to_string(), date.to_string())
576}
577
578/// A single issue comment, parsed from the `## Comments` body section.
579///
580/// Mirrors `contracts/comments-format.md`: each comment is a level-3 heading
581/// `### <author> (<YYYY-MM-DD>)` followed by free text up to the next `###`
582/// head or end of file. Blank lines inside `text` are preserved verbatim
583/// (NFR-005); the model never normalizes hand-authored content.
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct Comment {
586    pub author: String,
587    pub date: String,
588    pub text: String,
589}
590
591/// An issue: one markdown file with YAML frontmatter.
592///
593/// Field order and names are frozen by `contracts/file-format.md` FR-012.
594/// `number` is a stored frontmatter key assigned by the storage layer at
595/// create time by scanning the live issue set (issue #52; previously from
596/// `tix.toml` `[state].next_number`, which is no longer persisted); this
597/// module models and validates it but never allocates. `filename` and
598/// `body` are memory-only (`#[serde(skip)]`).
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600pub struct Issue {
601    /// Internal sequential id, never reused (C-007).
602    pub number: u32,
603    /// Human title; drives the slug in the filename.
604    #[serde(default)]
605    pub title: String,
606    /// Column (board workflow stage) the issue sits in.
607    #[serde(default)]
608    pub status: Status,
609    /// Free-form labels.
610    #[serde(default)]
611    pub labels: Labels,
612    /// Priority; defaults to `medium`.
613    #[serde(default)]
614    pub priority: Priority,
615    /// Assignee. In memory: bare handle (`alice`) or full `Name <email>`
616    /// identity, verbatim. Files serialize per the rule documented on
617    /// [`de_assignee`]/[`se_assignee`] (legacy `@alice` tolerated on read).
618    #[serde(
619        default,
620        skip_serializing_if = "Option::is_none",
621        deserialize_with = "de_assignee",
622        serialize_with = "se_assignee"
623    )]
624    pub assignee: Option<String>,
625    /// Free-form estimate (e.g. `2`, `S`, `3d`); default `null`.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub estimate: Option<String>,
628    /// Git commit recorded when moved to `done` (FR-026).
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub commit: Option<String>,
631    /// Remote GitLab issue id after export/import.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub gitlab_id: Option<String>,
634    /// Remote Linear issue id after export/import.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub linear_id: Option<String>,
637    /// Spec Kitty mission slug (e.g. `v3-archive-review`) this issue belongs to, if any.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub mission: Option<String>,
640    /// Spec Kitty work package id (e.g. `WP01`) this issue links to, if any.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub task: Option<String>,
643    /// Typed relationships to other issues, replacing the retired generic
644    /// `links: Vec<String>` bag (C-001).
645    ///
646    /// Each entry is a [`Relationship`]: a typed, directed edge with a stable
647    /// id and a human-readable display name. The vocabulary is the closed
648    /// four-kind set — `blocks`, `blocked_by`, `duplicates`, `related` — and
649    /// the kind that reaches disk is always the canonical one, so `blocked_by`
650    /// is normalized into `blocks` before it is stored and a pair is never
651    /// stored twice. See [`crate::relationships`] for the direction
652    /// convention, which this field does not restate.
653    ///
654    /// Files without the key parse (`default`). An empty list is **omitted**
655    /// rather than written as `[]`: the typed field is new, so there is no
656    /// legacy expectation to preserve for it, and omitting it keeps an issue
657    /// with no relationships free of a meaningless key.
658    #[serde(default, skip_serializing_if = "Vec::is_empty")]
659    pub relationships: Vec<Relationship>,
660    /// This issue's **decomposition parent**, as an issue number.
661    ///
662    /// A parent is a decomposition — "this work is part of that" — and is a
663    /// **distinct concept from a typed dependency edge**: `blocks` is a scheduling
664    /// dependency, and conflating the two in one field is prohibited (C-007). It is
665    /// also distinct from the `mission` and `task` Spec Kitty pointer fields, which
666    /// are governance metadata and not a hierarchy (research R2).
667    ///
668    /// **At most one parent** (C-001). The type is a single `Option<u32>`, so two
669    /// parents are not expressible; the field is not a list and no code path appends
670    /// to it. Child membership is *derived* by inverting this pointer and is never
671    /// stored on the parent (C-003) — see [`crate::hierarchy`], which owns that
672    /// derivation.
673    ///
674    /// `#[serde(default)]` so every legacy file without the key parses unchanged,
675    /// and skipped when absent so a file that never had a parent serialises
676    /// byte-identically to its pre-mission form.
677    ///
678    /// The value is an issue **number**, not a selector: a selector may be a title
679    /// fragment, and a title is not stable, so a stored title would silently break
680    /// every child when the parent was retitled. Selectors are the *input* to
681    /// `tix parent set`; the number is what lands in the file.
682    ///
683    /// A reference whose target no longer exists is **kept**, not cleared: it becomes
684    /// the diagnosed `UnresolvedParentState` so it can be repaired.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub parent: Option<u32>,
687    /// Repo-relative file paths of the governing specification (e.g.
688    /// `openspec/changes/<name>/specs/<cap>/spec.md`). Default `[]`.
689    #[serde(default)]
690    pub files: Vec<String>,
691    /// Planning cycle names this issue belongs to (FR-005). Each entry must
692    /// name a `[cycles.<name>]` config entry. `Option` so legacy files lack
693    /// the key entirely; serialized only when non-empty.
694    #[serde(
695        default,
696        skip_serializing_if = "skip_planning_list",
697        serialize_with = "se_planning_list"
698    )]
699    pub cycles: Option<Vec<String>>,
700    /// Milestone names this issue belongs to (FR-005). Each entry must name a
701    /// milestone file in `milestones_dir`. Serialized only when non-empty.
702    #[serde(
703        default,
704        skip_serializing_if = "skip_planning_list",
705        serialize_with = "se_planning_list"
706    )]
707    pub milestones: Option<Vec<String>>,
708    /// Advisory reviewer identity (FR-006), free-form like `assignee`.
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub reviewer: Option<String>,
711    /// Planning start date, `YYYY-MM-DD` (FR-006).
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub start_date: Option<String>,
714    /// Planning due date, `YYYY-MM-DD`; must be `>= start_date` (FR-007).
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub due_date: Option<String>,
717    /// When the issue most recently entered the triage inbox, RFC 3339 UTC
718    /// (`2026-09-30T09:12:44Z`), if it ever did.
719    ///
720    /// Mission `triage-inbox-review-flow-01M3C600`, data-model §2. Queue age is
721    /// measured from here and from nothing else: `updated` moves on every edit and
722    /// `created` predates the queue, so either would report a wait the issue did
723    /// not have (research R4). Read it through [`crate::triage::entry_time`],
724    /// which is the one documented fallback for an issue that has none.
725    ///
726    /// Optional and skipped when absent, so a repository that predates triage
727    /// round-trips byte-identically instead of gaining a key on every file (C-001).
728    ///
729    /// Refreshed on re-entry, not merged — a defer restarts the wait (data-model
730    /// §5) — and deliberately **not** stamped on every edit, which would turn it
731    /// into a second `updated`.
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub entered_at: Option<String>,
734    /// Where the issue's current entry came from: `new`, `import`, `defer`, or
735    /// any other origin the caller documents (data-model §2, `source`).
736    ///
737    /// Optional for the same reason as [`Self::entered_at`]. It describes *this*
738    /// entry rather than the issue's permanent provenance, so it is rewritten
739    /// whenever the issue re-enters.
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub source: Option<String>,
742    /// Creation date, `YYYY-MM-DD`, equals the filename date prefix.
743    #[serde(default = "today")]
744    pub created: String,
745    /// Last-mutation date, `YYYY-MM-DD`.
746    #[serde(default = "today")]
747    pub updated: String,
748    /// Issue file name — path relative to the issues dir (e.g. `alice/2026-05-10-fix-login.md` for grouped layout, or `2026-05-10-fix-login.md` for legacy flat files per C-010).
749    #[serde(skip)]
750    pub filename: String,
751    /// Raw markdown body; memory-only, preserved verbatim.
752    #[serde(skip)]
753    pub body: String,
754    /// The append-only `## History` section, parsed out of the body on read and
755    /// re-rendered on write; memory-only like [`Issue::body`].
756    ///
757    /// The section is *stripped* from `body` during parsing, so `body` holds
758    /// only the issue's own prose and the two never disagree. Rendering puts
759    /// the section back immediately after the frontmatter, the position
760    /// `data-model.md` §8 pins.
761    #[serde(skip)]
762    pub history: History,
763    /// Whether the source frontmatter still carries a legacy `links:` key.
764    ///
765    /// The key is never read as typed data (C-001) and never migrated; it is
766    /// recorded here so the service can report it as a
767    /// `retired_links_key` validation result. Memory-only, like the rest of the
768    /// file-surface state.
769    #[serde(skip)]
770    pub has_retired_links_key: bool,
771    /// Top-level frontmatter keys this model does not know, as `key -> raw block`.
772    ///
773    /// #1361. Before this, an unknown key was dropped on every write: `Issue`
774    /// deserialises a fixed set of keys and `frontmatter` re-emits exactly those, so
775    /// a key `tix` does not recognise was deleted by the next unrelated `tix edit` —
776    /// silently, because `tix lint` had no unknown-key rule at all.
777    ///
778    /// The value is the **raw source block**, not a parsed value, and that is the
779    /// whole design. Re-serialising a parsed value would re-quote and re-indent it, so
780    /// a key that was written one way would come back another way and every write
781    /// would produce a spurious diff. Splicing the original bytes back in makes the
782    /// round trip byte-stable for content this model has no opinion about.
783    ///
784    /// A `BTreeMap` rather than a `HashMap` so the render order is deterministic:
785    /// `HashMap` iteration order would reshuffle these keys on every write and
786    /// produce a diff in a file that had not changed.
787    ///
788    /// `Eq` is preserved (unlike `serde_yaml::Value`, which is not `Eq`), and the
789    /// blocks are plain `String`s, so `Issue` keeps every one of its derives.
790    ///
791    /// The retired `links:` key is **excluded** — see [`Issue::parse`]. It is
792    /// recognised, reported by its own finding, and dropped (C-001); round-tripping it
793    /// would silently undo the typed-relationship migration.
794    #[serde(skip)]
795    pub extra_frontmatter: BTreeMap<String, String>,
796    /// Whether the issue is archived (issue #1492).
797    ///
798    /// The **filename is the marker**, never a frontmatter field: an archived
799    /// issue's markdown file is dot-prefixed in place
800    /// (`docs/issues/<assignee>/.<file>.md`). This field is therefore derived
801    /// state, not data — it mirrors the leading dot of [`Issue::filename`] and
802    /// is set by [`crate::storage::Store::list_all`], never written to disk.
803    /// `#[serde(skip)]` keeps it out of the frontmatter, and it defaults to
804    /// `false` for every parse.
805    #[serde(skip)]
806    pub archived: bool,
807}
808
809// ---------------------------------------------------------------------------
810// Append-only History section (WP02 T007)
811// ---------------------------------------------------------------------------
812
813/// The heading that introduces the History section, per `data-model.md` §8.
814///
815/// The section is recognised by a line whose trimmed content is exactly this —
816/// nothing else introduces it.
817pub const HISTORY_HEADING: &str = "## History";
818
819/// The separator between the four fields of a rendered History entry
820/// (U+2014 em dash).
821const HISTORY_SEP: char = '\u{2014}';
822
823/// What kind of mutation a [`HistoryEvent`] records.
824///
825/// Closed set per `data-model.md` §8. `blocked_move_acknowledged` is defined
826/// here so WP04 consumes it without editing this file.
827#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
828#[serde(rename_all = "snake_case")]
829pub enum HistoryEventKind {
830    /// A relationship was created.
831    RelationshipAdded,
832    /// A relationship was removed.
833    RelationshipRemoved,
834    /// A relationship was retargeted or re-kinded.
835    RelationshipRetargeted,
836    /// A move toward completion was confirmed while blockers were open.
837    BlockedMoveAcknowledged,
838    /// An issue gained its first parent.
839    ParentSet,
840    /// An existing parent reference was repointed.
841    ParentChanged,
842    /// A parent reference was removed.
843    ParentCleared,
844    /// A status change was accepted by the transition service and applied.
845    ///
846    /// WP03. The name says *accepted* because acceptance is the only outcome that
847    /// reaches the log: a rejected transition is completely inert and appends
848    /// nothing (contract §3.5), and a re-applied transition is idempotent and also
849    /// appends nothing. So the presence of this kind in a file **is** the record
850    /// that the change was accepted — a name like `transition_attempted` would
851    /// imply rejections are logged here too, and they are not.
852    TransitionAccepted,
853    /// A triage inbox issue was accepted into the normal workflow.
854    ///
855    /// WP03 (mission `triage-inbox-review-flow-01M3C600`). Four triage kinds
856    /// rather than one `triage_decided` for the reason the parent kinds are
857    /// separate: the *decision* is the thing an audit reader is looking for, and
858    /// `triage_decided — decline …` makes them read the detail to learn which of
859    /// four mutually exclusive actions was taken. They extend the one #169 log
860    /// (research R5) — there is no second log and no triage-specific section.
861    ///
862    /// Only **applied** decisions reach the log: a refusal appends nothing and a
863    /// repeated identical decision is a no-op (contract `decision-history.md`),
864    /// exactly as with [`HistoryEventKind::TransitionAccepted`].
865    TriageAccepted,
866    /// A triage inbox issue was declined: moved to the configured declined
867    /// column, never deleted or archived (C-003).
868    TriageDeclined,
869    /// A triage inbox issue was marked a duplicate of another issue.
870    ///
871    /// The event records the decision; the typed `duplicates` edge is the
872    /// [`HistoryEventKind::RelationshipAdded`] entry. One decision, two kinds
873    /// *only* because the relation already has its own kind and inventing a
874    /// second spelling for it would leave the graph and the log disagreeing.
875    TriageDuplicated,
876    /// A triage inbox issue was deferred: it stays in the inbox with a
877    /// refreshed entry time (data-model §5).
878    TriageDeferred,
879    /// The issue file was archived: renamed in place to a dot-prefixed name
880    /// (issue #1492).
881    Archived,
882    /// An archived issue was restored to its active name (issue #1492).
883    Restored,
884}
885
886impl HistoryEventKind {
887    /// The canonical snake_case spelling written to the file.
888    pub fn as_str(self) -> &'static str {
889        match self {
890            HistoryEventKind::RelationshipAdded => "relationship_added",
891            HistoryEventKind::RelationshipRemoved => "relationship_removed",
892            HistoryEventKind::RelationshipRetargeted => "relationship_retargeted",
893            HistoryEventKind::BlockedMoveAcknowledged => "blocked_move_acknowledged",
894            // Three dedicated kinds rather than one generic `parent_changed` (research
895            // open question 2, resolved in `data-model.md` §6.1): "set" and "cleared" are
896            // not changes *of* a parent, they are its beginning and its end, and
897            // collapsing them leaves the audit trail unable to distinguish "gained a
898            // parent" from "moved to a different one" without reconstructing state from
899            // the detail text.
900            HistoryEventKind::ParentSet => "parent_set",
901            HistoryEventKind::ParentChanged => "parent_changed",
902            HistoryEventKind::ParentCleared => "parent_cleared",
903            HistoryEventKind::TransitionAccepted => "transition_accepted",
904            HistoryEventKind::TriageAccepted => "triage_accepted",
905            HistoryEventKind::TriageDeclined => "triage_declined",
906            HistoryEventKind::TriageDuplicated => "triage_duplicated",
907            HistoryEventKind::TriageDeferred => "triage_deferred",
908            HistoryEventKind::Archived => "archived",
909            HistoryEventKind::Restored => "restored",
910        }
911    }
912
913    /// Whether this kind records a triage decision (WP03's four).
914    ///
915    /// The membership test the decision service's idempotence check and
916    /// `not_in_triage`/`already_decided` split both ask, so "is this a triage
917    /// decision?" has one answer rather than a four-way `matches!` at every
918    /// call site — where a fifth kind would be forgotten first.
919    #[must_use]
920    pub fn is_triage_decision(self) -> bool {
921        matches!(
922            self,
923            HistoryEventKind::TriageAccepted
924                | HistoryEventKind::TriageDeclined
925                | HistoryEventKind::TriageDuplicated
926                | HistoryEventKind::TriageDeferred
927        )
928    }
929}
930
931impl fmt::Display for HistoryEventKind {
932    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
933        f.write_str(self.as_str())
934    }
935}
936
937impl FromStr for HistoryEventKind {
938    type Err = TixError;
939
940    /// Parse a kind spelling. An unknown kind is **rejected**, never coerced
941    /// to a default: a silently reclassified audit entry is worse than a
942    /// parse failure, and the field is part of an append-only log.
943    fn from_str(s: &str) -> Result<Self> {
944        match s {
945            "relationship_added" => Ok(HistoryEventKind::RelationshipAdded),
946            "relationship_removed" => Ok(HistoryEventKind::RelationshipRemoved),
947            "relationship_retargeted" => Ok(HistoryEventKind::RelationshipRetargeted),
948            "blocked_move_acknowledged" => Ok(HistoryEventKind::BlockedMoveAcknowledged),
949            "parent_set" => Ok(HistoryEventKind::ParentSet),
950            "parent_changed" => Ok(HistoryEventKind::ParentChanged),
951            "parent_cleared" => Ok(HistoryEventKind::ParentCleared),
952            "transition_accepted" => Ok(HistoryEventKind::TransitionAccepted),
953            "triage_accepted" => Ok(HistoryEventKind::TriageAccepted),
954            "triage_declined" => Ok(HistoryEventKind::TriageDeclined),
955            "triage_duplicated" => Ok(HistoryEventKind::TriageDuplicated),
956            "triage_deferred" => Ok(HistoryEventKind::TriageDeferred),
957            "archived" => Ok(HistoryEventKind::Archived),
958            "restored" => Ok(HistoryEventKind::Restored),
959            other => Err(TixError::Parse(format!(
960                "unknown History event kind `{other}`"
961            ))),
962        }
963    }
964}
965
966impl HistoryEventKind {
967    /// The model kind for a hierarchy event.
968    ///
969    /// Vocabulary translation, not a rule: the service decides *which* transition
970    /// happened, and this maps that decision onto the log's spelling. It lives on
971    /// the type that owns the log rather than at each call site, because three
972    /// callers need it — the storage write path, the `--parent` edit flag, and MCP's
973    /// `update_issue` in WP07 — and a mapping repeated three times is one that will
974    /// be completed three times and fixed once.
975    pub fn from_parent(kind: crate::hierarchy::ParentEventKind) -> Self {
976        match kind {
977            crate::hierarchy::ParentEventKind::ParentSet => HistoryEventKind::ParentSet,
978            crate::hierarchy::ParentEventKind::ParentChanged => HistoryEventKind::ParentChanged,
979            crate::hierarchy::ParentEventKind::ParentCleared => HistoryEventKind::ParentCleared,
980        }
981    }
982}
983
984/// The auditable record of one **accepted** status transition (`data-model.md`
985/// §5, WP03 T012).
986///
987/// ## Why this is not a `HistoryEventKind` alone
988///
989/// A [`HistoryEvent`] has exactly four fields — timestamp, actor, kind, detail —
990/// and the on-disk line format is fixed at four by the U+2014 split in
991/// [`HistoryEvent::parse`]. `data-model.md` §5 wants seven. The three surplus
992/// fields therefore live in `detail`, and this struct is what converts between
993/// the two: [`Self::to_event`] and [`Self::from_event`] are the only places that
994/// encoding is written or read.
995///
996/// ## The `detail` grammar
997///
998/// ```text
999/// detail := head [ "; override: " reason ]
1000/// head   := from " -> " to " (" from_category " -> " to_category ")"
1001/// ```
1002///
1003/// e.g. `todo -> in progress (unstarted -> active)`, or
1004/// `done -> in progress (completed -> active); override: mis-filed`.
1005///
1006/// **It is a grammar, not prose**, and that is a load-bearing choice rather than a
1007/// tidiness one. WP06's `lint_direct_edits` has to compare a file's *current*
1008/// `status` against the column its last accepted transition landed in; recovering
1009/// that column out of free-form English is guesswork, and a heuristic that
1010/// misreads it produces a lint result nobody can trust. A fixed grammar makes the
1011/// comparison exact, and makes "this detail is not a transition record"
1012/// detectable rather than a guess.
1013///
1014/// The parse is deliberately anchored rather than free-floating: the override is
1015/// split off at the first `") ; override: "`, and the categories are taken from
1016/// between the last `" ("` and the closing `")"`, so a **reason** may contain the
1017/// bare `"-> "` and the bare `"; override: "` without disturbing the fields. That
1018/// anchoring is the whole reason the marker includes the paren — see
1019/// [`OVERRIDE_MARKER`].
1020///
1021/// ## Known limit: a column name containing `" -> "`
1022///
1023/// The two column names are separated by the first `" -> "`, and nothing escapes
1024/// it, so a column literally named `a -> b` would render as
1025/// `a -> b -> in progress (unstarted -> active)` and parse back with `from == "a"`.
1026/// The categories still parse correctly, and the *categories* are what an audit
1027/// reader wants, but a WP06 that recovers `to` from a file would recover `b`.
1028///
1029/// This is inherent to a delimiter-based grammar, not a bug in this
1030/// implementation: any unescaped separator has a name it cannot represent, and
1031/// escaping would put backslashes into a line a human reads. A board column named
1032/// with an embedded arrow is not a realistic configuration, so the limit is
1033/// documented and pinned by a test rather than designed around. WP06 reads `to`
1034/// from these records, so it inherits the assumption and should not be surprised
1035/// by it.
1036#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1037pub struct TransitionDecision {
1038    /// When the transition was accepted, RFC 3339 — the same shape and clock as
1039    /// every other History entry.
1040    pub timestamp: String,
1041    /// Who performed it.
1042    ///
1043    /// A plain `String`, carried through to [`HistoryEvent::actor`] untouched, so
1044    /// identity handling is the History log's existing one: a bare handle or a full
1045    /// `Name <email>`, with a legacy `@alice` tolerated on read. This struct
1046    /// re-implements none of that and cannot drift from it.
1047    pub actor: String,
1048    /// The column the issue was in, as stored.
1049    pub from: String,
1050    /// The column it was moved to, as stored.
1051    pub to: String,
1052    /// The category `from` resolved to, recorded for auditability.
1053    ///
1054    /// **Derived, never stored on the issue and never re-derived on read** — that
1055    /// is the point. If a board's `category_map` is later edited, the historical
1056    /// record still says what the category *was* when the move was accepted, which
1057    /// is the only way the audit trail stays true.
1058    pub from_category: String,
1059    /// The category `to` resolved to, recorded for the same reason.
1060    pub to_category: String,
1061    /// Why a policy rule was overridden, when one was.
1062    ///
1063    /// Required to be `Some` for an override to exist at all: the service refuses a
1064    /// bare "I did it anyway" (see `TransitionOverride`), so this is never `Some`
1065    /// with an empty or blank reason.
1066    pub override_reason: Option<String>,
1067}
1068
1069/// The marker separating the transition record from its override reason.
1070///
1071/// **Includes the closing paren**, and that is load-bearing rather than
1072/// cosmetic. The obvious marker is `"; override: "`, split with `rsplit_once` so a
1073/// reason containing the marker survives — and it does not: the *last* occurrence
1074/// of a bare `"; override: "` inside a reason like `typo; override: actually…` is
1075/// the one that splits, which leaves the head ending in `; override: typo` and no
1076/// closing paren, so the parse fails outright. WP03's first version did exactly
1077/// that and its own test caught it.
1078///
1079/// Anchoring the marker to the `)` that closes the head makes the split
1080/// **unambiguous in both directions**: the head is the only `)` followed by the
1081/// marker, so `split_once` at the *first* occurrence always takes the right one.
1082/// A reason may therefore contain the bare marker, a bare `")"`, and even the full
1083/// anchored marker, and still round-trip.
1084///
1085/// That last case is worth spelling out because WP03's first two attempts both got
1086/// it wrong in opposite directions. `rsplit_once` on the *bare* marker took the
1087/// last occurrence, so a reason containing `"; override: "` truncated and the parse
1088/// failed outright. `rsplit_once` on the *anchored* marker would be no better: for
1089/// a reason containing `"); override: "` the last occurrence is the one inside the
1090/// reason. `split_once` on the anchored marker is the only combination of the two
1091/// that is correct, and `t012_a_reason_containing_the_anchored_marker_survives`
1092/// exists to keep it that way.
1093const OVERRIDE_MARKER: &str = "); override: ";
1094/// The marker opening the bracketed category pair.
1095const CATEGORY_OPEN: &str = " (";
1096/// The arrow between two names in the `detail` grammar.
1097const DETAIL_ARROW: &str = " -> ";
1098
1099impl TransitionDecision {
1100    /// Build a decision from an accepted transition.
1101    pub fn new(
1102        timestamp: impl Into<String>,
1103        actor: impl Into<String>,
1104        from: impl Into<String>,
1105        to: impl Into<String>,
1106        from_category: impl Into<String>,
1107        to_category: impl Into<String>,
1108        override_reason: Option<String>,
1109    ) -> Self {
1110        Self {
1111            timestamp: timestamp.into(),
1112            actor: actor.into(),
1113            from: from.into(),
1114            to: to.into(),
1115            from_category: from_category.into(),
1116            to_category: to_category.into(),
1117            override_reason,
1118        }
1119    }
1120
1121    /// Render to the single `detail` string the History line carries.
1122    pub fn detail(&self) -> String {
1123        let mut out = format!(
1124            "{from}{arrow}{to} ({fc}{arrow}{tc})",
1125            from = self.from,
1126            to = self.to,
1127            fc = self.from_category,
1128            tc = self.to_category,
1129            arrow = DETAIL_ARROW,
1130        );
1131        // A blank reason renders as no marker at all rather than as an empty field,
1132        // so `from_event` can never come back with `Some("")` — the same reasoning
1133        // that makes a blank actor render as an empty field rather than being
1134        // dropped, applied in the other direction.
1135        if let Some(reason) = self.override_reason.as_deref().map(str::trim) {
1136            if !reason.is_empty() {
1137                // The head already ends in the `)` the marker expects, so the
1138                // marker supplies the semicolon and the space only. Writing the
1139                // marker as a whole and *also* leaving the head's `)` is what
1140                // produced `...active)); override:` on the first pass.
1141                out.push_str(OVERRIDE_MARKER.trim_start_matches(')'));
1142                out.push_str(reason);
1143            }
1144        }
1145        out
1146    }
1147
1148    /// Recover a decision from a History entry.
1149    ///
1150    /// `Err` when `event` is not a `transition_accepted` entry or its `detail` does
1151    /// not match the grammar — never a partially-filled struct. A caller can
1152    /// therefore treat a successful parse as "this file records a transition to
1153    /// `to`", which is exactly the question WP06 asks.
1154    pub fn from_event(event: &HistoryEvent) -> Result<Self> {
1155        if event.kind != HistoryEventKind::TransitionAccepted {
1156            return Err(TixError::Parse(format!(
1157                "History entry is a `{}`, not a `transition_accepted`",
1158                event.kind
1159            )));
1160        }
1161        let malformed =
1162            |what: &str| TixError::Parse(format!("transition record {what}: `{}`", event.detail));
1163        // The reason is split off at the FIRST anchored marker: the head is the
1164        // only `)` followed by `; override: `, so a reason containing the bare
1165        // marker survives. See `OVERRIDE_MARKER`.
1166        let (head, override_reason) = match event.detail.split_once(OVERRIDE_MARKER) {
1167            Some((head, reason)) => (head, Some(reason.trim().to_string())),
1168            None => (event.detail.as_str(), None),
1169        };
1170        // The marker *includes* the head's closing paren, so put it back and let
1171        // the grammar below see one shape whether or not a reason was present. The
1172        // alternative — two separate shapes to parse — is how the two halves of a
1173        // grammar drift apart.
1174        let head = match &override_reason {
1175            Some(_) => format!("{head})"),
1176            None => head.to_string(),
1177        };
1178        let head = head.as_str();
1179        let (from, rest) = head
1180            .split_once(DETAIL_ARROW)
1181            .ok_or_else(|| malformed("has no `from -> to`"))?;
1182        let rest = rest
1183            .strip_suffix(')')
1184            .ok_or_else(|| malformed("has no closing `)` after the categories"))?;
1185        let (to, categories) = rest
1186            .split_once(CATEGORY_OPEN)
1187            .ok_or_else(|| malformed("has no ` (` before the categories"))?;
1188        let (from_category, to_category) = categories
1189            .split_once(DETAIL_ARROW)
1190            .ok_or_else(|| malformed("has no `from_category -> to_category`"))?;
1191        for (name, value) in [
1192            ("from", from),
1193            ("to", to),
1194            ("from_category", from_category),
1195            ("to_category", to_category),
1196        ] {
1197            if value.trim().is_empty() {
1198                return Err(malformed(name));
1199            }
1200        }
1201        Ok(Self {
1202            timestamp: event.timestamp.clone(),
1203            actor: event.actor.clone(),
1204            from: from.to_string(),
1205            to: to.to_string(),
1206            from_category: from_category.to_string(),
1207            to_category: to_category.to_string(),
1208            override_reason,
1209        })
1210    }
1211
1212    /// The History entry to append for this decision.
1213    pub fn to_event(&self) -> HistoryEvent {
1214        HistoryEvent::new(
1215            self.timestamp.clone(),
1216            self.actor.clone(),
1217            HistoryEventKind::TransitionAccepted,
1218            self.detail(),
1219        )
1220    }
1221
1222    /// Whether this decision is a same-column no-op record.
1223    ///
1224    /// The seam never appends one — idempotency is decided by
1225    /// `TransitionOutcome::Accepted { changed: false }` — but a **hand-edited** file
1226    /// can contain one, and WP06's `lint_direct_edits` needs to tell that apart
1227    /// from a real move rather than reading it as "status became todo".
1228    pub fn is_no_op(&self) -> bool {
1229        self.from == self.to
1230    }
1231}
1232
1233/// One recorded mutation, and one line of the `## History` section.
1234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1235pub struct HistoryEvent {
1236    /// When the mutation happened, RFC 3339.
1237    pub timestamp: String,
1238    /// Who performed it. Handled like [`Issue::assignee`]: a bare handle or a
1239    /// full identity, with a legacy `@alice` tolerated on read.
1240    pub actor: String,
1241    /// What kind of mutation this was.
1242    pub kind: HistoryEventKind,
1243    /// The field-aware description shown in detail views.
1244    pub detail: String,
1245}
1246
1247impl HistoryEvent {
1248    /// Build an event.
1249    pub fn new(
1250        timestamp: impl Into<String>,
1251        actor: impl Into<String>,
1252        kind: HistoryEventKind,
1253        detail: impl Into<String>,
1254    ) -> Self {
1255        Self {
1256            timestamp: timestamp.into(),
1257            actor: actor.into(),
1258            kind,
1259            detail: detail.into(),
1260        }
1261    }
1262
1263    /// The one-line form written to the file:
1264    /// `- <timestamp> — <actor> — <kind> — <detail>`.
1265    ///
1266    /// Four fields, not the three an earlier draft of `data-model.md` §8
1267    /// specified: a three-field form cannot carry `actor`, so the struct field
1268    /// would not survive a round trip and the audit trail would silently lose
1269    /// who performed each mutation. `data-model.md` §8 is corrected to match on
1270    /// the base and mission branches — it is a protected path, so the fix is not
1271    /// in this lane. **Read §8 there, not this comment**, if the two ever
1272    /// disagree.
1273    ///
1274    /// A blank actor renders as an empty field rather than being dropped, so
1275    /// the field count stays fixed and parsing never has to guess.
1276    fn render(&self) -> String {
1277        format!(
1278            "- {t} {h} {a} {h} {k} {h} {d}",
1279            t = self.timestamp.trim(),
1280            h = HISTORY_SEP,
1281            a = self.actor.trim(),
1282            k = self.kind.as_str(),
1283            d = self.detail
1284        )
1285    }
1286
1287    /// Parse one rendered list item.
1288    ///
1289    /// The four fields are split on the **first three** separators only, so a
1290    /// detail or actor containing an em dash survives intact.
1291    fn parse(line: &str) -> Result<Self> {
1292        let item = line
1293            .trim()
1294            .strip_prefix("- ")
1295            .ok_or_else(|| TixError::Parse(format!("History entry is not a list item: {line}")))?;
1296        // Split on the first three separators only, so a detail containing an
1297        // em dash survives intact. Fields are read **positionally** rather than
1298        // as "the next non-empty value": the actor may legitimately be blank,
1299        // which is exactly what `render` writes when no identity was recorded,
1300        // so demanding a non-empty value there would make the writer's own
1301        // output unparseable.
1302        let parts: Vec<&str> = item.splitn(4, HISTORY_SEP).collect();
1303        let missing =
1304            |what: &str| TixError::Parse(format!("History entry is missing `{what}`: {line}"));
1305        if parts.len() < 3 {
1306            return Err(missing("a field separator"));
1307        }
1308        let timestamp = parts[0].trim();
1309        if timestamp.is_empty() {
1310            return Err(missing("timestamp"));
1311        }
1312        let actor = parts[1].trim().to_string();
1313        let kind_text = parts[2].trim();
1314        if kind_text.is_empty() {
1315            return Err(missing("kind"));
1316        }
1317        let kind = kind_text.parse::<HistoryEventKind>()?;
1318        let detail = parts.get(3).copied().unwrap_or_default().trim().to_string();
1319        Ok(Self {
1320            timestamp: timestamp.to_string(),
1321            actor,
1322            kind,
1323            detail,
1324        })
1325    }
1326}
1327
1328/// Whether a line is a **recognised** History entry attempt.
1329/// Whether a line is **shaped like a markdown list item**, regardless of whether
1330/// it is a valid entry.
1331///
1332/// Used to decide where an unrecognised line belongs: a list item inside a
1333/// History section is far more likely a hand-edited entry than prose, and the two
1334/// are not told apart by anything else, because the section has no terminator.
1335///
1336/// All four common markers count, not just `- `. This is not a widening of the
1337/// entry grammar — recognition still requires U+2014, so nothing here can be read
1338/// as a relationship. With `- ` alone, an entry written
1339/// `* 2026-01-01T00:00:00Z — a — kind — d` was treated as prose, so the section
1340/// ended, the heading was deleted, and the next append was rendered above it.
1341fn is_list_item(item: &str) -> bool {
1342    for marker in ["- ", "* ", "+ "] {
1343        if item.starts_with(marker) {
1344            return true;
1345        }
1346    }
1347    // An ordered marker: digits then `.` or `)` then a space. Bounded to two
1348    // digits, or `2026. Q1 goals` reads as an ordered-list item and a paragraph
1349    // of ordinary body prose gets retained in the audit log.
1350    let Some(dot) = item.find(['.', ')']) else {
1351        return false;
1352    };
1353    dot > 0
1354        && dot <= 2
1355        && item[..dot].bytes().all(|b| b.is_ascii_digit())
1356        && item[dot + 1..].starts_with(' ')
1357}
1358
1359/// Whether a line is a **recognised** History entry attempt.
1360///
1361/// Recognition requires the U+2014 separator, and nothing else. That is
1362/// deliberately stricter than "looks like" one: a looser test here is not a
1363/// nicety, it decides whether a line is *moved into the audit log* or left in
1364/// the body. A timestamp-shaped heuristic accepted a body to-do item
1365/// (`- 2026-02-02 review the migration`) as an entry attempt, which emptied
1366/// `body` and made `tix lint` blame the author — the original M3 defect arriving
1367/// through a different door, while the documentation claimed the opposite.
1368///
1369/// A line that is genuinely an entry but was typed with ASCII hyphens is
1370/// therefore *not* recognised, and never becomes an event. It may still be
1371/// *retained* in the section; see [`looks_like_an_unrecognised_entry`].
1372pub fn looks_like_history_entry(line: &str) -> bool {
1373    let item = line.trim();
1374    let Some(rest) = item.strip_prefix("- ") else {
1375        return false;
1376    };
1377    rest.contains(HISTORY_SEP)
1378}
1379
1380/// Whether a line looks like a History entry that was **not** recognised — an
1381/// entry hand-typed with ASCII hyphens instead of U+2014.
1382///
1383/// This is the shape test behind two decisions, and they use it differently:
1384///
1385/// - `parse_front` asks whether an ambiguous line is a hand-edited *entry* rather
1386///   than body prose. Being wrong in that direction costs the author's bytes,
1387///   because a demoted line is body text and a later `tix edit --body` destroys
1388///   it with no trace.
1389/// - `tix lint` asks whether to *report* a line, where a false positive costs a
1390///   user-visible diagnostic on every run and a false negative costs a silent
1391///   typo.
1392///
1393/// A mistyped entry has the whole shape of one: a date, then three further
1394/// hyphen-separated fields — `<timestamp> - <actor> - <kind> - <detail>`. Both
1395/// halves are required. An earlier version accepted any 10-character run of
1396/// digits, dashes and punctuation, which flagged `- 10:00-11:00 sync with infra`,
1397/// `- 1234567890 is the upstream ticket`, `+49-30-12345678` and `1.2.3.4.5.6`; a
1398/// later one required only three fields, which flagged `- 2026-01-01 - Bob -
1399/// Done`. Four fields is what the format actually specifies, so that is what is
1400/// required.
1401pub fn looks_like_an_unrecognised_entry(line: &str) -> bool {
1402    let Some(rest) = strip_list_marker(line) else {
1403        return false;
1404    };
1405    if rest.contains(HISTORY_SEP) {
1406        return false; // genuinely recognised; not this function's business
1407    }
1408    entry_shaped_by_ascii_hyphens(rest, STRICT_TAIL_FIELDS)
1409}
1410
1411/// Tail fields required to call a line a mistyped entry when **reporting** it.
1412///
1413/// Four fields in total, because that is the form carrying a detail; the shorter
1414/// three-field form is indistinguishable from a dated changelog line
1415/// (`- 2026-01-01 - Bob - Done`), and `tix lint` exits non-zero on a finding.
1416const STRICT_TAIL_FIELDS: usize = 3;
1417
1418/// Tail fields required to call a line an entry **attempt** when deciding whether
1419/// to retain it.
1420///
1421/// Two, i.e. three fields in total, which is the shortest form `HistoryEvent::parse`
1422/// accepts — `detail` is optional there. A retention test stricter than the
1423/// recognition path is backwards: a valid three-field entry would be demoted to
1424/// body text and then destroyed by the next `tix edit --body`, while the
1425/// malformed four-field one survived. Cycle 6 found that inversion.
1426const RETENTION_TAIL_FIELDS: usize = 2;
1427
1428/// Whether a line is an **entry attempt**: a list item whose content is shaped
1429/// like an entry, under any accepted marker.
1430///
1431/// This is the parser's question, and it is deliberately looser than
1432/// [`looks_like_an_unrecognised_entry`]'s, because the two have opposite costs.
1433/// Retaining a line that turns out to be prose moves it into the audit log *and
1434/// says so*; demoting a line that turns out to be an entry puts it in the body,
1435/// where the next `tix edit --body` deletes it with no trace. Retention is the
1436/// recoverable direction, so the parser errs towards retaining and the
1437/// diagnostic errs towards silence.
1438/// Whether a line introduces the `## History` section, under the same
1439/// tolerance the parser applies: trailing spaces or tabs are ignored, leading
1440/// whitespace is not.
1441///
1442/// Public so `tix lint` can report a **misplaced** section using the parser's own
1443/// rule. A second copy of this test could disagree with the parser about which
1444/// lines are headings, and then lint would report violations that do not exist
1445/// while missing ones that do.
1446pub fn is_history_heading(line: &str) -> bool {
1447    line.trim_end_matches([' ', '\t']) == HISTORY_HEADING
1448}
1449
1450fn is_entry_attempt(item: &str) -> bool {
1451    let Some(rest) = strip_list_marker(item) else {
1452        return false;
1453    };
1454    if rest.contains(HISTORY_SEP) {
1455        // Correct separators. A `*`- or `1.`-marked entry is not *recognised* —
1456        // both `looks_like_history_entry` and `HistoryEvent::parse` require the
1457        // `- ` prefix — but it is unmistakably an entry attempt, and treating it
1458        // as prose cost a well-formed audit line. Cycle 6 found this.
1459        return true;
1460    }
1461    entry_shaped_by_ascii_hyphens(rest, RETENTION_TAIL_FIELDS)
1462}
1463
1464/// Whether `rest` is a plausible entry body whose separators were typed as ASCII
1465/// hyphens: a date, then at least `min_tail` further hyphen-separated fields.
1466///
1467/// The tail splits on hyphens with optional surrounding spaces rather than on the
1468/// literal `" - "`, because an entry typed with ASCII hyphens is often typed
1469/// without the spaces too: `- 2026-01-01T00:00:00Z-alice-kind-d`. An earlier
1470/// version required `" - "` and so missed the case this tool exists to catch,
1471/// while the recognition path trims after the marker and accepted the same
1472/// whitespace — the identical line was "not an entry" and never mentioned.
1473fn entry_shaped_by_ascii_hyphens(rest: &str, min_tail: usize) -> bool {
1474    let Some(tail) = strip_leading_timestamp(rest) else {
1475        return false;
1476    };
1477    let parts: Vec<&str> = tail
1478        .split('-')
1479        .map(str::trim)
1480        .filter(|p| !p.is_empty())
1481        .collect();
1482    if parts.len() < min_tail {
1483        return false;
1484    }
1485    // The field right after the timestamp is an actor or a kind, and neither is a
1486    // date. Rejecting a numeric one is what keeps a date *range* out:
1487    // `- 2026-01-01..2026-01-05 was the freeze` otherwise splits into three
1488    // parts and reads as a mistyped entry.
1489    !starts_like_a_date(parts[0])
1490}
1491
1492/// Whether a token opens with a run of four or more digits — a year, a serial
1493/// number, a date. Used only to reject prose that would otherwise split into
1494/// enough hyphen-separated fields to look like an entry.
1495fn starts_like_a_date(token: &str) -> bool {
1496    let digits = token.bytes().take_while(u8::is_ascii_digit).count();
1497    digits >= 4
1498}
1499
1500/// The text after any accepted list marker, with whatever whitespace a hand put
1501/// after it removed.
1502///
1503/// `HistoryEvent::parse` trims after the prefix too, so this must as well:
1504/// otherwise `-  2026-…—…` and `-\t2026-…—…` are recognised as real entries while
1505/// their ASCII-hyphen equivalents are silently unreported.
1506fn strip_list_marker(line: &str) -> Option<&str> {
1507    let item = line.trim();
1508    if is_list_item(item) {
1509        let space = item.find(char::is_whitespace).unwrap_or(item.len());
1510        return Some(item[space..].trim_start());
1511    }
1512    None
1513}
1514
1515/// The remainder of a line whose first field begins with a plausible ISO date, or
1516/// `None` if it does not.
1517///
1518/// A date is the only field shape that separates a mistyped entry from an
1519/// ordinary dated bullet, and requiring it is what keeps `tix lint` quiet on
1520/// prose. Plausibility is deliberately not checked: rejecting a hand-typed
1521/// offset like `+02:00` would create a false negative in the one tool whose job
1522/// is to catch exactly this mistake, so implausible shapes are reported rather
1523/// than filtered.
1524fn strip_leading_timestamp(rest: &str) -> Option<&str> {
1525    let b = rest.as_bytes();
1526    if b.len() < 10
1527        || !b[0..4].iter().all(u8::is_ascii_digit)
1528        || b[4] != b'-'
1529        || !b[5..7].iter().all(u8::is_ascii_digit)
1530        || b[7] != b'-'
1531        || !b[8..10].iter().all(u8::is_ascii_digit)
1532    {
1533        return None;
1534    }
1535    let mut i = 10;
1536    if b.get(i) == Some(&b'T') {
1537        i += 1;
1538        while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':' || b[i] == b'.') {
1539            i += 1;
1540        }
1541        match b.get(i) {
1542            Some(b'Z') => i += 1,
1543            // A `-` is a zone separator only when digits follow it; otherwise it
1544            // is the first field separator and the timestamp ended at the time.
1545            Some(&b'-') => {
1546                let mut j = i + 1;
1547                while j < b.len() && b[j].is_ascii_digit() {
1548                    j += 1;
1549                }
1550                if j > i + 1 {
1551                    i = j;
1552                }
1553            }
1554            Some(&b'+') => {
1555                i += 1;
1556                while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':') {
1557                    i += 1;
1558                }
1559            }
1560            _ => {}
1561        }
1562    }
1563    // \`.\` too: a date *range* continues with \`..\`, and leaving it glued to
1564    // the next token makes \`- 2026-01-01..2026-01-05 was the freeze\` split into
1565    // three fields.
1566    Some(rest[i.min(rest.len())..].trim_start_matches(['-', ' ', '\t', '.']))
1567}
1568
1569/// One line of the `## History` section.
1570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1571#[serde(untagged)]
1572pub enum HistoryEntry {
1573    /// A well-formed event.
1574    Event(HistoryEvent),
1575    /// A line that could not be parsed, preserved **verbatim**.
1576    ///
1577    /// The section is documented as human-editable, so a hand edit will
1578    /// eventually produce a line this parser cannot read — ASCII hyphens
1579    /// instead of the U+2014 separators is the obvious one. Such a line is
1580    /// never dropped, never coerced, and never auto-repaired: it is carried
1581    /// through a read/write cycle unchanged and reported by `tix lint` as a
1582    /// `history` finding on that issue only. It must never fail the
1583    /// parse of the whole file, because one mistyped line in one issue would
1584    /// otherwise make every issue in the repository unreadable.
1585    Unparsed(String),
1586}
1587
1588/// The append-only event log of an issue (`data-model.md` §8).
1589///
1590/// Append-only is a hard guarantee of this type: the only mutating operation
1591/// is [`History::append`]. There is deliberately no remove, no reorder, no
1592/// trim, and no compaction — those are out of scope, but the container is
1593/// shaped so a future retention policy has a seam.
1594#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1595pub struct History {
1596    /// Every line of the section, in file order, which is append order.
1597    ///
1598    /// Held as an ordered list of entries rather than a flat `Vec<HistoryEvent>`
1599    /// so an **unparseable** line keeps its position and its exact text. That
1600    /// is what lets the section round-trip byte-stably without ever dropping or
1601    /// repairing a line a human hand-edited.
1602    pub entries: Vec<HistoryEntry>,
1603}
1604
1605impl History {
1606    /// An empty log.
1607    pub fn new() -> Self {
1608        Self::default()
1609    }
1610
1611    /// Append one event. Never rewrites, reorders, or removes an existing line.
1612    pub fn append(&mut self, event: HistoryEvent) {
1613        self.entries.push(HistoryEntry::Event(event));
1614    }
1615
1616    /// Append with the current UTC timestamp.
1617    pub fn append_now(&mut self, actor: &str, kind: HistoryEventKind, detail: &str) {
1618        self.append(HistoryEvent::new(utc_now_rfc3339(), actor, kind, detail));
1619    }
1620
1621    /// The parsed events, in file order.
1622    ///
1623    /// Unparseable lines are excluded here; see [`History::unparsed`].
1624    pub fn events(&self) -> Vec<&HistoryEvent> {
1625        self.entries
1626            .iter()
1627            .filter_map(|e| match e {
1628                HistoryEntry::Event(event) => Some(event),
1629                HistoryEntry::Unparsed(_) => None,
1630            })
1631            .collect()
1632    }
1633
1634    /// Lines that could not be parsed, verbatim and in file order.
1635    ///
1636    /// Non-empty means the section was hand-edited into a shape this parser
1637    /// does not accept. `tix lint` reports each as a `history`
1638    /// finding; nothing else treats it as an error.
1639    pub fn unparsed(&self) -> Vec<&str> {
1640        self.entries
1641            .iter()
1642            .filter_map(|e| match e {
1643                HistoryEntry::Event(_) => None,
1644                HistoryEntry::Unparsed(line) => Some(line.as_str()),
1645            })
1646            .collect()
1647    }
1648
1649    /// Whether the section has no lines at all.
1650    pub fn is_empty(&self) -> bool {
1651        self.entries.is_empty()
1652    }
1653
1654    /// How many lines the section holds, parsed or not.
1655    pub fn len(&self) -> usize {
1656        self.entries.len()
1657    }
1658
1659    /// Render the section, including its heading and the blank line after it.
1660    ///
1661    /// An empty log renders to the empty string, so the section is *omitted*
1662    /// from the file entirely rather than written as an empty heading. An
1663    /// unparseable line is re-emitted exactly as it was read, in place, so a
1664    /// read/write cycle is byte-stable whether or not the section is clean.
1665    pub fn render(&self) -> String {
1666        if self.is_empty() {
1667            return String::new();
1668        }
1669        let mut out = String::from(HISTORY_HEADING);
1670        out.push_str("\n\n");
1671        for entry in &self.entries {
1672            match entry {
1673                HistoryEntry::Event(event) => {
1674                    out.push_str(&event.render());
1675                    out.push('\n');
1676                }
1677                HistoryEntry::Unparsed(line) => {
1678                    out.push_str(line);
1679                    out.push('\n');
1680                }
1681            }
1682        }
1683        out.push('\n');
1684        out
1685    }
1686
1687    /// Parse a leading `## History` section, returning the log and whatever body
1688    /// text followed it.
1689    ///
1690    /// Only a section at the *front* of the body is recognised, matching the
1691    /// position `data-model.md` §8 pins (immediately after the frontmatter). A
1692    /// `## History` heading appearing later in the prose is left alone as
1693    /// ordinary body text.
1694    ///
1695    /// The section ends at the first line that is neither blank nor a list item,
1696    /// so a **blank line between entries does not end it** — a blank line inside
1697    /// a list is the most ordinary thing a human or a Markdown formatter
1698    /// produces, and terminating there would silently demote audit entries to
1699    /// body prose and reorder the log. Unparseable list items are kept verbatim
1700    /// rather than failing the file.
1701    pub fn parse_front(body: &str) -> (Self, String) {
1702        let lead = body.len() - body.trim_start_matches('\n').len();
1703        let trimmed = &body[lead..];
1704        let Some(rest) = trimmed.strip_prefix(HISTORY_HEADING) else {
1705            return (History::new(), body.to_string());
1706        };
1707        // The heading must be alone on its line — but trailing spaces and tabs on
1708        // it are still that line. Requiring a bare `\n` meant `## History   ` was
1709        // not recognised at all, and since the heading is then neither the
1710        // section nor body, the *first* relationship write rendered a second
1711        // `## History` above the original. Cycle 6 executed that; it had been
1712        // filed against #1299 as a CRLF-only consequence, which understated it —
1713        // a trailing space is a far likelier trigger than CRLF.
1714        let rest = rest.trim_start_matches([' ', '\t']);
1715        let Some(after_heading) = rest.strip_prefix('\n') else {
1716            return (History::new(), body.to_string());
1717        };
1718        let after_heading = after_heading.trim_start_matches('\n');
1719
1720        let mut history = History::new();
1721        let mut offset = 0usize;
1722        for line in after_heading.split_inclusive('\n') {
1723            // `item` is the trimmed form used for *shape* decisions; `raw_line`
1724            // is what a preserved `Unparsed` entry stores, so a hand edit keeps
1725            // its own bytes.
1726            let raw_line = line;
1727            let item = raw_line.trim();
1728            if item.is_empty() {
1729                // A blank line *inside* the section is skipped, not treated as
1730                // the end of it. It is consumed, so the body that follows does
1731                // not start with stray blank lines.
1732                offset += line.len();
1733                continue;
1734            }
1735            if !looks_like_history_entry(item) {
1736                // The section has no end-of-section delimiter, so this line is
1737                // ambiguous: either body prose, or a hand-edited line *inside*
1738                // the section. The two are told apart by what follows.
1739                //
1740                // If every remaining non-blank line is entry-shaped, the
1741                // ambiguous line was a hand edit inside the section, so the
1742                // section continues and the line is kept verbatim as
1743                // `Unparsed` — nothing is dropped and the real events after it
1744                // stay events. If anything after it is not entry-shaped, this
1745                // is body prose and the section genuinely ends here.
1746                //
1747                // The alternative — absorbing any `- ` line — moved body content
1748                // into the audit log, deleted the blank line separating them on
1749                // the first write, and then had `tix lint` blaming the author's
1750                // prose as unparseable entries. Trigger was ordinary:
1751                // `tix edit --body "- step one\n- step two"`.
1752                // Collected rather than consumed with `next()` + `all()`: that
1753                // skips judging the *first* following line, and `all()` over an
1754                // empty remainder is vacuously true — so a section followed by a
1755                // single non-entry line was absorbed. Both mistakes are the same
1756                // one: judging nothing and concluding "all of it is entries".
1757                let following: Vec<&str> = after_heading[offset + line.len()..]
1758                    .lines()
1759                    .filter(|l| !l.trim().is_empty())
1760                    .collect();
1761                // Two different questions, and conflating them is what broke this
1762                // twice.
1763                //
1764                // *Does anything after this disqualify it as body prose?* When
1765                // nothing follows, nothing can — `all()` is then vacuously true
1766                // and that is the **correct** answer, not a bug. A guard
1767                // requiring a non-empty remainder (added to stop an earlier
1768                // vacuous-truth defect) made the last line in a section always
1769                // terminate it, which deleted the `## History` heading, demoted
1770                // the author's line into the body, and rendered the next append
1771                // *above* it — file order stopped being append order, and
1772                // `tix lint` said nothing because a demoted line is body prose
1773                // and the unparsed loop no longer sees it.
1774                //
1775                // *Is this line shaped like a list item at all?* That still
1776                // decides, at end of section, whether keeping it is defensible.
1777                // `- step one` at EOF is far more likely a hand-edited entry than
1778                // the only body content of an issue, and keeping it *discloses*
1779                // the problem. A bare paragraph at EOF is prose, full stop, and
1780                // absorbing it would pollute the audit log.
1781                // Three inputs, and which ones matter depends on whether
1782                // anything follows. Cycle 5 found this half-fixed: consulting
1783                // only `is_list_item`, and only when nothing followed, left the
1784                // whole defect alive for any ambiguous line with body prose
1785                // after it.
1786                //
1787                // The asymmetry that decides the design: a *demoted* line is body
1788                // text, and `tix edit --body` replaces the body wholesale — so a
1789                // hand-edited entry that was demoted is destroyed by an ordinary
1790                // command, with lint silent. A *retained* line is kept verbatim
1791                // inside the section and reported. Given the choice, retaining is
1792                // the recoverable one, so the tie only goes to "this is prose"
1793                // when the line is not plausibly an entry at all.
1794                let plausible_entry = is_entry_attempt(item);
1795                let after_all_entries = following.iter().all(|l| looks_like_history_entry(l));
1796                let keep = if following.is_empty() {
1797                    // Nothing after to judge by, so shape alone decides. A bare
1798                    // paragraph at end of section is prose; anything list-shaped
1799                    // is kept, because there is no following content that could
1800                    // have been prose instead.
1801                    is_list_item(item)
1802                } else {
1803                    plausible_entry || after_all_entries
1804                };
1805                if !keep {
1806                    break;
1807                }
1808                // The **raw** line, not the trimmed one: `Unparsed` promises a
1809                // hand edit is carried through a read/write cycle unchanged, and
1810                // trimming silently repaired leading indentation and trailing
1811                // whitespace — exactly the auto-repair data-model §8 forbids.
1812                history.entries.push(HistoryEntry::Unparsed(
1813                    raw_line.trim_end_matches('\n').to_string(),
1814                ));
1815                offset += line.len();
1816                continue;
1817            }
1818            match HistoryEvent::parse(item) {
1819                Ok(event) => history.entries.push(HistoryEntry::Event(event)),
1820                // Kept verbatim. Diagnosed by `tix lint`, never repaired, never
1821                // dropped, and never fatal to the file.
1822                // Raw line, for the same reason as above.
1823                // Terminator only. `trim_end()` also eats trailing spaces and
1824                // tabs, which is the auto-repair data-model §8 forbids — and
1825                // `Err` is the arm a *recognised* entry lands in, so it is the
1826                // more likely one to carry real trailing whitespace.
1827                Err(_) => history.entries.push(HistoryEntry::Unparsed(
1828                    raw_line.trim_end_matches('\n').to_string(),
1829                )),
1830            }
1831            offset += line.len();
1832        }
1833
1834        (history, after_heading[offset..].to_string())
1835    }
1836}
1837
1838impl Issue {
1839    /// Parse a full issue file (`frontmatter` + `body`) from its text.
1840    ///
1841    /// The first line must be `---`; the closing `---` terminates the
1842    /// frontmatter. The body after it is preserved verbatim (the single
1843    /// structural separator blank line is consumed).
1844    pub fn parse(contents: &str) -> Result<Self> {
1845        let rest = contents
1846            .strip_prefix("---\n")
1847            .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
1848        let mut current = rest;
1849        let mut fm_len = 0usize;
1850        loop {
1851            let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
1852            let content = current[..line_end]
1853                .strip_suffix('\n')
1854                .unwrap_or(&current[..line_end]);
1855            if content == "---" {
1856                break;
1857            }
1858            fm_len += line_end;
1859            current = &current[line_end..];
1860            if current.is_empty() {
1861                return Err(TixError::Parse(
1862                    "frontmatter is not terminated by a closing `---`".into(),
1863                ));
1864            }
1865        }
1866        let frontmatter = &rest[..fm_len];
1867        let body_raw = &rest[fm_len..];
1868        let mut body = body_raw.strip_prefix("---\n").unwrap_or(body_raw);
1869        if let Some(after) = body.strip_prefix('\n') {
1870            body = after;
1871        }
1872        let mut issue: Issue =
1873            serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
1874        // A legacy `links:` key is recorded, never read. The typed model does
1875        // not migrate it and does not reinterpret it as typed edges; the
1876        // service reports it as a `retired_links_key` validation result (C-001).
1877        // `relationships` stays as serde left it — empty.
1878        issue.has_retired_links_key = frontmatter_has_retired_links_key(frontmatter);
1879        // Unknown top-level keys are captured so the next write can put them back
1880        // (#1361). `links` is excluded on purpose — see [`capture_extra_frontmatter`].
1881        issue.extra_frontmatter = capture_extra_frontmatter(frontmatter);
1882        let (history, remaining_body) = History::parse_front(body);
1883        issue.history = history;
1884        issue.body = remaining_body;
1885        Ok(issue)
1886    }
1887
1888    /// Serialize just the frontmatter YAML (no delimiters), with `None`
1889    /// fields omitted except `created`/`updated` which are always present.
1890    pub fn frontmatter(&self) -> String {
1891        let fm =
1892            serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
1893        let mut fm = fm.trim_end_matches('\n').to_string();
1894        // Unknown keys are appended after the known ones, in `BTreeMap` order, so
1895        // the render is deterministic. A `HashMap` would reshuffle them on every
1896        // write and produce a diff in a file that had not changed (#1361).
1897        //
1898        // They go last rather than back in their original position because the
1899        // positions of the *known* keys move as optional fields come and go;
1900        // re-inserting at the original index would be a guess. `tix` has no
1901        // opinion about these keys, so where they sit carries no meaning.
1902        for (key, block) in &self.extra_frontmatter {
1903            fm.push('\n');
1904            fm.push_str(block);
1905            let _ = key;
1906        }
1907        fm.push('\n');
1908        fm
1909    }
1910
1911    /// Render the full issue file: `---`, frontmatter, closing `---`,
1912    /// blank separator and the raw body.
1913    pub fn to_file_contents(&self) -> String {
1914        // Frontmatter, then the History section (omitted when empty), then the
1915        // issue's own body — the order `data-model.md` §8 pins.
1916        let history = self.history.render();
1917        let mut out = format!("---\n{}---\n\n", self.frontmatter());
1918        out.push_str(&history);
1919        out.push_str(&self.body);
1920        out
1921    }
1922
1923    /// A borrowed [`IssueView`] of this issue, for the relationship service.
1924    ///
1925    /// Keeps the service off the full `Issue` surface: it needs only the
1926    /// number, title, status, relationships, and whether the legacy `links:`
1927    /// key is still present.
1928    pub fn issue_view(&self) -> IssueView<'_> {
1929        IssueView::new(
1930            self.number,
1931            &self.title,
1932            self.status.display(),
1933            &self.relationships,
1934        )
1935        .with_retired_links_key(self.has_retired_links_key)
1936        .with_parent(self.parent)
1937    }
1938
1939    /// The date-prefixed filename stem: `{created}-{slug}` (C-008).
1940    pub fn filename_stem(&self) -> String {
1941        format!("{}-{}", self.created, slugify(&self.title))
1942    }
1943
1944    /// Record a mutation by stamping today's date into `updated`.
1945    pub fn bump_updated(&mut self) {
1946        self.updated = today();
1947    }
1948
1949    /// Set `start_date`/`due_date` together after atomic validation (FR-007):
1950    /// both must parse as calendar dates and `due_date >= start_date` when
1951    /// both are present (delegates to [`validate_issue_dates`]).
1952    pub fn set_dates(
1953        &mut self,
1954        start_date: Option<String>,
1955        due_date: Option<String>,
1956    ) -> Result<()> {
1957        validate_issue_dates(start_date.as_deref(), due_date.as_deref())?;
1958        self.start_date = start_date;
1959        self.due_date = due_date;
1960        Ok(())
1961    }
1962
1963    /// The byte offset of the first top-level `## Comments` line in `body`,
1964    /// if any (C-001, `contracts/comments-format.md` rule 6).
1965    ///
1966    /// A line counts as the boundary only when it is *not* inside a fenced
1967    /// code block, so a literal `## Comments` in prose (or a code block) is
1968    /// never mistaken for the real section head.
1969    fn comments_boundary(body: &str) -> Option<usize> {
1970        let mut in_fence = false;
1971        let mut offset = 0usize;
1972        for raw in body.split_inclusive('\n') {
1973            let line = raw.strip_suffix('\n').unwrap_or(raw);
1974            if line.starts_with("```") || line.starts_with("~~~") {
1975                in_fence = !in_fence;
1976            } else if !in_fence && line == "## Comments" {
1977                return Some(offset);
1978            }
1979            offset += raw.len();
1980        }
1981        None
1982    }
1983
1984    /// The description: everything above the first top-level `## Comments`
1985    /// heading, trimmed of trailing whitespace. With no section, the whole
1986    /// body is the description.
1987    pub fn description(&self) -> &str {
1988        match Self::comments_boundary(&self.body) {
1989            Some(idx) => self.body[..idx].trim_end(),
1990            None => self.body.trim_end(),
1991        }
1992    }
1993
1994    /// The comments parsed from the `## Comments` section, in file order
1995    /// (NFR-005). With no section — or a section with no `###` heads —
1996    /// returns an empty vec.
1997    pub fn comments(&self) -> Vec<Comment> {
1998        let Some(boundary) = Self::comments_boundary(&self.body) else {
1999            return Vec::new();
2000        };
2001        let section = &self.body[boundary..];
2002        let mut comments = Vec::new();
2003        let mut offset = 0usize;
2004        for raw in section.split_inclusive('\n') {
2005            let line = raw.strip_suffix('\n').unwrap_or(raw);
2006            if let Some(after) = line.strip_prefix("### ") {
2007                let (author, date) = parse_comment_head(after);
2008                let start = offset + raw.len();
2009                let mut text = String::new();
2010                for next in section[start..].split_inclusive('\n') {
2011                    if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
2012                        break;
2013                    }
2014                    text.push_str(next);
2015                }
2016                comments.push(Comment {
2017                    author,
2018                    date,
2019                    text: text.trim_end_matches('\n').to_string(),
2020                });
2021            }
2022            offset += raw.len();
2023        }
2024        comments
2025    }
2026
2027    /// Append a comment to the `## Comments` section.
2028    ///
2029    /// Whitespace-only text is discarded and the body is left untouched
2030    /// (FR-004 case 4). Otherwise the section is created if absent and the
2031    /// comment block is appended after the last existing comment. The
2032    /// description and any existing comments are never rewritten.
2033    pub fn add_comment(&mut self, author: &str, text: &str) {
2034        if text.trim().is_empty() {
2035            return;
2036        }
2037        if Self::comments_boundary(&self.body).is_none() {
2038            if !self.body.trim().is_empty() {
2039                if !self.body.ends_with('\n') {
2040                    self.body.push('\n');
2041                }
2042                self.body.push('\n');
2043            }
2044            self.body.push_str("## Comments\n");
2045        }
2046        if !self.body.is_empty() && !self.body.ends_with('\n') {
2047            self.body.push('\n');
2048        }
2049        if !self.body.ends_with("\n\n") {
2050            self.body.push('\n');
2051        }
2052        self.body
2053            .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
2054        self.bump_updated();
2055    }
2056}
2057
2058// ---------------------------------------------------------------------------
2059// The hierarchy service's view of an `Issue`
2060// ---------------------------------------------------------------------------
2061
2062/// Read in place, rather than through a borrowed projection.
2063///
2064/// Implemented here because `hierarchy` cannot import `model` without a cycle, and
2065/// because it is `Issue` — not [`IssueView`] — that every store-level caller already
2066/// holds. Without this each of them builds a `Vec<IssueView>` first, which is a
2067/// projection nobody asked for and one more place for two services to disagree about
2068/// the same snapshot.
2069impl crate::hierarchy::HierarchyNode for Issue {
2070    fn number(&self) -> u32 {
2071        self.number
2072    }
2073    fn title(&self) -> &str {
2074        &self.title
2075    }
2076    fn status(&self) -> &str {
2077        self.status.display()
2078    }
2079    fn parent_number(&self) -> Option<u32> {
2080        self.parent
2081    }
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086    use super::*;
2087    use crate::relationships::RelationKind;
2088
2089    #[test]
2090    fn status_helpers() {
2091        let s = Status::new("in progress");
2092        assert!(s.is("in progress"));
2093        assert!(!s.is("in_progress"));
2094        assert_eq!(s.display(), "in progress");
2095        assert_eq!(Status::default(), Status::new("todo"));
2096    }
2097
2098    #[test]
2099    fn status_survives_unknown_column_name() {
2100        let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
2101        assert_eq!(parsed.display(), "renamed-column");
2102    }
2103
2104    #[test]
2105    fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
2106        // An unset priority round-trips as the empty string and is *not* the
2107        // default: `priority: ''` says "no priority", which is a different
2108        // statement from the `medium` a missing field yields.
2109        let undefined: Priority = "".parse().unwrap();
2110        assert!(undefined.is_undefined());
2111        assert_eq!(undefined.to_string(), "", "and it writes back as ''");
2112        assert!(
2113            !Priority::default().is_undefined(),
2114            "the default is medium, not unset"
2115        );
2116        assert!(
2117            !Priority::Other("urgent".into()).is_undefined(),
2118            "only the empty string is unset"
2119        );
2120        assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
2121        assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
2122        assert_eq!(Priority::default(), Priority::Medium);
2123        assert_eq!(Priority::Low.to_string(), "low");
2124    }
2125
2126    #[test]
2127    fn unknown_priority_is_preserved_not_normalized() {
2128        let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
2129        assert_eq!(urgent.to_string(), "urgent");
2130        let yaml = serde_yaml::to_string(&urgent).unwrap();
2131        assert!(yaml.contains("urgent"));
2132    }
2133
2134    #[test]
2135    fn issue_roundtrip_preserves_every_field_including_number_and_body() {
2136        let mut issue = Issue {
2137            number: 7,
2138            title: "Add documentation for the feature".into(),
2139            status: Status::new("todo"),
2140            labels: vec!["docs".into(), "onboarding".into()],
2141            priority: Priority::High,
2142            assignee: Some("alice".into()),
2143            estimate: Some("2".into()),
2144            commit: Some("abc123".into()),
2145            gitlab_id: Some("gl-9".into()),
2146            linear_id: Some("LIN-9".into()),
2147            mission: None,
2148            task: None,
2149            relationships: vec![],
2150            history: crate::model::History::new(),
2151            has_retired_links_key: false,
2152            files: vec![],
2153            cycles: None,
2154            milestones: None,
2155            reviewer: None,
2156            start_date: None,
2157            due_date: None,
2158            created: "2026-05-10".into(),
2159            updated: "2026-05-10".into(),
2160            filename: String::new(),
2161            body: "Describe the issue here.\n\n![diagram](./assets/diagram.png)".into(),
2162            extra_frontmatter: std::collections::BTreeMap::new(),
2163            parent: None,
2164            archived: false,
2165            entered_at: None,
2166            source: None,
2167        };
2168        let contents = issue.to_file_contents();
2169        let parsed = Issue::parse(&contents).unwrap();
2170        assert_eq!(parsed, issue);
2171        issue.body.clear();
2172        assert_ne!(parsed, issue);
2173    }
2174
2175    #[test]
2176    fn typed_relationships_round_trip_preserves_entries_verbatim() {
2177        let mut issue = Issue {
2178            number: 9,
2179            title: "T".into(),
2180            status: Status::default(),
2181            labels: vec![],
2182            priority: Priority::default(),
2183            assignee: None,
2184            estimate: None,
2185            commit: None,
2186            gitlab_id: None,
2187            linear_id: None,
2188            mission: None,
2189            task: None,
2190            relationships: vec![
2191                Relationship::new(9, 12, RelationKind::Blocks, "Twelve"),
2192                Relationship::new(9, 47, RelationKind::Related, "Forty seven"),
2193            ],
2194            history: History::new(),
2195            has_retired_links_key: false,
2196            files: vec!["docs/x.md".into(), "specs/y/spec.md".into()],
2197            cycles: None,
2198            milestones: None,
2199            reviewer: None,
2200            start_date: None,
2201            due_date: None,
2202            created: "2026-01-01".into(),
2203            updated: "2026-01-01".into(),
2204            filename: String::new(),
2205            body: String::new(),
2206            extra_frontmatter: std::collections::BTreeMap::new(),
2207            parent: None,
2208            archived: false,
2209            entered_at: None,
2210            source: None,
2211        };
2212        let contents = issue.to_file_contents();
2213        let parsed = Issue::parse(&contents).unwrap();
2214        assert_eq!(parsed.relationships.len(), 2);
2215        assert_eq!(parsed.relationships[0].kind, RelationKind::Blocks);
2216        assert_eq!(parsed.relationships[0].display, "blocks #12 Twelve");
2217        // The stable id survives the round trip, which is what makes an edit
2218        // addressable after a rewrite.
2219        assert_eq!(parsed.relationships[0].id, issue.relationships[0].id);
2220        assert_eq!(parsed.files, ["docs/x.md", "specs/y/spec.md"]);
2221        // Byte-stable through read -> write -> read.
2222        assert_eq!(parsed.to_file_contents(), contents);
2223        // A stored edge never carries the input-only `blocked_by` kind.
2224        assert!(
2225            parsed
2226                .relationships
2227                .iter()
2228                .all(|r| r.kind != RelationKind::BlockedBy)
2229        );
2230        issue.relationships.clear();
2231    }
2232
2233    #[test]
2234    fn empty_relationships_is_omitted_while_files_keeps_its_inline_empty_list() {
2235        // The empty-list shape is decided once and pinned here: the typed field
2236        // is **omitted** when empty, because it is new and a meaningless
2237        // `relationships: []` would otherwise appear on every issue file. The
2238        // pre-existing `files: []` convention is untouched.
2239        let issue = Issue {
2240            number: 4,
2241            title: "T".into(),
2242            status: Status::default(),
2243            labels: vec![],
2244            priority: Priority::default(),
2245            assignee: None,
2246            estimate: None,
2247            commit: None,
2248            gitlab_id: None,
2249            linear_id: None,
2250            mission: None,
2251            task: None,
2252            relationships: vec![],
2253            history: History::new(),
2254            has_retired_links_key: false,
2255            files: vec![],
2256            cycles: None,
2257            milestones: None,
2258            reviewer: None,
2259            start_date: None,
2260            due_date: None,
2261            created: "2026-01-01".into(),
2262            updated: "2026-01-01".into(),
2263            filename: String::new(),
2264            body: String::new(),
2265            extra_frontmatter: std::collections::BTreeMap::new(),
2266            parent: None,
2267            archived: false,
2268            entered_at: None,
2269            source: None,
2270        };
2271        let fm = issue.frontmatter();
2272        assert!(!fm.contains("relationships"), "got:\n{fm}");
2273        assert!(fm.contains("files: []"), "got:\n{fm}");
2274    }
2275
2276    #[test]
2277    fn an_unknown_relation_kind_in_frontmatter_is_rejected() {
2278        // The old field tolerated any string, so a non-numeric entry parsed
2279        // happily. The typed vocabulary is closed, so an unknown kind is a
2280        // parse error rather than a coerced default (C-001).
2281        let yaml = "\
2282number: 1
2283title: T
2284status: todo
2285relationships:
2286- id: rel_1
2287  from: 1
2288  to: 2
2289  kind: depends_on
2290created: '2026-01-01'
2291updated: '2026-01-01'
2292";
2293        let err = serde_yaml::from_str::<Issue>(yaml).unwrap_err();
2294        assert!(
2295            err.to_string().contains("depends_on"),
2296            "the unknown kind is named: {err}"
2297        );
2298    }
2299
2300    #[test]
2301    fn a_legacy_links_key_parses_but_sets_the_retired_flag() {
2302        // Tolerant on read so existing repositories still load, and flagged so
2303        // the service can report it. The value is never read as typed data.
2304        let yaml = "\
2305number: 1
2306title: T
2307status: todo
2308links:
2309- '12'
2310- 47
2311created: '2026-01-01'
2312updated: '2026-01-01'
2313";
2314        let issue = Issue::parse(&format!("---\n{yaml}---\n\nbody\n")).unwrap();
2315        assert!(issue.has_retired_links_key);
2316        assert!(
2317            issue.relationships.is_empty(),
2318            "C-001: legacy values are never reinterpreted as typed edges"
2319        );
2320    }
2321
2322    #[test]
2323    fn missing_dates_default_to_today() {
2324        let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
2325        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
2326        assert_eq!(issue.created, today());
2327        assert_eq!(issue.updated, today());
2328    }
2329
2330    #[test]
2331    fn body_is_preserved_verbatim() {
2332        let issue = Issue {
2333            number: 1,
2334            title: "T".into(),
2335            status: Status::default(),
2336            labels: vec![],
2337            priority: Priority::default(),
2338            assignee: None,
2339            estimate: None,
2340            commit: None,
2341            gitlab_id: None,
2342            linear_id: None,
2343            mission: None,
2344            task: None,
2345            relationships: vec![],
2346            history: crate::model::History::new(),
2347            has_retired_links_key: false,
2348            files: vec![],
2349            cycles: None,
2350            milestones: None,
2351            reviewer: None,
2352            start_date: None,
2353            due_date: None,
2354            created: "2026-01-02".into(),
2355            updated: "2026-01-02".into(),
2356            filename: String::new(),
2357            body: "line one\n\nline two\n```\ncode\n```".into(),
2358            extra_frontmatter: std::collections::BTreeMap::new(),
2359            parent: None,
2360            archived: false,
2361            entered_at: None,
2362            source: None,
2363        };
2364        let contents = issue.to_file_contents();
2365        let parsed = Issue::parse(&contents).unwrap();
2366        assert_eq!(parsed.body, issue.body);
2367    }
2368
2369    #[test]
2370    fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
2371        let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
2372        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
2373        assert_eq!(issue.assignee.as_deref(), Some("alice"));
2374        let rerendered = issue.frontmatter();
2375        assert!(
2376            rerendered.contains("assignee: \"@alice\"")
2377                || rerendered.contains("assignee: '@alice'")
2378        );
2379    }
2380
2381    #[test]
2382    fn full_identity_assignee_round_trips_verbatim() {
2383        let issue = Issue {
2384            number: 5,
2385            title: "T".into(),
2386            status: Status::default(),
2387            labels: vec![],
2388            priority: Priority::default(),
2389            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
2390            estimate: None,
2391            commit: None,
2392            gitlab_id: None,
2393            linear_id: None,
2394            mission: None,
2395            task: None,
2396            relationships: vec![],
2397            files: vec![],
2398            cycles: None,
2399            milestones: None,
2400            reviewer: None,
2401            start_date: None,
2402            due_date: None,
2403            created: "2026-01-01".into(),
2404            updated: "2026-01-01".into(),
2405            filename: String::new(),
2406            body: String::new(),
2407            history: crate::model::History::new(),
2408            has_retired_links_key: false,
2409            extra_frontmatter: std::collections::BTreeMap::new(),
2410            parent: None,
2411            archived: false,
2412            entered_at: None,
2413            source: None,
2414        };
2415        let fm = issue.frontmatter();
2416        assert!(
2417            fm.contains("Herbert Maschke <thyseus@pm.me>"),
2418            "identity must be stored verbatim, got:\n{fm}"
2419        );
2420        assert!(
2421            !fm.contains("@Herbert Maschke"),
2422            "identity must not be @-prefixed, got:\n{fm}"
2423        );
2424        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
2425        assert_eq!(parsed, issue);
2426    }
2427
2428    #[test]
2429    fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
2430        let issue = Issue {
2431            number: 6,
2432            title: "T".into(),
2433            status: Status::default(),
2434            labels: vec![],
2435            priority: Priority::default(),
2436            assignee: Some("alice".into()),
2437            estimate: None,
2438            commit: None,
2439            gitlab_id: None,
2440            linear_id: None,
2441            mission: None,
2442            task: None,
2443            relationships: vec![],
2444            files: vec![],
2445            cycles: None,
2446            milestones: None,
2447            reviewer: None,
2448            start_date: None,
2449            due_date: None,
2450            created: "2026-01-01".into(),
2451            updated: "2026-01-01".into(),
2452            filename: String::new(),
2453            body: String::new(),
2454            history: crate::model::History::new(),
2455            has_retired_links_key: false,
2456            extra_frontmatter: std::collections::BTreeMap::new(),
2457            parent: None,
2458            archived: false,
2459            entered_at: None,
2460            source: None,
2461        };
2462        let fm = issue.frontmatter();
2463        assert!(
2464            fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
2465            "legacy @-prefixed bytes expected, got:\n{fm}"
2466        );
2467        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
2468        assert_eq!(parsed.assignee.as_deref(), Some("alice"));
2469    }
2470
2471    #[test]
2472    fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
2473        let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
2474        let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
2475        let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
2476        let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
2477        assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
2478        assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
2479    }
2480
2481    #[test]
2482    fn angle_bracket_identity_without_name_written_verbatim() {
2483        let issue = Issue {
2484            number: 8,
2485            title: "T".into(),
2486            status: Status::default(),
2487            labels: vec![],
2488            priority: Priority::default(),
2489            assignee: Some("<thyseus@pm.me>".into()),
2490            estimate: None,
2491            commit: None,
2492            gitlab_id: None,
2493            linear_id: None,
2494            mission: None,
2495            task: None,
2496            relationships: vec![],
2497            files: vec![],
2498            cycles: None,
2499            milestones: None,
2500            reviewer: None,
2501            start_date: None,
2502            due_date: None,
2503            created: "2026-01-01".into(),
2504            updated: "2026-01-01".into(),
2505            filename: String::new(),
2506            body: String::new(),
2507            history: crate::model::History::new(),
2508            has_retired_links_key: false,
2509            extra_frontmatter: std::collections::BTreeMap::new(),
2510            parent: None,
2511            archived: false,
2512            entered_at: None,
2513            source: None,
2514        };
2515        let fm = issue.frontmatter();
2516        assert!(
2517            fm.contains("<thyseus@pm.me>"),
2518            "identity-like value must be verbatim, got:\n{fm}"
2519        );
2520        assert!(
2521            !fm.contains("@<thyseus"),
2522            "identity-like value must not be @-prefixed, got:\n{fm}"
2523        );
2524    }
2525
2526    #[test]
2527    fn all_none_frontmatter_only_contains_required_keys() {
2528        let issue = Issue {
2529            number: 4,
2530            title: "T".into(),
2531            status: Status::default(),
2532            labels: vec![],
2533            priority: Priority::default(),
2534            assignee: None,
2535            estimate: None,
2536            commit: None,
2537            gitlab_id: None,
2538            linear_id: None,
2539            mission: None,
2540            task: None,
2541            relationships: vec![],
2542            files: vec![],
2543            cycles: None,
2544            milestones: None,
2545            reviewer: None,
2546            start_date: None,
2547            due_date: None,
2548            created: "2026-01-01".into(),
2549            updated: "2026-01-01".into(),
2550            filename: String::new(),
2551            body: String::new(),
2552            history: crate::model::History::new(),
2553            has_retired_links_key: false,
2554            extra_frontmatter: std::collections::BTreeMap::new(),
2555            parent: None,
2556            archived: false,
2557            entered_at: None,
2558            source: None,
2559        };
2560        let fm = issue.frontmatter();
2561        assert!(fm.contains("number:"));
2562        assert!(fm.contains("title:"));
2563        assert!(fm.contains("status:"));
2564        assert!(fm.contains("labels:"));
2565        assert!(fm.contains("priority:"));
2566        assert!(fm.contains("created:"));
2567        assert!(fm.contains("updated:"));
2568        for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
2569            assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
2570        }
2571    }
2572
2573    #[test]
2574    fn filename_stem_matches_contract_format() {
2575        let issue = Issue {
2576            number: 1,
2577            title: "Fix !Login crash?".into(),
2578            status: Status::default(),
2579            labels: vec![],
2580            priority: Priority::default(),
2581            assignee: None,
2582            estimate: None,
2583            commit: None,
2584            gitlab_id: None,
2585            linear_id: None,
2586            mission: None,
2587            task: None,
2588            relationships: vec![],
2589            files: vec![],
2590            cycles: None,
2591            milestones: None,
2592            reviewer: None,
2593            start_date: None,
2594            due_date: None,
2595            created: "2026-05-10".into(),
2596            updated: "2026-05-10".into(),
2597            filename: String::new(),
2598            body: String::new(),
2599            history: crate::model::History::new(),
2600            has_retired_links_key: false,
2601            extra_frontmatter: std::collections::BTreeMap::new(),
2602            parent: None,
2603            archived: false,
2604            entered_at: None,
2605            source: None,
2606        };
2607        assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
2608    }
2609
2610    #[test]
2611    fn bump_updated_stamps_today() {
2612        let mut issue = Issue {
2613            number: 1,
2614            title: "T".into(),
2615            status: Status::default(),
2616            labels: vec![],
2617            priority: Priority::default(),
2618            assignee: None,
2619            estimate: None,
2620            commit: None,
2621            gitlab_id: None,
2622            linear_id: None,
2623            mission: None,
2624            task: None,
2625            relationships: vec![],
2626            files: vec![],
2627            cycles: None,
2628            milestones: None,
2629            reviewer: None,
2630            start_date: None,
2631            due_date: None,
2632            created: "2020-01-01".into(),
2633            updated: "2020-01-01".into(),
2634            filename: String::new(),
2635            body: String::new(),
2636            history: crate::model::History::new(),
2637            has_retired_links_key: false,
2638            extra_frontmatter: std::collections::BTreeMap::new(),
2639            parent: None,
2640            archived: false,
2641            entered_at: None,
2642            source: None,
2643        };
2644        issue.bump_updated();
2645        assert_eq!(issue.updated, today());
2646        assert_ne!(issue.updated, "2020-01-01");
2647    }
2648
2649    #[test]
2650    fn parse_rejects_malformed_frontmatter() {
2651        assert!(Issue::parse("no delimiter\n").is_err());
2652        assert!(Issue::parse("---\na: 1\n").is_err()); // unterminated
2653        assert!(Issue::parse("---\nbroken: [").is_err()); // bad yaml, unterminated
2654    }
2655
2656    #[test]
2657    fn today_is_an_iso_calendar_date() {
2658        let t = today();
2659        assert_eq!(t.len(), 10);
2660        let parts: Vec<&str> = t.split('-').collect();
2661        assert_eq!(parts.len(), 3);
2662        assert_eq!(parts[0].len(), 4);
2663        assert_eq!(parts[1].len(), 2);
2664        assert_eq!(parts[2].len(), 2);
2665    }
2666
2667    #[test]
2668    fn civil_from_days_epoch_is_1970_01_01() {
2669        assert_eq!(civil_from_days(0), (1970, 1, 1));
2670    }
2671
2672    // ------------------------------------------------------------------
2673    // WP01 — Comment data model & body lifecycle
2674    // ------------------------------------------------------------------
2675
2676    fn issue(body: &str) -> Issue {
2677        Issue {
2678            number: 1,
2679            title: "T".into(),
2680            status: Status::default(),
2681            labels: vec![],
2682            priority: Priority::default(),
2683            assignee: None,
2684            estimate: None,
2685            commit: None,
2686            gitlab_id: None,
2687            linear_id: None,
2688            mission: None,
2689            task: None,
2690            relationships: vec![],
2691            history: History::new(),
2692            has_retired_links_key: false,
2693            files: vec![],
2694            cycles: None,
2695            milestones: None,
2696            reviewer: None,
2697            start_date: None,
2698            due_date: None,
2699            created: "2026-01-01".into(),
2700            updated: "2026-01-01".into(),
2701            filename: String::new(),
2702            body: body.to_string(),
2703            extra_frontmatter: std::collections::BTreeMap::new(),
2704            parent: None,
2705            archived: false,
2706            entered_at: None,
2707            source: None,
2708        }
2709    }
2710
2711    // --- T001: parse body → description + comments ---
2712
2713    #[test]
2714    fn comments_empty_when_no_section() {
2715        let i = issue("Some description text.\n");
2716        assert_eq!(i.description(), "Some description text.");
2717        assert!(i.comments().is_empty());
2718    }
2719
2720    #[test]
2721    fn comments_parsed_from_section() {
2722        let i = issue(
2723            "Hello\n\n## Comments\n\n\
2724             ### @alice (2026-09-13)\n\n\
2725             First comment.\n\n\
2726             ### bob (2026-09-14)\n\n\
2727             Second comment.\n",
2728        );
2729        assert_eq!(i.description(), "Hello");
2730        let cs = i.comments();
2731        assert_eq!(cs.len(), 2);
2732        assert_eq!(cs[0].author, "@alice");
2733        assert_eq!(cs[0].date, "2026-09-13");
2734        assert_eq!(cs[1].author, "bob");
2735        assert_eq!(cs[1].date, "2026-09-14");
2736    }
2737
2738    #[test]
2739    fn code_fence_boundary_not_treated_as_boundary() {
2740        let body = "Description\n\n\
2741             ```\n\
2742             ## Comments\n\
2743             ```\n\n\
2744             ## Comments\n\n\
2745             ### eve (2026-10-01)\n\n\
2746             real comment\n";
2747        let i = issue(body);
2748        let cs = i.comments();
2749        assert_eq!(cs.len(), 1);
2750        assert_eq!(cs[0].author, "eve");
2751        assert_eq!(cs[0].date, "2026-10-01");
2752        assert_eq!(cs[0].text, "\nreal comment");
2753        assert!(
2754            i.description().contains("## Comments"),
2755            "code-blocked heading must be in description"
2756        );
2757    }
2758
2759    #[test]
2760    fn blank_lines_preserved_in_comment_text() {
2761        let i = issue(
2762            "## Comments\n\n\
2763             ### @alice (2026-09-13)\n\n\
2764             line one\n\nline two\n\n\n\
2765             line three\n",
2766        );
2767        let cs = i.comments();
2768        assert_eq!(cs.len(), 1);
2769        let text = &cs[0].text;
2770        assert!(
2771            text.contains("\nline two"),
2772            "internal blank lines must be preserved: got {text:?}"
2773        );
2774        assert!(
2775            text.contains("line three"),
2776            "must contain all lines: got {text:?}"
2777        );
2778    }
2779
2780    #[test]
2781    fn comments_empty_when_section_has_no_heads() {
2782        let i = issue("## Comments\n\n\n");
2783        assert!(i.comments().is_empty());
2784    }
2785
2786    // --- T002: add_comment ---
2787
2788    #[test]
2789    fn add_first_comment_to_empty_body() {
2790        let mut i = issue("");
2791        i.add_comment("@alice", "Hello world");
2792        assert_eq!(i.description(), "");
2793        let cs = i.comments();
2794        assert_eq!(cs.len(), 1);
2795        assert_eq!(cs[0].author, "@alice");
2796        assert_eq!(cs[0].text, "\nHello world");
2797        assert!(i.body.contains("## Comments"));
2798    }
2799
2800    #[test]
2801    fn second_comment_appended_after_first() {
2802        let mut i = issue("");
2803        i.add_comment("@alice", "first");
2804        i.add_comment("bob", "second");
2805        let cs = i.comments();
2806        assert_eq!(cs.len(), 2);
2807        let pos_a = i.body.find("### @alice");
2808        let pos_b = i.body.find("### bob");
2809        assert!(pos_a < pos_b, "first comment must precede second");
2810    }
2811
2812    #[test]
2813    fn whitespace_only_comment_discarded() {
2814        let mut i = issue("Hello\n");
2815        let before = i.body.clone();
2816        i.add_comment("@alice", "   \n  ");
2817        assert_eq!(i.body, before, "whitespace-only text must not change body");
2818    }
2819
2820    #[test]
2821    fn description_unchanged_after_add_comment() {
2822        let mut i = issue("Description text.\n");
2823        i.add_comment("@alice", "a comment");
2824        assert_eq!(i.description(), "Description text.");
2825    }
2826
2827    #[test]
2828    fn literal_comments_heading_in_code_block_not_touched() {
2829        let mut i = issue("```\n## Comments\n```\n");
2830        i.add_comment("@alice", "new");
2831        assert!(
2832            i.body.contains("```\n## Comments\n```"),
2833            "code-block heading must be preserved: {0:?}",
2834            i.body
2835        );
2836        assert!(i.body.contains("### @alice"), "must contain new comment");
2837    }
2838
2839    // --- T003: roundtrip / NFR-005 ---
2840
2841    #[test]
2842    fn roundtrip_preserves_comment_data() {
2843        let mut i = issue(
2844            "## Comments\n\n\
2845             ### @alice (2026-09-13)\n\n\
2846             Original comment.\n",
2847        );
2848        i.add_comment("bob", "Second comment");
2849        let file = i.to_file_contents();
2850        let parsed = Issue::parse(&file).unwrap();
2851        assert_eq!(parsed.description(), i.description());
2852        let cs = parsed.comments();
2853        assert_eq!(cs.len(), 2);
2854        assert_eq!(cs[0].author, "@alice");
2855        assert_eq!(cs[1].author, "bob");
2856        assert_eq!(cs[1].text, "\nSecond comment");
2857    }
2858
2859    #[test]
2860    fn three_comments_in_file_order() {
2861        let mut i = issue("");
2862        i.add_comment("a", "one");
2863        i.add_comment("b", "two");
2864        i.add_comment("c", "three");
2865        let cs = i.comments();
2866        assert_eq!(
2867            cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
2868            ["a", "b", "c"]
2869        );
2870        assert_eq!(
2871            i.body,
2872            format!(
2873                "## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
2874                today()
2875            )
2876        );
2877    }
2878
2879    #[test]
2880    fn preserves_hand_authored_comment_markdown() {
2881        let body = "## Comments\n\n\
2882             ### @alice (2026-09-13)\n\n\
2883             **bold** and _italic_\n\n\
2884             - item 1\n- item 2\n\n";
2885        let i = issue(body);
2886        let cs = i.comments();
2887        assert_eq!(cs.len(), 1);
2888        assert!(cs[0].text.contains("**bold**"));
2889        assert!(cs[0].text.contains("- item 1"));
2890    }
2891
2892    #[test]
2893    fn description_code_block_heading_not_boundary() {
2894        let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
2895        let before = i.body.clone();
2896        i.add_comment("eve", "real");
2897        assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
2898        let cs = i.comments();
2899        assert_eq!(cs.len(), 1);
2900        assert_eq!(cs[0].author, "eve");
2901        assert!(
2902            i.body.starts_with(&before),
2903            "code-block heading must be preserved"
2904        );
2905    }
2906
2907    // ------------------------------------------------------------------
2908    // WP02 — Planning fields, dates & file format
2909    // ------------------------------------------------------------------
2910
2911    #[test]
2912    fn planning_fields_round_trip() {
2913        let issue = Issue {
2914            number: 12,
2915            title: "Ship cycle one".into(),
2916            status: Status::new("in progress"),
2917            labels: vec!["release".into()],
2918            priority: Priority::High,
2919            assignee: Some("alice".into()),
2920            estimate: None,
2921            commit: None,
2922            gitlab_id: None,
2923            linear_id: None,
2924            mission: None,
2925            task: None,
2926            relationships: vec![],
2927            history: crate::model::History::new(),
2928            has_retired_links_key: false,
2929            files: vec!["specs/p/spec.md".into()],
2930            cycles: Some(vec!["Cycle 1".into(), "Sprint hardening".into()]),
2931            milestones: Some(vec!["v1.0".into()]),
2932            reviewer: Some("Bob <bob@example.com>".into()),
2933            start_date: Some("2026-09-07".into()),
2934            due_date: Some("2026-09-18".into()),
2935            created: "2026-09-07".into(),
2936            updated: "2026-09-07".into(),
2937            filename: String::new(),
2938            body: "body".into(),
2939            extra_frontmatter: std::collections::BTreeMap::new(),
2940            parent: None,
2941            archived: false,
2942            entered_at: None,
2943            source: None,
2944        };
2945        let contents = issue.to_file_contents();
2946        let parsed = Issue::parse(&contents).unwrap();
2947        assert_eq!(parsed, issue);
2948        assert_eq!(
2949            parsed.cycles.clone().unwrap(),
2950            ["Cycle 1", "Sprint hardening"]
2951        );
2952        assert_eq!(parsed.milestones.clone().unwrap(), ["v1.0"]);
2953        assert_eq!(parsed.reviewer.as_deref(), Some("Bob <bob@example.com>"));
2954        assert_eq!(parsed.start_date.as_deref(), Some("2026-09-07"));
2955        assert_eq!(parsed.due_date.as_deref(), Some("2026-09-18"));
2956    }
2957
2958    #[test]
2959    fn planning_fields_absent_on_legacy_serialize_nothing() {
2960        let issue = Issue {
2961            number: 3,
2962            title: "T".into(),
2963            status: Status::default(),
2964            labels: vec![],
2965            priority: Priority::default(),
2966            assignee: None,
2967            estimate: None,
2968            commit: None,
2969            gitlab_id: None,
2970            linear_id: None,
2971            mission: None,
2972            task: None,
2973            relationships: vec![],
2974            history: crate::model::History::new(),
2975            has_retired_links_key: false,
2976            files: vec![],
2977            cycles: None,
2978            milestones: None,
2979            reviewer: None,
2980            start_date: None,
2981            due_date: None,
2982            created: "2026-01-01".into(),
2983            updated: "2026-01-01".into(),
2984            filename: String::new(),
2985            body: "body".into(),
2986            extra_frontmatter: std::collections::BTreeMap::new(),
2987            parent: None,
2988            archived: false,
2989            entered_at: None,
2990            source: None,
2991        };
2992        let fm = issue.frontmatter();
2993        for needle in ["cycles", "milestones", "reviewer", "start_date", "due_date"] {
2994            assert!(!fm.contains(needle), "{needle} must be omitted, got:\n{fm}");
2995        }
2996        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
2997        assert!(parsed.cycles.is_none());
2998        assert!(parsed.milestones.is_none());
2999        assert!(parsed.reviewer.is_none());
3000        assert!(parsed.start_date.is_none());
3001        assert!(parsed.due_date.is_none());
3002    }
3003
3004    #[test]
3005    fn empty_planning_lists_serialize_as_omitted() {
3006        let issue = Issue {
3007            number: 3,
3008            title: "T".into(),
3009            status: Status::default(),
3010            labels: vec![],
3011            priority: Priority::default(),
3012            assignee: None,
3013            estimate: None,
3014            commit: None,
3015            gitlab_id: None,
3016            linear_id: None,
3017            mission: None,
3018            task: None,
3019            relationships: vec![],
3020            history: crate::model::History::new(),
3021            has_retired_links_key: false,
3022            files: vec![],
3023            cycles: Some(vec![]),
3024            milestones: Some(vec![]),
3025            reviewer: None,
3026            start_date: None,
3027            due_date: None,
3028            created: "2026-01-01".into(),
3029            updated: "2026-01-01".into(),
3030            filename: String::new(),
3031            body: "body".into(),
3032            extra_frontmatter: std::collections::BTreeMap::new(),
3033            parent: None,
3034            archived: false,
3035            entered_at: None,
3036            source: None,
3037        };
3038        let fm = issue.frontmatter();
3039        assert!(!fm.contains("cycles"), "{fm}");
3040        assert!(!fm.contains("milestones"), "{fm}");
3041    }
3042
3043    #[test]
3044    fn duplicate_planning_list_entries_collapse_on_write() {
3045        let issue = Issue {
3046            number: 3,
3047            title: "T".into(),
3048            status: Status::default(),
3049            labels: vec![],
3050            priority: Priority::default(),
3051            assignee: None,
3052            estimate: None,
3053            commit: None,
3054            gitlab_id: None,
3055            linear_id: None,
3056            mission: None,
3057            task: None,
3058            relationships: vec![],
3059            history: crate::model::History::new(),
3060            has_retired_links_key: false,
3061            files: vec![],
3062            cycles: Some(vec!["A".into(), "A".into(), "B".into()]),
3063            milestones: Some(vec!["m".into(), "m".into()]),
3064            reviewer: None,
3065            start_date: None,
3066            due_date: None,
3067            created: "2026-01-01".into(),
3068            updated: "2026-01-01".into(),
3069            filename: String::new(),
3070            body: "body".into(),
3071            extra_frontmatter: std::collections::BTreeMap::new(),
3072            parent: None,
3073            archived: false,
3074            entered_at: None,
3075            source: None,
3076        };
3077        let fm = issue.frontmatter();
3078        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
3079        assert_eq!(parsed.cycles.clone().unwrap(), ["A", "B"]);
3080        assert_eq!(parsed.milestones.clone().unwrap(), ["m"]);
3081        assert_eq!(fm.matches("- A").count(), 1, "{fm}");
3082        assert_eq!(fm.matches("- m").count(), 1, "{fm}");
3083    }
3084
3085    #[test]
3086    fn legacy_fixture_round_trips_with_only_the_retired_key_removed() {
3087        // Canonical fixtures captured from the real corpus before WP02
3088        // (tests/fixtures/legacy-01.md, legacy-02.md).
3089        //
3090        // Each file carries one `links: []` line, and that line is the
3091        // **approved breaking change** (C-001): the typed model replaces the
3092        // generic bag and does not re-emit the retired key. So the round trip
3093        // for these particular fixtures is deliberately not byte-identical, and
3094        // must not pretend to be. The exact, intended delta is the one removed
3095        // line and nothing else: every other byte, including the
3096        // `## Mission Progress` and `## Comments` body sections, survives.
3097        //
3098        // The fixtures carry the key **empty**, so it is not *reported* — an
3099        // empty value has nothing to lose, and reporting it made `tix lint`
3100        // reject the whole corpus. The key is still never re-emitted, which is
3101        // what this test is about, and that is unchanged.
3102        for name in ["legacy-01.md", "legacy-02.md"] {
3103            let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
3104            let original = std::fs::read_to_string(&path).unwrap();
3105            let issue = Issue::parse(&original).unwrap();
3106            assert!(
3107                !issue.has_retired_links_key,
3108                "{name} carries an *empty* retired key, so there is nothing to report"
3109            );
3110            assert!(
3111                issue.relationships.is_empty(),
3112                "C-001: the legacy value is never read as typed edges"
3113            );
3114
3115            let kept: Vec<&str> = original
3116                .lines()
3117                .filter(|line| !line.starts_with("links:"))
3118                .collect();
3119            let expected = format!("{}\n", kept.join("\n"));
3120
3121            let round = issue.to_file_contents();
3122            assert_eq!(
3123                round, expected,
3124                "{name} must differ from the legacy file by exactly the retired `links:` line"
3125            );
3126            assert!(
3127                !round.contains("links:"),
3128                "the retired key is never re-emitted"
3129            );
3130            // Byte-stable from here on: the new form round-trips exactly.
3131            assert_eq!(
3132                Issue::parse(&round).unwrap().to_file_contents(),
3133                round,
3134                "{name} must be byte-stable once in the new format"
3135            );
3136            assert!(issue.cycles.is_none() && issue.milestones.is_none());
3137            assert!(issue.reviewer.is_none());
3138            assert!(issue.start_date.is_none() && issue.due_date.is_none());
3139        }
3140    }
3141
3142    #[test]
3143    fn a_file_in_the_current_format_round_trips_byte_identically() {
3144        // NFR-003: a file the current code writes must survive read -> write
3145        // -> read with no change at all. This is the guarantee the legacy
3146        // fixtures cannot make, because they predate the format change.
3147        let original = concat!(
3148            "---\n",
3149            "number: 7\n",
3150            "title: Current\n",
3151            "status: todo\n",
3152            "labels: []\n",
3153            "priority: medium\n",
3154            "created: '2026-01-01'\n",
3155            "updated: '2026-01-01'\n",
3156            "---\n",
3157            "\n",
3158            "## History\n",
3159            "\n",
3160            "- 2026-01-02T03:04:05Z \u{2014} alice \u{2014} relationship_added \u{2014} created blocks: blocks #9 Nine\n",
3161            "\n",
3162            "Prose that follows the section.\n",
3163        );
3164        let issue = Issue::parse(original).unwrap();
3165        assert!(!issue.has_retired_links_key);
3166        assert_eq!(issue.history.len(), 1, "the History section is parsed");
3167        assert_eq!(issue.history.events()[0].actor, "alice");
3168        assert_eq!(
3169            issue.history.events()[0].kind,
3170            HistoryEventKind::RelationshipAdded
3171        );
3172        assert!(
3173            issue.body.contains("Prose that follows"),
3174            "prose after the section is preserved in the body: {:?}",
3175            issue.body
3176        );
3177
3178        let rendered = issue.to_file_contents();
3179        assert!(rendered.contains("## History"), "{rendered}");
3180        assert!(
3181            rendered.contains(" \u{2014} alice \u{2014} relationship_added \u{2014} "),
3182            "the rendered entry uses the documented spaced form: {rendered}"
3183        );
3184        // Idempotent: rendering the re-parsed form changes nothing. Asserting
3185        // idempotence rather than equality with the hand-written fixture keeps
3186        // the guarantee honest about what it covers.
3187        assert_eq!(
3188            Issue::parse(&rendered).unwrap().to_file_contents(),
3189            rendered,
3190            "a file in the current format round-trips byte-identically"
3191        );
3192    }
3193
3194    #[test]
3195    fn validate_date_accepts_real_calendar_dates() {
3196        assert!(validate_date("2026-09-18"));
3197        assert!(validate_date("1970-01-01"));
3198        assert!(validate_date("2024-02-29")); // leap year
3199        assert!(!validate_date("2023-02-29")); // not leap
3200        assert!(!validate_date("2026-02-30"));
3201        assert!(!validate_date("2026-13-01"));
3202        assert!(!validate_date("2026-00-01"));
3203        assert!(!validate_date("2026-01-00"));
3204        assert!(!validate_date("2026-1-01"));
3205        assert!(!validate_date("2026-01-1"));
3206        assert!(!validate_date("1969-12-31")); // pre-1970 rejected
3207        assert!(!validate_date("2026/01/01"));
3208        assert!(!validate_date("today"));
3209        assert_eq!(days_in_month(2024, 2), 29);
3210        assert_eq!(days_in_month(2023, 2), 28);
3211        assert_eq!(days_in_month(2026, 4), 30);
3212        assert_eq!(days_in_month(2026, 1), 31);
3213    }
3214
3215    #[test]
3216    fn lexicographic_order_equals_chronological_order() {
3217        assert!(date_lte("2026-01-05", "2026-01-12"));
3218        assert!(date_lte("2026-01-05", "2026-01-05"));
3219        assert!(!date_lte("2026-01-12", "2026-01-05"));
3220        assert!(date_lte("2025-12-31", "2026-01-01"));
3221    }
3222
3223    #[test]
3224    fn validate_issue_dates_enforces_format_and_ordering() {
3225        assert!(validate_issue_dates(None, None).is_ok());
3226        assert!(validate_issue_dates(Some("2026-09-07"), None).is_ok());
3227        assert!(validate_issue_dates(None, Some("2026-09-18")).is_ok());
3228        assert!(validate_issue_dates(Some("2026-09-07"), Some("2026-09-18")).is_ok());
3229        assert!(validate_issue_dates(Some("2026-09-18"), Some("2026-09-18")).is_ok());
3230        let e = validate_issue_dates(Some("2026-09-18"), Some("2026-09-07")).unwrap_err();
3231        assert!(e.to_string().contains("due_date"), "naming both: {e}");
3232        assert!(e.to_string().contains("start_date"), "naming both: {e}");
3233        assert!(validate_issue_dates(Some("2026-13-01"), None).is_err());
3234        assert!(validate_issue_dates(None, Some("2026-02-30")).is_err());
3235    }
3236
3237    #[test]
3238    fn malformed_planning_fields_load_without_panic() {
3239        // Storage is tolerant (T007): garbage in the new optional fields must
3240        // parse without panic and be preserved as-is on the parsed value;
3241        // validation is a separate, explicit concern.
3242        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";
3243        let issue = Issue::parse(invalid).unwrap();
3244        assert_eq!(issue.start_date.as_deref(), Some("not-a-date"));
3245        assert_eq!(issue.due_date.as_deref(), Some("2026-02-30"));
3246        assert_eq!(issue.reviewer.as_deref(), Some("nobody"));
3247        assert_eq!(issue.cycles.clone().unwrap(), [""]);
3248    }
3249
3250    #[test]
3251    fn set_dates_validates_before_mutating() {
3252        let mut issue = issue("body");
3253        issue
3254            .set_dates(Some("2026-09-07".into()), Some("2026-09-18".into()))
3255            .unwrap();
3256        assert_eq!(issue.start_date.as_deref(), Some("2026-09-07"));
3257        assert_eq!(issue.due_date.as_deref(), Some("2026-09-18"));
3258        let before = issue.clone();
3259        let err = issue.set_dates(Some("2026-09-18".into()), Some("2026-09-07".into()));
3260        assert!(err.is_err());
3261        assert_eq!(issue, before, "failed set_dates must not partially mutate");
3262    }
3263    // ------------------------------------------------------------------
3264    // Final-review probes, promoted to tests where they pin real behavior
3265    // ------------------------------------------------------------------
3266
3267    /// Frontmatter with one extra line inserted before the closing delimiter.
3268    fn frontmatter_with(extra: &str) -> String {
3269        format!("---\nnumber: 1\ntitle: T\nstatus: todo\n{extra}---\n\nBody.\n")
3270    }
3271
3272    #[test]
3273    fn retired_key_detection_matches_only_the_exact_key_name() {
3274        // The detector parses the frontmatter and looks for a top-level `links`
3275        // key, because the key is not part of the model and so cannot be seen
3276        // after deserialization. A key that merely *contains* `links` is not the
3277        // retired key, and a line inside a block scalar is content, not a key.
3278        for (extra, expect) in [
3279            // Populated: reported, because the value is no longer readable.
3280            ("links:\n- '12'\n", true),
3281            ("related_links: []\n", false),
3282            ("links_count: 3\n", false),
3283            ("# links: []\n", false),
3284            // A quoted key is still the same key, so it IS the retired key —
3285            // the earlier line scan missed these entirely.
3286            ("\"links\":\n- '12'\n", true),
3287            // Empty: present, but nothing to lose, so not reported. Reporting it
3288            // rejected every issue file this repository has ever written.
3289            ("links: []\n", false),
3290            ("links: ~\n", false),
3291            ("priority: medium\n", false),
3292            // A nested `links:` is not the retired top-level key.
3293            ("meta:\n  links: []\n", false),
3294            // A line inside a block scalar is content, not a key.
3295            ("notes: |\n  links: see the docs\n", false),
3296        ] {
3297            let issue = Issue::parse(&frontmatter_with(extra)).unwrap();
3298            assert_eq!(
3299                issue.has_retired_links_key, expect,
3300                "frontmatter line {extra:?}"
3301            );
3302        }
3303    }
3304
3305    #[test]
3306    fn retired_key_detection_agrees_with_itself_on_the_fallback_path() {
3307        // The line-scan fallback is reachable: `serde_yaml` rejects duplicate
3308        // keys when building a `Mapping` but `Issue` tolerates a duplicate
3309        // *unknown* key, so such a file parses and lands on the fallback. Both
3310        // paths must agree that a nested `links:` is not the retired key — the
3311        // fallback used to match it at any indentation and disagree with the
3312        // primary path, so the same file was reported or not depending on which
3313        // path ran.
3314        for (extra, expect) in [
3315            ("meta:\n  links: []\n", false),
3316            ("meta:\n  links: []\nzzz: 1\nzzz: 2\n", false),
3317            ("links: []\nzzz: 1\nzzz: 2\n", true),
3318        ] {
3319            let issue = Issue::parse(&frontmatter_with(extra)).unwrap();
3320            assert_eq!(
3321                issue.has_retired_links_key, expect,
3322                "frontmatter {extra:?} must be judged the same way on both paths"
3323            );
3324        }
3325    }
3326
3327    #[test]
3328    fn a_history_heading_inside_prose_stays_prose() {
3329        // Only a section at the *front* of the body is the History section, the
3330        // position data-model §8 pins. A heading further down is ordinary prose
3331        // and must survive a read/write round trip untouched.
3332        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";
3333        let issue = Issue::parse(raw).unwrap();
3334        assert!(
3335            issue.history.is_empty(),
3336            "a mid-body heading is not History"
3337        );
3338        assert!(
3339            issue.body.contains("## History"),
3340            "and it stays in the body: {:?}",
3341            issue.body
3342        );
3343        // And it round-trips as prose, not as a section.
3344        let rendered = issue.to_file_contents();
3345        assert!(rendered.contains("## History"));
3346        assert_eq!(
3347            Issue::parse(&rendered).unwrap().to_file_contents(),
3348            rendered
3349        );
3350    }
3351
3352    /// The three heading cases the cycle-7 review found **unpinned**: a trailing
3353    /// tab, leading whitespace, and extra words after the heading. All three
3354    /// behave correctly — they are now pinned so the next change to the heading
3355    /// rule has to move a test rather than a comment.
3356    ///
3357    /// The pair that matter is the tab against the leading space, because they sit
3358    /// on opposite sides of the stated rule ("trailing whitespace is ignored;
3359    /// leading whitespace is not") and nothing tested it.
3360    #[test]
3361    fn the_heading_rule_ignores_trailing_whitespace_and_nothing_else() {
3362        let event = "2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d";
3363        let with = |heading: &str| {
3364            format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n{heading}\n\n- {event}\n")
3365        };
3366
3367        for (heading, recognised, why) in [
3368            ("## History", true, "the exact heading"),
3369            (
3370                "## History\t",
3371                true,
3372                "a trailing tab is trailing whitespace",
3373            ),
3374            ("## History  ", true, "so are trailing spaces"),
3375            (
3376                "   ## History",
3377                false,
3378                "leading whitespace is explicitly not ignored",
3379            ),
3380            (
3381                "## History extra words",
3382                false,
3383                "nothing else may introduce the section",
3384            ),
3385        ] {
3386            let issue = Issue::parse(&with(heading)).unwrap();
3387            assert_eq!(issue.history.is_empty(), !recognised, "{heading:?} — {why}");
3388            if recognised {
3389                assert_eq!(issue.history.len(), 1, "{heading:?} holds one event");
3390            } else {
3391                assert!(
3392                    issue.body.contains("History"),
3393                    "{heading:?} stays body prose: {:?}",
3394                    issue.body
3395                );
3396            }
3397        }
3398    }
3399
3400    /// The two non-recognised heading shapes above are **undiagnosed**: `tix lint`
3401    /// reports a mispositioned `## History` only when the heading matches exactly,
3402    /// so `   ## History` and `## History extra words` fall through both checks
3403    /// silently.
3404    ///
3405    /// That is defensible rather than a defect — neither is a section, so there is
3406    /// no audit trail to lose, and §8 already says a heading appearing in an
3407    /// issue's prose is ordinary text. It is pinned here so the silence is a
3408    /// recorded decision: if a future change makes either shape *recognised*, this
3409    /// test is where that has to be noticed, because `tix lint` will not notice.
3410    #[test]
3411    fn a_non_section_history_heading_is_undiagnosed() {
3412        for heading in ["   ## History", "## History extra words"] {
3413            let raw =
3414                format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n{heading}\n\nBody.\n");
3415            let issue = Issue::parse(&raw).unwrap();
3416            assert!(issue.history.is_empty(), "{heading:?} is not a section");
3417            // The misposition diagnostic keys on the exact heading, so it does not
3418            // fire for either of these.
3419            assert!(
3420                !is_history_heading(&format!(
3421                    "{heading}
3422"
3423                )),
3424                "{heading:?} is not the History heading at all"
3425            );
3426        }
3427    }
3428
3429    /// A front-position section whose first list-shaped line is followed by prose
3430    /// is **emptied**: the heading and every line after it become body text, and
3431    /// nothing reports it.
3432    ///
3433    /// This is #1336, found while applying the §8 corrections for #1309. It is the
3434    /// same deferred damage §8's own asymmetry describes — the demoted text
3435    /// survives a plain write and is destroyed by the next `tix edit --body` — and
3436    /// it needs nothing more exotic than a hand-edited to-do bullet above real
3437    /// prose. Pinned here so the behaviour is recorded rather than discovered.
3438    #[test]
3439    fn a_front_position_section_can_be_emptied_by_an_earlier_line() {
3440        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-01 \u{2014} Bob \u{2014} Done\n";
3441        let issue = Issue::parse(raw).unwrap();
3442        // The section is recognised, then emptied: `is_history_heading` says the
3443        // heading is at the front, so the misposition diagnostic cannot fire, and
3444        // an empty section renders as nothing.
3445        assert!(
3446            issue.history.is_empty(),
3447            "the section is emptied: {:?}",
3448            issue.history
3449        );
3450        assert!(
3451            !issue.to_file_contents().contains("## History"),
3452            "and the heading is gone from the rendered file:\n{}",
3453            issue.to_file_contents()
3454        );
3455        // The demoted text is still in the body — the loss is deferred, not
3456        // immediate, which is what makes it look unrelated to the bullet.
3457        assert!(
3458            issue.body.contains("step one") && issue.body.contains("Done"),
3459            "the text survives as prose until the next `tix edit --body`: {:?}",
3460            issue.body
3461        );
3462    }
3463
3464    #[test]
3465    fn a_blank_actor_round_trips_because_its_position_is_fixed() {
3466        // `render` writes an empty actor field when no identity was recorded. If
3467        // `parse` demanded a non-empty field it would reject the writer's own
3468        // output — and before the parse became non-fatal, that made the whole
3469        // file, and therefore every issue in the repository, unreadable.
3470        let event = HistoryEvent::new(
3471            "2026-01-01T00:00:00Z",
3472            "",
3473            HistoryEventKind::RelationshipAdded,
3474            "d",
3475        );
3476        let line = event.render();
3477        assert!(
3478            line.contains("\u{2014}  \u{2014}"),
3479            "render writes a blank, fixed-position actor field: {line}"
3480        );
3481        assert_eq!(
3482            HistoryEvent::parse(&line).expect("the writer's own output must parse"),
3483            event
3484        );
3485    }
3486
3487    #[test]
3488    fn a_blank_line_inside_the_section_does_not_end_it() {
3489        // A blank line between list entries is the most ordinary thing a human
3490        // or a Markdown formatter produces. Terminating the section there would
3491        // silently demote audit entries to body prose — they would vanish from
3492        // `history`, reappear as the issue's description, and the next append
3493        // would write *before* them, so file order would stop being append
3494        // order.
3495        let raw = concat!(
3496            "---\n",
3497            "number: 1\n",
3498            "title: T\n",
3499            "status: todo\n",
3500            "---\n",
3501            "\n",
3502            "## History\n",
3503            "\n",
3504            "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} first\n",
3505            "\n",
3506            "- 2026-01-01T00:00:01Z \u{2014} a \u{2014} relationship_added \u{2014} second\n",
3507            "\n",
3508            "Tail prose.\n",
3509        );
3510        let issue = Issue::parse(raw).unwrap();
3511        assert_eq!(
3512            issue.history.events().len(),
3513            2,
3514            "both entries are History events, not one event plus prose"
3515        );
3516        assert_eq!(issue.history.unparsed().len(), 0);
3517        assert_eq!(
3518            issue.body, "Tail prose.\n",
3519            "the prose after the section is still the body"
3520        );
3521        // Append order is preserved: a new event lands last, not first.
3522        let mut issue = issue;
3523        issue
3524            .history
3525            .append_now("a", HistoryEventKind::RelationshipRemoved, "third");
3526        let rendered = issue.to_file_contents();
3527        let first = rendered.find("first").unwrap();
3528        let second = rendered.find("second").unwrap();
3529        let third = rendered.find("third").unwrap();
3530        assert!(
3531            first < second && second < third,
3532            "file order is append order"
3533        );
3534        assert_eq!(
3535            Issue::parse(&rendered).unwrap().to_file_contents(),
3536            rendered
3537        );
3538    }
3539
3540    #[test]
3541    fn m3_body_bullets_after_the_section_stay_body() {
3542        // M3. The section has no end-of-section delimiter, and an earlier rule
3543        // accepted *any* `- ` line as an entry attempt. So ordinary prose
3544        // bullets were absorbed into the audit log: `body` became empty, the
3545        // blank line separating the section from the body was deleted on the
3546        // first write, and `tix lint` then blamed the author for "unparseable
3547        // entries" they never wrote. Trigger was a plain
3548        // `tix edit --body "- step one\n- step two"`.
3549        let raw = concat!(
3550            "---\n",
3551            "number: 1\n",
3552            "title: T\n",
3553            "status: todo\n",
3554            "---\n",
3555            "\n",
3556            "## History\n",
3557            "\n",
3558            "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d1\n",
3559            "\n",
3560            "- Reproduction: run it\n",
3561            "- Expected: it works\n",
3562        );
3563        let issue = Issue::parse(raw).unwrap();
3564        assert_eq!(issue.history.events().len(), 1, "only the real event");
3565        assert!(
3566            issue.history.unparsed().is_empty(),
3567            "prose bullets are not mistaken for entries: {:?}",
3568            issue.history.unparsed()
3569        );
3570        assert_eq!(
3571            issue.body, "- Reproduction: run it\n- Expected: it works\n",
3572            "the bullets stay body prose"
3573        );
3574        // And the write does not restructure the file.
3575        let rendered = issue.to_file_contents();
3576        assert!(rendered.contains("- Reproduction: run it"));
3577        assert_eq!(
3578            Issue::parse(&rendered).unwrap().to_file_contents(),
3579            rendered
3580        );
3581    }
3582
3583    #[test]
3584    fn m3_a_stray_line_inside_the_section_keeps_the_events_after_it() {
3585        // The mirror image of the case above, and the reason the boundary is
3586        // resolved by looking ahead rather than by shape alone. A hand-edited
3587        // non-entry line *inside* the section must not demote the real events
3588        // after it: they would vanish from the audit trail, reappear as the
3589        // issue's description, and the next append would be written *before*
3590        // them, so file order would stop being append order.
3591        let raw = concat!(
3592            "---\n",
3593            "number: 1\n",
3594            "title: T\n",
3595            "status: todo\n",
3596            "---\n",
3597            "\n",
3598            "## History\n",
3599            "\n",
3600            "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d1\n",
3601            "  garbage hand edit   \n",
3602            "- 2026-01-01T00:00:01Z \u{2014} a \u{2014} relationship_added \u{2014} d2\n",
3603        );
3604        let mut issue = Issue::parse(raw).unwrap();
3605        assert_eq!(
3606            issue.history.events().len(),
3607            2,
3608            "the event after the stray line is still an event"
3609        );
3610        let unparsed = issue.history.unparsed();
3611        assert_eq!(unparsed.len(), 1, "and the stray line is kept, not dropped");
3612        // Compared **raw**. The earlier version trimmed before comparing, which
3613        // is why it could not see either the injected blank line or the loss of
3614        // the leading indentation and trailing spaces — the exact properties it
3615        // claimed to check. The fixture carries both on purpose.
3616        assert_eq!(
3617            unparsed[0], "  garbage hand edit   ",
3618            "preserved byte-for-byte, including indentation and trailing space"
3619        );
3620        // And the render must not have inserted a blank line of its own.
3621        let rendered = issue.to_file_contents();
3622        assert!(
3623            rendered.contains("  garbage hand edit   \n"),
3624            "re-emitted exactly: {:?}",
3625            rendered
3626        );
3627        assert!(
3628            !rendered.contains("garbage hand edit   \n\n"),
3629            "and no blank line is injected after it: {:?}",
3630            rendered
3631        );
3632        issue
3633            .history
3634            .append_now("bob", HistoryEventKind::RelationshipAdded, "d3");
3635        let rendered = issue.to_file_contents();
3636        let d1 = rendered.find("d1").unwrap();
3637        let d2 = rendered.find("d2").unwrap();
3638        let d3 = rendered.find("d3").unwrap();
3639        assert!(d1 < d2 && d2 < d3, "file order is still append order");
3640    }
3641
3642    #[test]
3643    fn m3_a_bullet_that_looks_like_an_entry_is_recognised_for_diagnosis() {
3644        // An entry hand-typed with ASCII hyphens has no U+2014 separator, so it
3645        // cannot be recognised as an entry and must not be absorbed into the
3646        // body silently. `looks_like_history_entry` is what lets `tix lint`
3647        // report it.
3648        assert!(looks_like_history_entry(
3649            "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d"
3650        ));
3651        // Recognition requires the separator. An entry typed with ASCII hyphens
3652        // is **not** recognised, deliberately: this predicate decides whether a
3653        // line is moved into the audit log, and the looser timestamp heuristic
3654        // absorbed ordinary body to-do items.
3655        assert!(
3656            !looks_like_history_entry("- 2026-01-01T00:00:00Z - a - relationship_added - d"),
3657            "an ASCII-hyphen line is not recognised, so it stays in the body"
3658        );
3659        assert!(
3660            !looks_like_history_entry("- Reproduction: run it"),
3661            "ordinary prose is not an entry"
3662        );
3663        assert!(!looks_like_history_entry("just a paragraph"));
3664        assert!(!looks_like_history_entry("-no space after the dash"));
3665
3666        // The *reporting* predicate is separate and deliberately looser: it only
3667        // makes `tix lint` say something, it never moves a line.
3668        assert!(looks_like_an_unrecognised_entry(
3669            "- 2026-01-01T00:00:00Z - a - relationship_added - d"
3670        ));
3671        assert!(!looks_like_an_unrecognised_entry(
3672            "- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d"
3673        ));
3674        assert!(
3675            !looks_like_an_unrecognised_entry("- Reproduction: run it"),
3676            "and ordinary prose is still not accused"
3677        );
3678    }
3679
3680    #[test]
3681    fn f5_every_list_marker_is_recognised_and_reported() {
3682        // Cycle-4, on the `*`/`1.` accept that cycle 3 recorded as "undiagnosed".
3683        // It was the *same* defect as F1 through a different marker: with only
3684        // `- ` treated as a list item, an entry written as
3685        // `* 2026-01-01T00:00:00Z — a — kind — d` was called prose, the section
3686        // ended, the heading was deleted, and the next append was rendered above
3687        // it. Silent, because a demoted line is body prose.
3688        //
3689        // This is not a widening of the entry grammar: `looks_like_history_entry`
3690        // still requires U+2014, so none of these can be read as a relationship.
3691        // It is about which lines get a fair hearing at end-of-section.
3692        // With the separator present but a non-`-` marker, the line is retained
3693        // verbatim and reported rather than parsed. Parsing it would re-emit it
3694        // with a `- ` marker, and rewriting the author's bytes is exactly the
3695        // auto-repair data-model §8 forbids. Retained-and-reported is the third
3696        // option between "parse and rewrite" and "demote and say nothing", and
3697        // it is the one that loses nothing.
3698        for marker in ["* ", "+ ", "1. ", "2) "] {
3699            let line = format!(
3700                "{marker}2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d"
3701            );
3702            assert!(
3703                !looks_like_history_entry(&line),
3704                "{marker:?} is not the canonical form, so it is not an event"
3705            );
3706            let raw =
3707                format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{line}\n");
3708            let issue = Issue::parse(&raw).unwrap();
3709            assert!(
3710                issue.history.events().is_empty(),
3711                "{marker:?}: no event is fabricated"
3712            );
3713            assert_eq!(
3714                issue.history.unparsed(),
3715                vec![line.clone()],
3716                "{marker:?}: retained byte-for-byte, marker intact"
3717            );
3718            assert_eq!(issue.body, "", "{marker:?}: and not demoted to the body");
3719        }
3720
3721        // The canonical marker with the separator is a real event, as before.
3722        let canonical =
3723            "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d";
3724        assert!(looks_like_history_entry(canonical));
3725        let canonical_raw =
3726            format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{canonical}\n");
3727        assert_eq!(
3728            Issue::parse(&canonical_raw).unwrap().history.events().len(),
3729            1,
3730            "the canonical form is an event"
3731        );
3732
3733        // And every marker *without* the separator still gets a fair hearing
3734        // rather than being silently demoted.
3735        for marker in ["- ", "* ", "+ ", "1. "] {
3736            let line = format!("{marker}hand edited note");
3737            let raw =
3738                format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{line}\n");
3739            let issue = Issue::parse(&raw).unwrap();
3740            assert!(
3741                issue.history.events().is_empty(),
3742                "{marker:?}: no event is fabricated"
3743            );
3744            assert_eq!(
3745                issue.history.unparsed(),
3746                vec![line.clone()],
3747                "{marker:?}: retained verbatim in the section"
3748            );
3749            assert!(
3750                issue.body.is_empty(),
3751                "{marker:?}: and not demoted into the body, got {:?}",
3752                issue.body
3753            );
3754        }
3755
3756        // The "and reported" half of this test's name, which the retention
3757        // assertions above did not check. Reporting for a line *inside* a section
3758        // goes through the unparsed loop; reporting for one in the *body* goes
3759        // through `looks_like_an_unrecognised_entry`. Both are asserted here so
3760        // the name is earned — cycle 5 found the retention half asserted and the
3761        // reporting half assumed.
3762        for marker in ["- ", "* ", "+ ", "1. "] {
3763            let line = format!("{marker}2026-01-01T00:00:00Z - alice - relationship_added - d");
3764            assert!(
3765                looks_like_an_unrecognised_entry(&line),
3766                "{marker:?}: reported as a mistyped entry"
3767            );
3768            // And the same line with a *different* marker shape is not, so the
3769            // assertion above is discriminating rather than trivially true.
3770            assert!(
3771                !looks_like_an_unrecognised_entry(&format!("2026. {line}")),
3772                "a bare sentence is never an entry"
3773            );
3774        }
3775
3776        // A bare paragraph at end of section is still prose. That is the other
3777        // half of the rule and the reason `is_list_item` is not simply "true".
3778        for prose in [
3779            "just a closing paragraph",
3780            "Reproduction: run it",
3781            "see the thread for context",
3782        ] {
3783            let raw =
3784                format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n");
3785            let issue = Issue::parse(&raw).unwrap();
3786            assert!(issue.history.is_empty(), "{prose:?}: no section content");
3787            assert_eq!(issue.body.trim(), prose, "{prose:?}: it is body prose");
3788        }
3789    }
3790
3791    #[test]
3792    fn an_unparseable_entry_never_fails_the_parse() {
3793        // The single most important property of the History section: it is
3794        // advertised as human-editable, so a hand edit will eventually produce a
3795        // line this parser cannot read. That must cost the *author* a lint
3796        // finding on their own issue, not the whole repository its readability.
3797        // A recognised entry attempt whose kind is unknown: kept verbatim inside
3798        // the section, so `tix lint` can report it.
3799        let bad_kind = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d";
3800        let raw =
3801            format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{bad_kind}\n");
3802        let issue = Issue::parse(&raw).expect("one bad line must not fail the file");
3803        assert!(issue.history.events().is_empty(), "no event was fabricated");
3804        assert_eq!(issue.history.unparsed(), vec![bad_kind], "kept verbatim");
3805        assert_eq!(issue.body, "", "nothing leaked into the body");
3806
3807        // An entry typed with ASCII hyphens is not *recognised*, but it is still a
3808        // list item at the end of the section, so it is retained verbatim and
3809        // reported rather than demoted. The earlier version of this test
3810        // asserted it stayed body text; cycle-4 F1 showed the alternative deletes
3811        // the `## History` heading and reorders the log around it, silently.
3812        // Retention is the disclosed failure mode, so it is the one chosen — and
3813        // `looks_like_history_entry` still returns false for it, so no event is
3814        // ever fabricated from it either.
3815        let ascii = "- 2026-01-01T00:00:00Z - alice - relationship_added - ascii";
3816        let raw2 =
3817            format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{ascii}\n");
3818        let issue2 = Issue::parse(&raw2).expect("and it must not fail the file either");
3819        assert!(issue2.history.events().is_empty(), "no event is fabricated");
3820        assert_eq!(issue2.history.unparsed(), vec![ascii], "kept verbatim");
3821        assert_eq!(issue2.body, "", "and not demoted into the body");
3822
3823        let rendered = issue.to_file_contents();
3824        assert_eq!(
3825            Issue::parse(&rendered).unwrap().to_file_contents(),
3826            rendered,
3827            "and the file round-trips unchanged"
3828        );
3829    }
3830
3831    #[test]
3832    fn a_history_entry_keeps_an_em_dash_inside_its_detail() {
3833        // The line is split on the first three separators only, so a detail
3834        // containing an em dash survives rather than being truncated.
3835        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";
3836        let issue = Issue::parse(raw).unwrap();
3837        assert_eq!(issue.history.len(), 1);
3838        let event = &issue.history.events()[0];
3839        assert_eq!(event.timestamp, "2026-01-01T00:00:00Z");
3840        assert_eq!(event.actor, "alice");
3841        assert_eq!(event.kind, HistoryEventKind::RelationshipAdded);
3842        assert_eq!(event.detail, "note \u{2014} dash inside");
3843        // Idempotence rather than equality with the hand-written input: the
3844        // input omits keys serde always emits, so only the rendered form is a
3845        // meaningful byte-stability assertion.
3846        let rendered = issue.to_file_contents();
3847        assert_eq!(
3848            Issue::parse(&rendered).unwrap().to_file_contents(),
3849            rendered,
3850            "the entry round-trips byte-identically"
3851        );
3852        assert_eq!(
3853            Issue::parse(&rendered).unwrap().history.events()[0].detail,
3854            "note \u{2014} dash inside",
3855            "and the detail is stable across the round trip"
3856        );
3857    }
3858
3859    // -----------------------------------------------------------------------
3860    // WP02 / T006 + T007 — the `parent` field and its three History kinds
3861    // -----------------------------------------------------------------------
3862    //
3863    // Requirement ids in the test names: C-001 (at most one parent), C-003 (no
3864    // child data on the parent), C-004 (every change audited), C-007 (a parent is
3865    // not a dependency edge), SC-004 (a rejected change writes nothing).
3866
3867    /// A file body, so a test's only variable is the frontmatter under test.
3868    fn wp02_file(fm: &str) -> String {
3869        format!("---\n{fm}---\n\nBody text.")
3870    }
3871
3872    /// The frontmatter every legacy file in the repository carries: no `parent:`
3873    /// key at all. This is the pinned pre-mission byte form.
3874    ///
3875    /// In the **canonical rendered form**, not a merely plausible one. The writer
3876    /// unquotes strings and always emits `files: []`, so a hand-written fixture
3877    /// with quotes in it is not byte-stable for reasons that have nothing to do
3878    /// with `parent` — the first version of these tests failed for exactly that
3879    /// reason and was measuring the renderer rather than the field.
3880    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";
3881
3882    /// The same file, with a parent, canonical.
3883    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";
3884
3885    #[test]
3886    fn t006_a_file_with_a_parent_round_trips_byte_stably() {
3887        let original = wp02_file(WP02_PARENT_FM);
3888        let issue = Issue::parse(&original).expect("a parent parses");
3889        assert_eq!(issue.parent, Some(7), "the parent number is read as data");
3890
3891        // load -> save -> load, byte-identical at every hop.
3892        let once = issue.to_file_contents();
3893        assert_eq!(
3894            once, original,
3895            "the parent key is written where it was read"
3896        );
3897        let twice = Issue::parse(&once).unwrap().to_file_contents();
3898        assert_eq!(twice, once, "a second round trip changes nothing");
3899    }
3900
3901    #[test]
3902    fn t006_a_legacy_file_with_no_parent_key_serialises_to_its_pre_mission_bytes() {
3903        // The pinned legacy gate. If this fails, the field is leaking into files
3904        // that never had one.
3905        let original = wp02_file(WP02_LEGACY_FM);
3906        let issue = Issue::parse(&original).unwrap();
3907        assert_eq!(issue.parent, None, "an absent key is no parent");
3908        let rendered = issue.to_file_contents();
3909        assert_eq!(rendered, original);
3910        assert!(
3911            !rendered.contains("parent:"),
3912            "a file that never had a parent must not grow a `parent:` key"
3913        );
3914        assert_eq!(
3915            Issue::parse(&rendered).unwrap().to_file_contents(),
3916            rendered
3917        );
3918    }
3919
3920    #[test]
3921    fn t006_the_parent_key_is_written_exactly_once() {
3922        // Regression: `parent` was added to [`Issue`] but not to
3923        // [`KNOWN_FRONTMATTER_KEYS`], so `capture_extra_frontmatter` also took it
3924        // for an unknown key and the writer emitted the key twice in one file. The
3925        // file still parsed. A count assertion is what notices.
3926        let rendered = Issue::parse(&wp02_file(WP02_PARENT_FM))
3927            .unwrap()
3928            .to_file_contents();
3929        assert_eq!(
3930            rendered.matches("\nparent:").count(),
3931            1,
3932            "`parent:` must be written once, got:\n{rendered}"
3933        );
3934        let reparsed = Issue::parse(&rendered).unwrap();
3935        assert_eq!(reparsed.to_file_contents(), rendered);
3936    }
3937
3938    #[test]
3939    fn known_frontmatter_keys_cover_every_written_key() {
3940        // The guard for `KNOWN_FRONTMATTER_KEYS`, which is a hand-maintained mirror
3941        // of the frontmatter field set — no macro can read a struct's own field
3942        // names. Both of its failure modes are silent, and in opposite directions:
3943        //
3944        // * a key **listed but not modelled** is dropped on write (data loss, the
3945        //   bug #1361 exists to remove);
3946        // * a key **modelled but not listed** is also captured as unknown and emitted
3947        //   a *second* time, so the file carries the same key twice. That is what
3948        //   `parent` did.
3949        //
3950        // The first version of this test was a one-directional subset check with a
3951        // fixture that left `relationships`, `cycles` and `milestones` unpopulated.
3952        // Deleting `"relationships"` from the list left it **green** while the
3953        // double-write defect was live, and so did adding a brand-new unregistered
3954        // field — both measured and confirmed. A test that cannot go red in the
3955        // states it claims to rule out is worse than none, because it is read as
3956        // proof.
3957        //
3958        // So this asserts **both directions**, populates every optional field so
3959        // the key set is complete, and adds two direct checks that do not consult
3960        // the list at all — the symptom, not the cause.
3961        let mut issue = Issue::parse(&wp02_file(
3962            "number: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n",
3963        ))
3964        .unwrap();
3965        issue.assignee = Some("@alice".into());
3966        issue.estimate = Some("1h".into());
3967        issue.commit = Some("abc1234".into());
3968        issue.gitlab_id = Some("7".into());
3969        issue.linear_id = Some("9".into());
3970        issue.mission = Some("m".into());
3971        issue.task = Some("T001".into());
3972        issue.reviewer = Some("@bob".into());
3973        issue.start_date = Some("2026-01-01".into());
3974        issue.due_date = Some("2026-02-01".into());
3975        issue.entered_at = Some("2026-03-01T10:00:00Z".into());
3976        issue.source = Some("import".into());
3977        issue.parent = Some(2);
3978        issue.cycles = Some(vec!["A".into()]);
3979        issue.milestones = Some(vec!["m".into()]);
3980        issue.files = vec!["docs/a.md".into()];
3981        issue.labels = vec!["one".into(), "two".into()];
3982        issue.relationships = vec![Relationship::new(1, 2, RelationKind::Blocks, "T")];
3983
3984        let frontmatter = issue.frontmatter();
3985        let written: Vec<String> = frontmatter.lines().filter_map(top_level_key).collect();
3986
3987        // -- The direct checks first ------------------------------------------
3988        //
3989        // These do not consult `KNOWN_FRONTMATTER_KEYS` at all, so they hold
3990        // whatever the list says, and they are what actually catches the defect:
3991        // the symptom rather than the cause. Ordered first so a regression reports
3992        // the real problem instead of a completeness count.
3993        //
3994        // A fully-populated canonical file must have **no unknown keys**. A key the
3995        // model owns but the list omits is captured as unknown and will be emitted
3996        // a second time.
3997        assert_eq!(
3998            capture_extra_frontmatter(&frontmatter),
3999            BTreeMap::new(),
4000            "a fully-populated canonical file must have NO unknown keys: a key the \
4001             model owns is being captured as unknown and will be emitted twice"
4002        );
4003
4004        // And no top-level key may appear twice in a rendered file. This is the
4005        // defect stated as an assertion, with nothing to maintain but the writer.
4006        let mut sorted = written.clone();
4007        sorted.sort();
4008        let before = sorted.len();
4009        sorted.dedup();
4010        assert_eq!(
4011            before,
4012            sorted.len(),
4013            "a key is written more than once in:\n{frontmatter}"
4014        );
4015
4016        // -- Direction 1: every written key is registered -----------------------
4017        for key in &written {
4018            assert!(
4019                KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()),
4020                "`{key}` is written by the model but absent from KNOWN_FRONTMATTER_KEYS, \
4021                 so capture_extra_frontmatter treats it as unknown and the writer emits \
4022                 it twice. Add it to the list."
4023            );
4024        }
4025
4026        // -- Direction 2: every registered key is one the model can write -------
4027        //
4028        // The opposite silent failure: a key listed but not modelled is dropped on
4029        // write, which is data loss.
4030        for key in KNOWN_FRONTMATTER_KEYS {
4031            if *key == "links" {
4032                continue; // Recognised but never re-emitted, by design (that C-001).
4033            }
4034            assert!(
4035                written.iter().any(|k| k == key),
4036                "`{key}` is listed as known but no field writes it, so the entry is \
4037                 wrong in the other direction: a key wrongly listed as known is \
4038                 dropped on write. Either model it or remove it from the list."
4039            );
4040        }
4041
4042        // -- Completeness floor -------------------------------------------------
4043        //
4044        // Last, because it is a statement about the *fixture*, not about the code:
4045        // if a field is added and this test stops populating it, the two assertions
4046        // above silently cover less. Asserted so that cannot pass unnoticed.
4047        assert_eq!(
4048            written.len(),
4049            KNOWN_FRONTMATTER_KEYS.len() - 1,
4050            "the fixture must exercise every known key except the retired one; \
4051             got {written:?} against {KNOWN_FRONTMATTER_KEYS:?}"
4052        );
4053
4054        for key in KNOWN_FRONTMATTER_KEYS {
4055            assert!(
4056                !key.is_empty() && !key.contains(char::is_whitespace),
4057                "`{key}` is not a usable top-level key"
4058            );
4059        }
4060    }
4061
4062    #[test]
4063    fn t006_an_explicitly_null_parent_is_the_absent_state() {
4064        // `parent:` with no value is the "no parent" state. It is dropped on
4065        // write, matching every other optional field in this frontmatter
4066        // (`assignee:`, `commit:`). Pinned rather than assumed, because the
4067        // legacy gate above is what would catch the blast radius if it changed.
4068        let issue =
4069            Issue::parse(&wp02_file("number: 3\ntitle: T\nstatus: todo\nparent:\n")).unwrap();
4070        assert_eq!(issue.parent, None);
4071        assert!(!issue.to_file_contents().contains("parent:"));
4072    }
4073
4074    #[test]
4075    fn t006_an_empty_string_parent_is_rejected_rather_than_read_as_a_reference() {
4076        // A YAML `""` is a *string*, not a number. Coercing it to `None` would
4077        // erase a real reference that a template or an editor emptied by
4078        // mistake, and the file would then read as a deliberate "no parent".
4079        let err = Issue::parse(&wp02_file("number: 3\ntitle: T\nparent: \"\"\n"))
4080            .expect_err("an empty-string parent is malformed, not a reference");
4081        assert!(
4082            err.to_string().contains("parent"),
4083            "the error must name the offending field, got: {err}"
4084        );
4085    }
4086
4087    #[test]
4088    fn t006_a_malformed_parent_value_is_rejected() {
4089        // The spellings that are genuinely rejected. Every one of these would be a
4090        // reference the user did not write if the parser were lenient about them.
4091        for (label, value) in [
4092            ("an empty string", "\"\""),
4093            ("a single-quoted number", "'7'"),
4094            ("a zero-padded number", "007"),
4095            ("an underscore-separated number", "1_000"),
4096            ("a float", "7.5"),
4097            ("a float written as an integer", "7.0"),
4098            ("scientific notation", "1e2"),
4099            ("a list", "[7]"),
4100            ("a map", "{a: 1}"),
4101            ("a bool", "true"),
4102            ("a negative", "-1"),
4103            ("a value beyond u32", "4294967296"),
4104        ] {
4105            let parsed = Issue::parse(&wp02_file(&format!(
4106                "number: 3\ntitle: T\nparent: {value}\n"
4107            )));
4108            assert!(
4109                parsed.is_err(),
4110                "{label} (`parent: {value}`) must be rejected, got {parsed:?}"
4111            );
4112        }
4113    }
4114
4115    #[test]
4116    fn t006_the_accepted_scalar_forms_are_pinned_because_they_are_surprising() {
4117        // An earlier version of the malformed-value test claimed "none may become
4118        // `Some(_)`" and listed seven spellings. It was **false**: measured, five
4119        // more scalar forms are read as numbers.
4120        //
4121        // Pinned here rather than left unstated, so the behaviour is a recorded
4122        // decision instead of a YAML accident. Whether these should be *accepted*
4123        // is an open question (filed as #1372): tightening the accepted set is a
4124        // parse-semantics change and belongs in a contract clause, not in a test
4125        // edit made quietly inside WP02.
4126        //
4127        // `!!str 7` is the sharp one. A value explicitly tagged as a *string* is read
4128        // as a number — the same class the empty-string test refuses. `0x1F`, `0o17`
4129        // and `0b111` are alternative radices, and `+7` is a leading sign.
4130        //
4131        // `parent: 0` deserves a note, because an earlier version of this comment
4132        // claimed "`0` is accepted although issue numbers start at 1" and a later
4133        // one said the reverse. The history: the claim was *wrong* when written —
4134        // `Store::resolve_next_number` returned `Ok(0)` for an empty directory, so
4135        // the first issue in every fresh repository was `#0`. Found by running the
4136        // binary, not by reading the code (tix #1376).
4137        //
4138        // `resolve_next_number` now starts at `1`, so the original comment is
4139        // finally accurate. **`0` still parses, and must.** The parser accepts any
4140        // `u32`; rejecting `0` here would contradict it, and an existing repository
4141        // can already contain an issue numbered `0` (this one does — it was written
4142        // before the fix, and renumbering it is a migration). Rejecting the value
4143        // would also refuse to *read* such a file, turning a numbering convention
4144        // into data loss.
4145        //
4146        // The general rule this test pins: the parser is not the allocator. What
4147        // `resolve_next_number` may *produce* is a separate question with a
4148        // separate answer, and this list asserts only that both `0` and `7` are
4149        // numbers.
4150        for (value, expected) in [
4151            ("0x1F", 31),
4152            ("0o17", 15),
4153            ("0b111", 7),
4154            ("+7", 7),
4155            ("!!str 7", 7),
4156            ("0", 0),
4157        ] {
4158            let issue = Issue::parse(&wp02_file(&format!(
4159                "number: 3\ntitle: T\nparent: {value}\n"
4160            )))
4161            .unwrap_or_else(|e| panic!("`parent: {value}` is expected to parse: {e}"));
4162            assert_eq!(
4163                issue.parent,
4164                Some(expected),
4165                "`parent: {value}` is pinned to {expected}"
4166            );
4167        }
4168
4169        // And the two spellings of "no parent" that are not a null literal.
4170        for value in ["null", "~"] {
4171            let issue = Issue::parse(&wp02_file(&format!(
4172                "number: 3\ntitle: T\nparent: {value}\n"
4173            )))
4174            .unwrap();
4175            assert_eq!(issue.parent, None, "`parent: {value}` is the absent state");
4176        }
4177    }
4178
4179    #[test]
4180    fn t006_at_most_one_parent_is_expressible_because_the_field_is_not_a_list() {
4181        // C-001 is a *shape* guarantee, so it is asserted as a shape: a second
4182        // `parent:` key in the file is a duplicate-mapping error, not a silent
4183        // last-one-wins.
4184        let err = Issue::parse(&wp02_file("number: 3\ntitle: T\nparent: 7\nparent: 8\n"))
4185            .expect_err("two parents are not representable");
4186        assert!(err.to_string().contains("parent"), "got: {err}");
4187    }
4188
4189    #[test]
4190    fn t006_the_parent_key_is_distinct_from_mission_and_task() {
4191        // C-007 / research R2: a decomposition parent is not the Spec Kitty
4192        // governance pointer, and not a typed dependency edge. Asserted on the
4193        // *rendered keys*, not the struct fields, because only the keys are the
4194        // contract.
4195        let mut issue = Issue::parse(&wp02_file(
4196            "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",
4197        ))
4198        .unwrap();
4199        // Set after parsing, because a typed relationship carries a generated id
4200        // that a hand-written fixture cannot supply. Without this the rendered file
4201        // has no `relationships:` key at all — an empty list is skipped — and the
4202        // assertion below would pass for the wrong reason.
4203        issue.relationships = vec![Relationship::new(3, 8, RelationKind::Blocks, "Eight")];
4204        assert!(
4205            issue.relationships.len() == 1,
4206            "the fixture must carry a relationship under test"
4207        );
4208        let fm = issue.frontmatter();
4209        // `relationships:` is in this list on purpose. WP02's own review guidance
4210        // asks whether the parent key is distinct from `mission`, `task` **and the
4211        // typed relationship key**; the first version of this test named only the
4212        // first two.
4213        for key in ["mission:", "task:", "relationships:", "parent:"] {
4214            assert_eq!(
4215                fm.matches(key).count(),
4216                1,
4217                "`{key}` must appear exactly once, got:\n{fm}"
4218            );
4219        }
4220        assert_eq!(issue.mission.as_deref(), Some("some-mission"));
4221        assert_eq!(issue.task.as_deref(), Some("T003"));
4222        assert_eq!(issue.parent, Some(7));
4223
4224        // And clearing one leaves the others alone: they are not aliases.
4225        issue.parent = None;
4226        let fm = issue.frontmatter();
4227        assert!(!fm.contains("parent:"));
4228        assert!(fm.contains("mission: some-mission"));
4229        assert!(fm.contains("task: T003"));
4230    }
4231
4232    #[test]
4233    fn t007_the_three_parent_event_kinds_round_trip_through_the_file() {
4234        // C-004: the audit trail is only an audit trail if the kinds survive it.
4235        for (kind, spelling) in [
4236            (HistoryEventKind::ParentSet, "parent_set"),
4237            (HistoryEventKind::ParentChanged, "parent_changed"),
4238            (HistoryEventKind::ParentCleared, "parent_cleared"),
4239        ] {
4240            assert_eq!(kind.as_str(), spelling);
4241            assert_eq!(
4242                HistoryEventKind::from_str(spelling).unwrap(),
4243                kind,
4244                "`{spelling}` must parse back to the same kind"
4245            );
4246        }
4247        // An unknown kind is rejected rather than coerced (this module's own
4248        // rule), so a typo in a hand-edited log cannot be quietly reclassified.
4249        assert!(HistoryEventKind::from_str("parent_changed_to").is_err());
4250
4251        // Pin the *serde* spelling too. The enum derives `Serialize`/`Deserialize`
4252        // under `rename_all = "snake_case"` **and** carries `as_str`/`from_str`, so
4253        // there are two independent sources of the wire spelling and nothing tied
4254        // them together. `History` is `#[serde(skip)]` on `Issue`, so the derive is
4255        // currently unused on the file path — which is exactly why it could drift
4256        // unnoticed if the history ever became serialised.
4257        for (kind, spelling) in [
4258            (HistoryEventKind::ParentSet, "parent_set"),
4259            (HistoryEventKind::ParentChanged, "parent_changed"),
4260            (HistoryEventKind::ParentCleared, "parent_cleared"),
4261        ] {
4262            let yaml = serde_yaml::to_string(&kind).unwrap();
4263            assert_eq!(
4264                yaml.trim(),
4265                spelling,
4266                "the derived serde spelling must match `as_str`"
4267            );
4268            let back: HistoryEventKind = serde_yaml::from_str(&yaml).unwrap();
4269            assert_eq!(back, kind, "`{spelling}` must round trip through serde");
4270        }
4271    }
4272
4273    #[test]
4274    fn t007_parent_events_survive_a_history_section_round_trip() {
4275        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";
4276        let issue = Issue::parse(original).unwrap();
4277        assert_eq!(issue.history.len(), 1);
4278        assert_eq!(issue.history.events()[0].kind, HistoryEventKind::ParentSet);
4279        assert_eq!(issue.to_file_contents(), original, "byte-stable");
4280    }
4281
4282    #[test]
4283    fn t007_issue_view_carries_the_parent_so_both_services_read_one_projection() {
4284        // The hierarchy service and the relationship service must not each build
4285        // their own view of an issue, or they can disagree about the same
4286        // snapshot.
4287        let issue = Issue::parse(&wp02_file(WP02_PARENT_FM)).unwrap();
4288        let view = issue.issue_view();
4289        assert_eq!(view.parent, Some(7));
4290        assert_eq!(
4291            crate::hierarchy::HierarchyNode::parent_number(&view),
4292            Some(7)
4293        );
4294    }
4295
4296    // ------------------------------------------------------------------
4297    // WP03 T012 — the TransitionDecision record and its detail grammar
4298    // ------------------------------------------------------------------
4299
4300    fn a_decision() -> TransitionDecision {
4301        TransitionDecision::new(
4302            "2026-09-29T12:00:00Z",
4303            "Herbert Maschke <thyseus@pm.me>",
4304            "todo",
4305            "in progress",
4306            "unstarted",
4307            "active",
4308            None,
4309        )
4310    }
4311
4312    #[test]
4313    fn t012_the_kind_spells_parses_and_renders() {
4314        assert_eq!(
4315            HistoryEventKind::TransitionAccepted.as_str(),
4316            "transition_accepted"
4317        );
4318        assert_eq!(
4319            "transition_accepted".parse::<HistoryEventKind>().unwrap(),
4320            HistoryEventKind::TransitionAccepted
4321        );
4322        assert_eq!(
4323            HistoryEventKind::TransitionAccepted.to_string(),
4324            "transition_accepted"
4325        );
4326    }
4327
4328    #[test]
4329    fn t012_an_unknown_kind_is_rejected_rather_than_coerced() {
4330        // T012 step 6. A silently reclassified audit entry is worse than a parse
4331        // failure, so this is the same contract the other kinds have — pinned
4332        // because the new kind is the newest addition to a closed set and the set
4333        // is exactly where a `catch-all => Ok(..)` would be introduced.
4334        let err = "transition_attempted"
4335            .parse::<HistoryEventKind>()
4336            .unwrap_err();
4337        assert!(
4338            err.to_string().contains("unknown History event kind"),
4339            "{err}"
4340        );
4341    }
4342
4343    #[test]
4344    fn t012_the_detail_grammar_renders_and_recovers_every_field() {
4345        let decision = a_decision();
4346        assert_eq!(
4347            decision.detail(),
4348            "todo -> in progress (unstarted -> active)",
4349            "the rendered form is the contract, so it is asserted literally"
4350        );
4351        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4352        assert_eq!(recovered, decision, "a lossless round trip");
4353    }
4354
4355    #[test]
4356    fn t012_an_override_reason_round_trips() {
4357        let mut decision = a_decision();
4358        decision.override_reason = Some("reopening: filed against the wrong epic".into());
4359        assert_eq!(
4360            decision.detail(),
4361            "todo -> in progress (unstarted -> active); override: reopening: filed \
4362             against the wrong epic"
4363        );
4364        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4365        assert_eq!(
4366            recovered.override_reason.as_deref(),
4367            Some("reopening: filed against the wrong epic")
4368        );
4369    }
4370
4371    #[test]
4372    fn t012_a_reason_containing_the_bare_marker_survives() {
4373        // The reason is split off at the first `") ; override: "`, so a reason may
4374        // contain the bare `"; override: "` without being truncated. This is the
4375        // test that caught the first implementation, which used `rsplit_once` on
4376        // the *bare* marker: the last occurrence inside the reason is the one that
4377        // split, leaving a head with no closing paren and failing to parse at all.
4378        let mut decision = a_decision();
4379        decision.override_reason = Some("typo; override: actually the right one".into());
4380        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4381        assert_eq!(
4382            recovered.override_reason.as_deref(),
4383            Some("typo; override: actually the right one")
4384        );
4385        assert_eq!(recovered.to, "in progress", "and the fields still line up");
4386    }
4387
4388    #[test]
4389    fn t012_a_reason_containing_the_anchored_marker_survives() {
4390        // The discriminating test. `split_once` and `rsplit_once` agree on every
4391        // other input — the anchored marker appears exactly once unless the reason
4392        // contains it — so nothing else in this module can tell them apart, and the
4393        // first version of `t012_a_reason_containing_the_bare_marker_survives`
4394        // passed against both. This one cannot.
4395        let mut decision = a_decision();
4396        decision.override_reason = Some("quoting it: ); override: and again".into());
4397        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4398        assert_eq!(
4399            recovered.override_reason.as_deref(),
4400            Some("quoting it: ); override: and again"),
4401            "the FIRST anchored marker is the split point, and the reason is the rest"
4402        );
4403        assert_eq!(recovered.from, "todo");
4404        assert_eq!(recovered.to, "in progress");
4405    }
4406
4407    #[test]
4408    fn t012_a_reason_containing_an_arrow_survives() {
4409        let mut decision = a_decision();
4410        decision.override_reason = Some("note: a -> b was meant to be a -> c".into());
4411        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4412        assert_eq!(
4413            recovered.override_reason.as_deref(),
4414            Some("note: a -> b was meant to be a -> c")
4415        );
4416        assert_eq!(
4417            recovered.from, "todo",
4418            "the head is unaffected by the reason"
4419        );
4420        assert_eq!(recovered.to, "in progress");
4421    }
4422
4423    #[test]
4424    fn t012_a_blank_reason_renders_as_no_marker_at_all() {
4425        // A blank reason must not come back as `Some("")`, which would make
4426        // `from_event` and `detail()` disagree about whether an override exists.
4427        let mut decision = a_decision();
4428        decision.override_reason = Some("   ".into());
4429        assert_eq!(
4430            decision.detail(),
4431            "todo -> in progress (unstarted -> active)"
4432        );
4433        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4434        assert!(recovered.override_reason.is_none());
4435    }
4436
4437    #[test]
4438    fn t012_a_column_named_with_an_arrow_is_a_documented_limit_not_a_silent_mis_split() {
4439        // WP03's doc comment originally claimed "a column named `a -> b` cannot
4440        // desynchronise the fields". It **can**, and this test is why that claim
4441        // was struck: the grammar is delimiter-based with no escaping, so `from`
4442        // parses as `"a"`. The limit is inherent — any unescaped separator has a
4443        // name it cannot represent — and is now documented on the type.
4444        //
4445        // What matters downstream is pinned here: the **categories** still parse
4446        // correctly, because they come from the bracketed tail rather than from
4447        // the arrow-separated head.
4448        let mut decision = a_decision();
4449        decision.from = "a -> b".into();
4450        decision.to = "in progress".into();
4451        let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
4452        assert_eq!(recovered.from, "a", "the documented mis-split, pinned");
4453        assert_eq!(recovered.to_category, "active", "categories stay intact");
4454        assert_eq!(recovered.from_category, "unstarted");
4455    }
4456
4457    #[test]
4458    fn t012_a_malformed_detail_is_an_error_not_a_partial_record() {
4459        for bad in [
4460            "no arrows here",
4461            "todo -> in progress",             // no bracketed categories
4462            "todo -> in progress (unstarted)", // no category arrow
4463            "todo -> in progress (unstarted -> active", // no closing paren
4464            " -> in progress (unstarted -> active)", // empty from
4465        ] {
4466            let event = HistoryEvent::new(
4467                "2026-09-29T12:00:00Z",
4468                "alice",
4469                HistoryEventKind::TransitionAccepted,
4470                bad,
4471            );
4472            assert!(
4473                TransitionDecision::from_event(&event).is_err(),
4474                "`{bad}` must not parse into a partial record"
4475            );
4476        }
4477    }
4478
4479    #[test]
4480    fn t012_from_event_refuses_a_different_kind() {
4481        let event = HistoryEvent::new(
4482            "2026-09-29T12:00:00Z",
4483            "alice",
4484            HistoryEventKind::ParentSet,
4485            "todo -> in progress (unstarted -> active)",
4486        );
4487        let err = TransitionDecision::from_event(&event).unwrap_err();
4488        assert!(
4489            err.to_string().contains("not a `transition_accepted`"),
4490            "a parent event is not a transition, however well-formed its detail: {err}"
4491        );
4492    }
4493
4494    #[test]
4495    fn t012_the_decision_survives_a_full_file_round_trip_byte_for_byte() {
4496        let mut decision = a_decision();
4497        decision.override_reason = Some("reopening".into());
4498        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.";
4499        let mut issue = Issue::parse(FILE).unwrap();
4500        issue.history.append(decision.to_event());
4501        let rendered = issue.to_file_contents();
4502        let reparsed = Issue::parse(&rendered).unwrap();
4503        assert_eq!(
4504            reparsed.to_file_contents(),
4505            rendered,
4506            "byte-stable through the file, like every other History kind"
4507        );
4508        let events = reparsed.history.events();
4509        assert_eq!(events.len(), 1);
4510        assert_eq!(
4511            TransitionDecision::from_event(events[0]).unwrap(),
4512            decision,
4513            "and every field survives the file, not just the rendered text"
4514        );
4515    }
4516
4517    #[test]
4518    fn t012_a_same_column_record_is_recognisable_as_a_no_op() {
4519        // A hand-edited file can contain one of these even though the seam never
4520        // writes one; WP06 must be able to tell that apart from a real move.
4521        let mut decision = a_decision();
4522        decision.to = "todo".into();
4523        assert!(decision.is_no_op());
4524        let mut real = decision.clone();
4525        real.to = "in progress".into();
4526        assert!(!real.is_no_op());
4527    }
4528}