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(¤t[..line_end]);
1912 if content == "---" {
1913 break;
1914 }
1915 fm_len += line_end;
1916 current = ¤t[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".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}