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