Skip to main content

tix/
model.rs

1//! Issue data model and file-surface types.
2//!
3//! Mirrors `contracts/file-format.md` and `data-model.md` exactly. The
4//! [`Issue`] struct is the Serde surface for the YAML frontmatter of an
5//! issue file; the markdown body is carried in `body` and round-trips
6//! verbatim. [`Status`], [`Priority`] and [`Labels`] are the value types
7//! that appear in frontmatter.
8
9use std::fmt;
10use std::str::FromStr;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::error::{Result, TixError};
16use crate::slug::slugify;
17
18/// A board column name. A newtype over the user-defined column strings from
19/// `tix.toml`; unknown names survive deserialization because columns are
20/// config-defined (a renamed column renders as a "Mismatched" pseudo-column,
21/// never a crash).
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23pub struct Status(pub String);
24
25impl Status {
26    /// Create a status from a column name.
27    pub fn new(name: impl Into<String>) -> Self {
28        Status(name.into())
29    }
30
31    /// Whether this status equals `name` (exact, case-sensitive).
32    pub fn is(&self, name: &str) -> bool {
33        self.0 == name
34    }
35
36    /// The column name this status refers to.
37    pub fn display(&self) -> &str {
38        &self.0
39    }
40}
41
42impl Default for Status {
43    fn default() -> Self {
44        Status("todo".to_string())
45    }
46}
47
48impl fmt::Display for Status {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(&self.0)
51    }
52}
53
54/// Issue priority.
55///
56/// The documented set is `low` / `medium` / `high` with `medium` as the
57/// default (spec + data-model). Any other value is preserved as-is for
58/// forward compatibility (data-model: "unknown values accepted as-is",
59/// "preserved ... but not normalized"), so e.g. `urgent` round-trips
60/// unchanged.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
62pub enum Priority {
63    Low,
64    #[default]
65    Medium,
66    High,
67    /// An unknown priority string, preserved verbatim (forward-compatible).
68    Other(String),
69}
70
71impl Priority {
72    fn as_str(&self) -> &str {
73        match self {
74            Priority::Low => "low",
75            Priority::Medium => "medium",
76            Priority::High => "high",
77            Priority::Other(s) => s,
78        }
79    }
80}
81
82impl FromStr for Priority {
83    type Err = std::convert::Infallible;
84
85    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
86        Ok(match s.to_ascii_lowercase().as_str() {
87            "low" => Priority::Low,
88            "medium" => Priority::Medium,
89            "high" => Priority::High,
90            _ => Priority::Other(s.to_string()),
91        })
92    }
93}
94
95impl fmt::Display for Priority {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101impl Serialize for Priority {
102    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
103        s.serialize_str(self.as_str())
104    }
105}
106
107impl<'de> Deserialize<'de> for Priority {
108    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
109        let raw = String::deserialize(d)?;
110        match raw.parse() {
111            Ok(parsed) => Ok(parsed),
112            Err(_) => Ok(Priority::Other(raw)),
113        }
114    }
115}
116
117/// Issue labels. Free-form strings kept exactly as authored (case and
118/// spaces preserved); default `[]`.
119pub type Labels = Vec<String>;
120
121/// Assignee file-encoding rule (FR-027 / FR-014, `contracts/file-format.md`
122/// rule 5).
123///
124/// - **In memory**: a manual handle is always bare (`alice`, never
125///   `@alice`); a git-derived identity is the full `Name <email>` string,
126///   verbatim.
127/// - **Read** ([`de_assignee`]): a single leading `@` is stripped, so both
128///   the legacy `@alice` bytes and the bare `alice` bytes normalize to the
129///   same in-memory handle. A full identity / identity-like value never
130///   starts with `@` and passes through untouched.
131/// - **Write** ([`se_assignee`]): a value carrying an identity form (both `<`
132///   and `>` present, e.g. `Name <email>`) is persisted verbatim — no forced
133///   `@` and no mangling of the angle brackets. Any other value is a manual
134///   handle and is persisted with a single leading `@`, byte-identical to the
135///   legacy `@alice` files (C-010) and to what `contracts/cli.md` conformance
136///   pins for `--assignee @alice`.
137/// - `None` persists as `null` (omitted by `skip_serializing_if`).
138///
139/// The in-memory value stays display-neutral: the `@` cosmetic a CLI/TUI
140/// renders around handles is their concern, not the model's.
141fn de_assignee<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
142    let opt = Option::<String>::deserialize(d)?;
143    Ok(opt.map(|v| v.strip_prefix('@').unwrap_or(&v).to_string()))
144}
145
146/// Serialize an assignee per the encoding rule documented on [`de_assignee`]:
147/// identity-like values verbatim, manual handles `@`-prefixed (legacy bytes).
148fn se_assignee<S: Serializer>(opt: &Option<String>, s: S) -> std::result::Result<S::Ok, S::Error> {
149    match opt {
150        None => s.serialize_none(),
151        Some(value) => {
152            let is_identity = value.contains('<') && value.contains('>');
153            let full = if is_identity || value.starts_with('@') {
154                value.clone()
155            } else {
156                format!("@{value}")
157            };
158            s.serialize_str(&full)
159        }
160    }
161}
162
163/// Today's date as a local-free calendar date in `YYYY-MM-DD`.
164///
165/// Computed from the Unix epoch in UTC; `crate`-wide reproducible and
166/// dependency-free. Later WPs reuse it for `created`/`updated` and the
167/// date prefix of filenames.
168pub fn today() -> String {
169    let secs = SystemTime::now()
170        .duration_since(UNIX_EPOCH)
171        .map(|d| d.as_secs())
172        .unwrap_or(0);
173    let days = (secs / 86_400) as i64;
174    let (y, m, d) = civil_from_days(days);
175    format!("{y:04}-{m:02}-{d:02}")
176}
177
178/// Convert days since the Unix epoch to a `(year, month, day)` civil date
179/// (Howard Hinnant's `civil_from_days`).
180fn civil_from_days(z: i64) -> (i64, u32, u32) {
181    let z = z + 719_468;
182    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
183    let doe = z - era * 146_097; // [0, 146096]
184    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
185    let y = yoe + era * 400;
186    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
187    let mp = (5 * doy + 2) / 153; // [0, 11]
188    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
189    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
190    (if m <= 2 { y + 1 } else { y }, m, d)
191}
192
193/// Parse a `### <author> (<YYYY-MM-DD>)` comment head into its parts.
194///
195/// The date is the trailing `(<YYYY-MM-DD>)` group; the author is everything
196/// before it. A head without a well-formed trailing date still yields the
197/// author (the date defaults to `""`), so malformed heads survive the parse
198/// without crashing (`contracts/comments-format.md` rule "preserve verbatim").
199fn parse_comment_head(after: &str) -> (String, String) {
200    let (author, date) = match after.len() {
201        len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
202            let close = len - 1;
203            let open = after[..close].rfind(" (");
204            match open {
205                Some(open) => {
206                    let maybe_date = &after[open + 2..close];
207                    if maybe_date.len() == 10
208                        && maybe_date.as_bytes()[4] == b'-'
209                        && maybe_date.as_bytes()[7] == b'-'
210                    {
211                        (&after[..open], maybe_date)
212                    } else {
213                        (after, "")
214                    }
215                }
216                None => (after, ""),
217            }
218        }
219        _ => (after, ""),
220    };
221    (author.trim_end().to_string(), date.to_string())
222}
223
224/// A single issue comment, parsed from the `## Comments` body section.
225///
226/// Mirrors `contracts/comments-format.md`: each comment is a level-3 heading
227/// `### <author> (<YYYY-MM-DD>)` followed by free text up to the next `###`
228/// head or end of file. Blank lines inside `text` are preserved verbatim
229/// (NFR-005); the model never normalizes hand-authored content.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct Comment {
232    pub author: String,
233    pub date: String,
234    pub text: String,
235}
236
237/// An issue: one markdown file with YAML frontmatter.
238///
239/// Field order and names are frozen by `contracts/file-format.md` FR-012.
240/// `number` is a stored frontmatter key assigned by the storage layer at
241/// create time by scanning the live issue set (issue #52; previously from
242/// `tix.toml` `[state].next_number`, which is no longer persisted); this
243/// module models and validates it but never allocates. `filename` and
244/// `body` are memory-only (`#[serde(skip)]`).
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct Issue {
247    /// Internal sequential id, never reused (C-007).
248    pub number: u32,
249    /// Human title; drives the slug in the filename.
250    #[serde(default)]
251    pub title: String,
252    /// Column (board workflow stage) the issue sits in.
253    #[serde(default)]
254    pub status: Status,
255    /// Free-form labels.
256    #[serde(default)]
257    pub labels: Labels,
258    /// Priority; defaults to `medium`.
259    #[serde(default)]
260    pub priority: Priority,
261    /// Assignee. In memory: bare handle (`alice`) or full `Name <email>`
262    /// identity, verbatim. Files serialize per the rule documented on
263    /// [`de_assignee`]/[`se_assignee`] (legacy `@alice` tolerated on read).
264    #[serde(
265        default,
266        skip_serializing_if = "Option::is_none",
267        deserialize_with = "de_assignee",
268        serialize_with = "se_assignee"
269    )]
270    pub assignee: Option<String>,
271    /// Free-form estimate (e.g. `2`, `S`, `3d`); default `null`.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub estimate: Option<String>,
274    /// Git commit recorded when moved to `done` (FR-026).
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub commit: Option<String>,
277    /// Remote GitLab issue id after export/import.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub gitlab_id: Option<String>,
280    /// Remote Linear issue id after export/import.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub linear_id: Option<String>,
283    /// Spec Kitty mission slug (e.g. `v3-archive-review`) this issue belongs to, if any.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub mission: Option<String>,
286    /// Spec Kitty work package id (e.g. `WP01`) this issue links to, if any.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub task: Option<String>,
289    /// Creation date, `YYYY-MM-DD`, equals the filename date prefix.
290    #[serde(default = "today")]
291    pub created: String,
292    /// Last-mutation date, `YYYY-MM-DD`.
293    #[serde(default = "today")]
294    pub updated: String,
295    /// 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).
296    #[serde(skip)]
297    pub filename: String,
298    /// Raw markdown body; memory-only, preserved verbatim.
299    #[serde(skip)]
300    pub body: String,
301}
302
303impl Issue {
304    /// Parse a full issue file (`frontmatter` + `body`) from its text.
305    ///
306    /// The first line must be `---`; the closing `---` terminates the
307    /// frontmatter. The body after it is preserved verbatim (the single
308    /// structural separator blank line is consumed).
309    pub fn parse(contents: &str) -> Result<Self> {
310        let rest = contents
311            .strip_prefix("---\n")
312            .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
313        let mut current = rest;
314        let mut fm_len = 0usize;
315        loop {
316            let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
317            let content = current[..line_end]
318                .strip_suffix('\n')
319                .unwrap_or(&current[..line_end]);
320            if content == "---" {
321                break;
322            }
323            fm_len += line_end;
324            current = &current[line_end..];
325            if current.is_empty() {
326                return Err(TixError::Parse(
327                    "frontmatter is not terminated by a closing `---`".into(),
328                ));
329            }
330        }
331        let frontmatter = &rest[..fm_len];
332        let body_raw = &rest[fm_len..];
333        let mut body = body_raw.strip_prefix("---\n").unwrap_or(body_raw);
334        if let Some(after) = body.strip_prefix('\n') {
335            body = after;
336        }
337        let body = body.to_string();
338        let mut issue: Issue =
339            serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
340        issue.body = body;
341        Ok(issue)
342    }
343
344    /// Serialize just the frontmatter YAML (no delimiters), with `None`
345    /// fields omitted except `created`/`updated` which are always present.
346    pub fn frontmatter(&self) -> String {
347        let fm =
348            serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
349        fm.trim_end_matches('\n').to_string() + "\n"
350    }
351
352    /// Render the full issue file: `---`, frontmatter, closing `---`,
353    /// blank separator and the raw body.
354    pub fn to_file_contents(&self) -> String {
355        format!("---\n{}---\n\n{}", self.frontmatter(), self.body)
356    }
357
358    /// The date-prefixed filename stem: `{created}-{slug}` (C-008).
359    pub fn filename_stem(&self) -> String {
360        format!("{}-{}", self.created, slugify(&self.title))
361    }
362
363    /// Record a mutation by stamping today's date into `updated`.
364    pub fn bump_updated(&mut self) {
365        self.updated = today();
366    }
367
368    /// The byte offset of the first top-level `## Comments` line in `body`,
369    /// if any (C-001, `contracts/comments-format.md` rule 6).
370    ///
371    /// A line counts as the boundary only when it is *not* inside a fenced
372    /// code block, so a literal `## Comments` in prose (or a code block) is
373    /// never mistaken for the real section head.
374    fn comments_boundary(body: &str) -> Option<usize> {
375        let mut in_fence = false;
376        let mut offset = 0usize;
377        for raw in body.split_inclusive('\n') {
378            let line = raw.strip_suffix('\n').unwrap_or(raw);
379            if line.starts_with("```") || line.starts_with("~~~") {
380                in_fence = !in_fence;
381            } else if !in_fence && line == "## Comments" {
382                return Some(offset);
383            }
384            offset += raw.len();
385        }
386        None
387    }
388
389    /// The description: everything above the first top-level `## Comments`
390    /// heading, trimmed of trailing whitespace. With no section, the whole
391    /// body is the description.
392    pub fn description(&self) -> &str {
393        match Self::comments_boundary(&self.body) {
394            Some(idx) => self.body[..idx].trim_end(),
395            None => self.body.trim_end(),
396        }
397    }
398
399    /// The comments parsed from the `## Comments` section, in file order
400    /// (NFR-005). With no section — or a section with no `###` heads —
401    /// returns an empty vec.
402    pub fn comments(&self) -> Vec<Comment> {
403        let Some(boundary) = Self::comments_boundary(&self.body) else {
404            return Vec::new();
405        };
406        let section = &self.body[boundary..];
407        let mut comments = Vec::new();
408        let mut offset = 0usize;
409        for raw in section.split_inclusive('\n') {
410            let line = raw.strip_suffix('\n').unwrap_or(raw);
411            if let Some(after) = line.strip_prefix("### ") {
412                let (author, date) = parse_comment_head(after);
413                let start = offset + raw.len();
414                let mut text = String::new();
415                for next in section[start..].split_inclusive('\n') {
416                    if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
417                        break;
418                    }
419                    text.push_str(next);
420                }
421                comments.push(Comment {
422                    author,
423                    date,
424                    text: text.trim_end_matches('\n').to_string(),
425                });
426            }
427            offset += raw.len();
428        }
429        comments
430    }
431
432    /// Append a comment to the `## Comments` section.
433    ///
434    /// Whitespace-only text is discarded and the body is left untouched
435    /// (FR-004 case 4). Otherwise the section is created if absent and the
436    /// comment block is appended after the last existing comment. The
437    /// description and any existing comments are never rewritten.
438    pub fn add_comment(&mut self, author: &str, text: &str) {
439        if text.trim().is_empty() {
440            return;
441        }
442        if Self::comments_boundary(&self.body).is_none() {
443            if !self.body.trim().is_empty() {
444                if !self.body.ends_with('\n') {
445                    self.body.push('\n');
446                }
447                self.body.push('\n');
448            }
449            self.body.push_str("## Comments\n");
450        }
451        if !self.body.is_empty() && !self.body.ends_with('\n') {
452            self.body.push('\n');
453        }
454        if !self.body.ends_with("\n\n") {
455            self.body.push('\n');
456        }
457        self.body
458            .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
459        self.bump_updated();
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn status_helpers() {
469        let s = Status::new("in progress");
470        assert!(s.is("in progress"));
471        assert!(!s.is("in_progress"));
472        assert_eq!(s.display(), "in progress");
473        assert_eq!(Status::default(), Status::new("todo"));
474    }
475
476    #[test]
477    fn status_survives_unknown_column_name() {
478        let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
479        assert_eq!(parsed.display(), "renamed-column");
480    }
481
482    #[test]
483    fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
484        assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
485        assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
486        assert_eq!(Priority::default(), Priority::Medium);
487        assert_eq!(Priority::Low.to_string(), "low");
488    }
489
490    #[test]
491    fn unknown_priority_is_preserved_not_normalized() {
492        let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
493        assert_eq!(urgent.to_string(), "urgent");
494        let yaml = serde_yaml::to_string(&urgent).unwrap();
495        assert!(yaml.contains("urgent"));
496    }
497
498    #[test]
499    fn issue_roundtrip_preserves_every_field_including_number_and_body() {
500        let mut issue = Issue {
501            number: 7,
502            title: "Add documentation for the feature".into(),
503            status: Status::new("todo"),
504            labels: vec!["docs".into(), "onboarding".into()],
505            priority: Priority::High,
506            assignee: Some("alice".into()),
507            estimate: Some("2".into()),
508            commit: Some("abc123".into()),
509            gitlab_id: Some("gl-9".into()),
510            linear_id: Some("LIN-9".into()),
511            mission: None,
512            task: None,
513            created: "2026-05-10".into(),
514            updated: "2026-05-10".into(),
515            filename: String::new(),
516            body: "Describe the issue here.\n\n![diagram](./assets/diagram.png)".into(),
517        };
518        let contents = issue.to_file_contents();
519        let parsed = Issue::parse(&contents).unwrap();
520        assert_eq!(parsed, issue);
521        issue.body.clear();
522        assert_ne!(parsed, issue);
523    }
524
525    #[test]
526    fn missing_dates_default_to_today() {
527        let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
528        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
529        assert_eq!(issue.created, today());
530        assert_eq!(issue.updated, today());
531    }
532
533    #[test]
534    fn body_is_preserved_verbatim() {
535        let issue = Issue {
536            number: 1,
537            title: "T".into(),
538            status: Status::default(),
539            labels: vec![],
540            priority: Priority::default(),
541            assignee: None,
542            estimate: None,
543            commit: None,
544            gitlab_id: None,
545            linear_id: None,
546            mission: None,
547            task: None,
548            created: "2026-01-02".into(),
549            updated: "2026-01-02".into(),
550            filename: String::new(),
551            body: "line one\n\nline two\n```\ncode\n```".into(),
552        };
553        let contents = issue.to_file_contents();
554        let parsed = Issue::parse(&contents).unwrap();
555        assert_eq!(parsed.body, issue.body);
556    }
557
558    #[test]
559    fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
560        let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
561        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
562        assert_eq!(issue.assignee.as_deref(), Some("alice"));
563        let rerendered = issue.frontmatter();
564        assert!(
565            rerendered.contains("assignee: \"@alice\"")
566                || rerendered.contains("assignee: '@alice'")
567        );
568    }
569
570    #[test]
571    fn full_identity_assignee_round_trips_verbatim() {
572        let issue = Issue {
573            number: 5,
574            title: "T".into(),
575            status: Status::default(),
576            labels: vec![],
577            priority: Priority::default(),
578            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
579            estimate: None,
580            commit: None,
581            gitlab_id: None,
582            linear_id: None,
583            mission: None,
584            task: None,
585            created: "2026-01-01".into(),
586            updated: "2026-01-01".into(),
587            filename: String::new(),
588            body: String::new(),
589        };
590        let fm = issue.frontmatter();
591        assert!(
592            fm.contains("Herbert Maschke <thyseus@pm.me>"),
593            "identity must be stored verbatim, got:\n{fm}"
594        );
595        assert!(
596            !fm.contains("@Herbert Maschke"),
597            "identity must not be @-prefixed, got:\n{fm}"
598        );
599        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
600        assert_eq!(parsed, issue);
601    }
602
603    #[test]
604    fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
605        let issue = Issue {
606            number: 6,
607            title: "T".into(),
608            status: Status::default(),
609            labels: vec![],
610            priority: Priority::default(),
611            assignee: Some("alice".into()),
612            estimate: None,
613            commit: None,
614            gitlab_id: None,
615            linear_id: None,
616            mission: None,
617            task: None,
618            created: "2026-01-01".into(),
619            updated: "2026-01-01".into(),
620            filename: String::new(),
621            body: String::new(),
622        };
623        let fm = issue.frontmatter();
624        assert!(
625            fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
626            "legacy @-prefixed bytes expected, got:\n{fm}"
627        );
628        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
629        assert_eq!(parsed.assignee.as_deref(), Some("alice"));
630    }
631
632    #[test]
633    fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
634        let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
635        let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
636        let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
637        let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
638        assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
639        assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
640    }
641
642    #[test]
643    fn angle_bracket_identity_without_name_written_verbatim() {
644        let issue = Issue {
645            number: 8,
646            title: "T".into(),
647            status: Status::default(),
648            labels: vec![],
649            priority: Priority::default(),
650            assignee: Some("<thyseus@pm.me>".into()),
651            estimate: None,
652            commit: None,
653            gitlab_id: None,
654            linear_id: None,
655            mission: None,
656            task: None,
657            created: "2026-01-01".into(),
658            updated: "2026-01-01".into(),
659            filename: String::new(),
660            body: String::new(),
661        };
662        let fm = issue.frontmatter();
663        assert!(
664            fm.contains("<thyseus@pm.me>"),
665            "identity-like value must be verbatim, got:\n{fm}"
666        );
667        assert!(
668            !fm.contains("@<thyseus"),
669            "identity-like value must not be @-prefixed, got:\n{fm}"
670        );
671    }
672
673    #[test]
674    fn all_none_frontmatter_only_contains_required_keys() {
675        let issue = Issue {
676            number: 4,
677            title: "T".into(),
678            status: Status::default(),
679            labels: vec![],
680            priority: Priority::default(),
681            assignee: None,
682            estimate: None,
683            commit: None,
684            gitlab_id: None,
685            linear_id: None,
686            mission: None,
687            task: None,
688            created: "2026-01-01".into(),
689            updated: "2026-01-01".into(),
690            filename: String::new(),
691            body: String::new(),
692        };
693        let fm = issue.frontmatter();
694        assert!(fm.contains("number:"));
695        assert!(fm.contains("title:"));
696        assert!(fm.contains("status:"));
697        assert!(fm.contains("labels:"));
698        assert!(fm.contains("priority:"));
699        assert!(fm.contains("created:"));
700        assert!(fm.contains("updated:"));
701        for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
702            assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
703        }
704    }
705
706    #[test]
707    fn filename_stem_matches_contract_format() {
708        let issue = Issue {
709            number: 1,
710            title: "Fix !Login crash?".into(),
711            status: Status::default(),
712            labels: vec![],
713            priority: Priority::default(),
714            assignee: None,
715            estimate: None,
716            commit: None,
717            gitlab_id: None,
718            linear_id: None,
719            mission: None,
720            task: None,
721            created: "2026-05-10".into(),
722            updated: "2026-05-10".into(),
723            filename: String::new(),
724            body: String::new(),
725        };
726        assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
727    }
728
729    #[test]
730    fn bump_updated_stamps_today() {
731        let mut issue = Issue {
732            number: 1,
733            title: "T".into(),
734            status: Status::default(),
735            labels: vec![],
736            priority: Priority::default(),
737            assignee: None,
738            estimate: None,
739            commit: None,
740            gitlab_id: None,
741            linear_id: None,
742            mission: None,
743            task: None,
744            created: "2020-01-01".into(),
745            updated: "2020-01-01".into(),
746            filename: String::new(),
747            body: String::new(),
748        };
749        issue.bump_updated();
750        assert_eq!(issue.updated, today());
751        assert_ne!(issue.updated, "2020-01-01");
752    }
753
754    #[test]
755    fn parse_rejects_malformed_frontmatter() {
756        assert!(Issue::parse("no delimiter\n").is_err());
757        assert!(Issue::parse("---\na: 1\n").is_err()); // unterminated
758        assert!(Issue::parse("---\nbroken: [").is_err()); // bad yaml, unterminated
759    }
760
761    #[test]
762    fn today_is_an_iso_calendar_date() {
763        let t = today();
764        assert_eq!(t.len(), 10);
765        let parts: Vec<&str> = t.split('-').collect();
766        assert_eq!(parts.len(), 3);
767        assert_eq!(parts[0].len(), 4);
768        assert_eq!(parts[1].len(), 2);
769        assert_eq!(parts[2].len(), 2);
770    }
771
772    #[test]
773    fn civil_from_days_epoch_is_1970_01_01() {
774        assert_eq!(civil_from_days(0), (1970, 1, 1));
775    }
776
777    // ------------------------------------------------------------------
778    // WP01 — Comment data model & body lifecycle
779    // ------------------------------------------------------------------
780
781    fn issue(body: &str) -> Issue {
782        Issue {
783            number: 1,
784            title: "T".into(),
785            status: Status::default(),
786            labels: vec![],
787            priority: Priority::default(),
788            assignee: None,
789            estimate: None,
790            commit: None,
791            gitlab_id: None,
792            linear_id: None,
793            mission: None,
794            task: None,
795            created: "2026-01-01".into(),
796            updated: "2026-01-01".into(),
797            filename: String::new(),
798            body: body.to_string(),
799        }
800    }
801
802    // --- T001: parse body → description + comments ---
803
804    #[test]
805    fn comments_empty_when_no_section() {
806        let i = issue("Some description text.\n");
807        assert_eq!(i.description(), "Some description text.");
808        assert!(i.comments().is_empty());
809    }
810
811    #[test]
812    fn comments_parsed_from_section() {
813        let i = issue(
814            "Hello\n\n## Comments\n\n\
815             ### @alice (2026-09-13)\n\n\
816             First comment.\n\n\
817             ### bob (2026-09-14)\n\n\
818             Second comment.\n",
819        );
820        assert_eq!(i.description(), "Hello");
821        let cs = i.comments();
822        assert_eq!(cs.len(), 2);
823        assert_eq!(cs[0].author, "@alice");
824        assert_eq!(cs[0].date, "2026-09-13");
825        assert_eq!(cs[1].author, "bob");
826        assert_eq!(cs[1].date, "2026-09-14");
827    }
828
829    #[test]
830    fn code_fence_boundary_not_treated_as_boundary() {
831        let body = "Description\n\n\
832             ```\n\
833             ## Comments\n\
834             ```\n\n\
835             ## Comments\n\n\
836             ### eve (2026-10-01)\n\n\
837             real comment\n";
838        let i = issue(body);
839        let cs = i.comments();
840        assert_eq!(cs.len(), 1);
841        assert_eq!(cs[0].author, "eve");
842        assert_eq!(cs[0].date, "2026-10-01");
843        assert_eq!(cs[0].text, "\nreal comment");
844        assert!(
845            i.description().contains("## Comments"),
846            "code-blocked heading must be in description"
847        );
848    }
849
850    #[test]
851    fn blank_lines_preserved_in_comment_text() {
852        let i = issue(
853            "## Comments\n\n\
854             ### @alice (2026-09-13)\n\n\
855             line one\n\nline two\n\n\n\
856             line three\n",
857        );
858        let cs = i.comments();
859        assert_eq!(cs.len(), 1);
860        let text = &cs[0].text;
861        assert!(
862            text.contains("\nline two"),
863            "internal blank lines must be preserved: got {text:?}"
864        );
865        assert!(
866            text.contains("line three"),
867            "must contain all lines: got {text:?}"
868        );
869    }
870
871    #[test]
872    fn comments_empty_when_section_has_no_heads() {
873        let i = issue("## Comments\n\n\n");
874        assert!(i.comments().is_empty());
875    }
876
877    // --- T002: add_comment ---
878
879    #[test]
880    fn add_first_comment_to_empty_body() {
881        let mut i = issue("");
882        i.add_comment("@alice", "Hello world");
883        assert_eq!(i.description(), "");
884        let cs = i.comments();
885        assert_eq!(cs.len(), 1);
886        assert_eq!(cs[0].author, "@alice");
887        assert_eq!(cs[0].text, "\nHello world");
888        assert!(i.body.contains("## Comments"));
889    }
890
891    #[test]
892    fn second_comment_appended_after_first() {
893        let mut i = issue("");
894        i.add_comment("@alice", "first");
895        i.add_comment("bob", "second");
896        let cs = i.comments();
897        assert_eq!(cs.len(), 2);
898        let pos_a = i.body.find("### @alice");
899        let pos_b = i.body.find("### bob");
900        assert!(pos_a < pos_b, "first comment must precede second");
901    }
902
903    #[test]
904    fn whitespace_only_comment_discarded() {
905        let mut i = issue("Hello\n");
906        let before = i.body.clone();
907        i.add_comment("@alice", "   \n  ");
908        assert_eq!(i.body, before, "whitespace-only text must not change body");
909    }
910
911    #[test]
912    fn description_unchanged_after_add_comment() {
913        let mut i = issue("Description text.\n");
914        i.add_comment("@alice", "a comment");
915        assert_eq!(i.description(), "Description text.");
916    }
917
918    #[test]
919    fn literal_comments_heading_in_code_block_not_touched() {
920        let mut i = issue("```\n## Comments\n```\n");
921        i.add_comment("@alice", "new");
922        assert!(
923            i.body.contains("```\n## Comments\n```"),
924            "code-block heading must be preserved: {0:?}",
925            i.body
926        );
927        assert!(i.body.contains("### @alice"), "must contain new comment");
928    }
929
930    // --- T003: roundtrip / NFR-005 ---
931
932    #[test]
933    fn roundtrip_preserves_comment_data() {
934        let mut i = issue(
935            "## Comments\n\n\
936             ### @alice (2026-09-13)\n\n\
937             Original comment.\n",
938        );
939        i.add_comment("bob", "Second comment");
940        let file = i.to_file_contents();
941        let parsed = Issue::parse(&file).unwrap();
942        assert_eq!(parsed.description(), i.description());
943        let cs = parsed.comments();
944        assert_eq!(cs.len(), 2);
945        assert_eq!(cs[0].author, "@alice");
946        assert_eq!(cs[1].author, "bob");
947        assert_eq!(cs[1].text, "\nSecond comment");
948    }
949
950    #[test]
951    fn three_comments_in_file_order() {
952        let mut i = issue("");
953        i.add_comment("a", "one");
954        i.add_comment("b", "two");
955        i.add_comment("c", "three");
956        let cs = i.comments();
957        assert_eq!(
958            cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
959            ["a", "b", "c"]
960        );
961        assert_eq!(
962            i.body,
963            format!(
964                "## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
965                today()
966            )
967        );
968    }
969
970    #[test]
971    fn preserves_hand_authored_comment_markdown() {
972        let body = "## Comments\n\n\
973             ### @alice (2026-09-13)\n\n\
974             **bold** and _italic_\n\n\
975             - item 1\n- item 2\n\n";
976        let i = issue(body);
977        let cs = i.comments();
978        assert_eq!(cs.len(), 1);
979        assert!(cs[0].text.contains("**bold**"));
980        assert!(cs[0].text.contains("- item 1"));
981    }
982
983    #[test]
984    fn description_code_block_heading_not_boundary() {
985        let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
986        let before = i.body.clone();
987        i.add_comment("eve", "real");
988        assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
989        let cs = i.comments();
990        assert_eq!(cs.len(), 1);
991        assert_eq!(cs[0].author, "eve");
992        assert!(
993            i.body.starts_with(&before),
994            "code-block heading must be preserved"
995        );
996    }
997}