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