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    /// Related issue numbers, as authored (tolerant like `labels`); symmetric
290    /// links maintained by `tix link`/`--links` writes. Default `[]`.
291    #[serde(default)]
292    pub links: Vec<String>,
293    /// Repo-relative file paths of the governing specification (e.g.
294    /// `openspec/changes/<name>/specs/<cap>/spec.md`). Default `[]`.
295    #[serde(default)]
296    pub files: Vec<String>,
297    /// Creation date, `YYYY-MM-DD`, equals the filename date prefix.
298    #[serde(default = "today")]
299    pub created: String,
300    /// Last-mutation date, `YYYY-MM-DD`.
301    #[serde(default = "today")]
302    pub updated: String,
303    /// 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).
304    #[serde(skip)]
305    pub filename: String,
306    /// Raw markdown body; memory-only, preserved verbatim.
307    #[serde(skip)]
308    pub body: String,
309}
310
311impl Issue {
312    /// Parse a full issue file (`frontmatter` + `body`) from its text.
313    ///
314    /// The first line must be `---`; the closing `---` terminates the
315    /// frontmatter. The body after it is preserved verbatim (the single
316    /// structural separator blank line is consumed).
317    pub fn parse(contents: &str) -> Result<Self> {
318        let rest = contents
319            .strip_prefix("---\n")
320            .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
321        let mut current = rest;
322        let mut fm_len = 0usize;
323        loop {
324            let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
325            let content = current[..line_end]
326                .strip_suffix('\n')
327                .unwrap_or(&current[..line_end]);
328            if content == "---" {
329                break;
330            }
331            fm_len += line_end;
332            current = &current[line_end..];
333            if current.is_empty() {
334                return Err(TixError::Parse(
335                    "frontmatter is not terminated by a closing `---`".into(),
336                ));
337            }
338        }
339        let frontmatter = &rest[..fm_len];
340        let body_raw = &rest[fm_len..];
341        let mut body = body_raw.strip_prefix("---\n").unwrap_or(body_raw);
342        if let Some(after) = body.strip_prefix('\n') {
343            body = after;
344        }
345        let body = body.to_string();
346        let mut issue: Issue =
347            serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
348        issue.body = body;
349        Ok(issue)
350    }
351
352    /// Serialize just the frontmatter YAML (no delimiters), with `None`
353    /// fields omitted except `created`/`updated` which are always present.
354    pub fn frontmatter(&self) -> String {
355        let fm =
356            serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
357        fm.trim_end_matches('\n').to_string() + "\n"
358    }
359
360    /// Render the full issue file: `---`, frontmatter, closing `---`,
361    /// blank separator and the raw body.
362    pub fn to_file_contents(&self) -> String {
363        format!("---\n{}---\n\n{}", self.frontmatter(), self.body)
364    }
365
366    /// The date-prefixed filename stem: `{created}-{slug}` (C-008).
367    pub fn filename_stem(&self) -> String {
368        format!("{}-{}", self.created, slugify(&self.title))
369    }
370
371    /// Record a mutation by stamping today's date into `updated`.
372    pub fn bump_updated(&mut self) {
373        self.updated = today();
374    }
375
376    /// The byte offset of the first top-level `## Comments` line in `body`,
377    /// if any (C-001, `contracts/comments-format.md` rule 6).
378    ///
379    /// A line counts as the boundary only when it is *not* inside a fenced
380    /// code block, so a literal `## Comments` in prose (or a code block) is
381    /// never mistaken for the real section head.
382    fn comments_boundary(body: &str) -> Option<usize> {
383        let mut in_fence = false;
384        let mut offset = 0usize;
385        for raw in body.split_inclusive('\n') {
386            let line = raw.strip_suffix('\n').unwrap_or(raw);
387            if line.starts_with("```") || line.starts_with("~~~") {
388                in_fence = !in_fence;
389            } else if !in_fence && line == "## Comments" {
390                return Some(offset);
391            }
392            offset += raw.len();
393        }
394        None
395    }
396
397    /// The description: everything above the first top-level `## Comments`
398    /// heading, trimmed of trailing whitespace. With no section, the whole
399    /// body is the description.
400    pub fn description(&self) -> &str {
401        match Self::comments_boundary(&self.body) {
402            Some(idx) => self.body[..idx].trim_end(),
403            None => self.body.trim_end(),
404        }
405    }
406
407    /// The comments parsed from the `## Comments` section, in file order
408    /// (NFR-005). With no section — or a section with no `###` heads —
409    /// returns an empty vec.
410    pub fn comments(&self) -> Vec<Comment> {
411        let Some(boundary) = Self::comments_boundary(&self.body) else {
412            return Vec::new();
413        };
414        let section = &self.body[boundary..];
415        let mut comments = Vec::new();
416        let mut offset = 0usize;
417        for raw in section.split_inclusive('\n') {
418            let line = raw.strip_suffix('\n').unwrap_or(raw);
419            if let Some(after) = line.strip_prefix("### ") {
420                let (author, date) = parse_comment_head(after);
421                let start = offset + raw.len();
422                let mut text = String::new();
423                for next in section[start..].split_inclusive('\n') {
424                    if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
425                        break;
426                    }
427                    text.push_str(next);
428                }
429                comments.push(Comment {
430                    author,
431                    date,
432                    text: text.trim_end_matches('\n').to_string(),
433                });
434            }
435            offset += raw.len();
436        }
437        comments
438    }
439
440    /// Append a comment to the `## Comments` section.
441    ///
442    /// Whitespace-only text is discarded and the body is left untouched
443    /// (FR-004 case 4). Otherwise the section is created if absent and the
444    /// comment block is appended after the last existing comment. The
445    /// description and any existing comments are never rewritten.
446    pub fn add_comment(&mut self, author: &str, text: &str) {
447        if text.trim().is_empty() {
448            return;
449        }
450        if Self::comments_boundary(&self.body).is_none() {
451            if !self.body.trim().is_empty() {
452                if !self.body.ends_with('\n') {
453                    self.body.push('\n');
454                }
455                self.body.push('\n');
456            }
457            self.body.push_str("## Comments\n");
458        }
459        if !self.body.is_empty() && !self.body.ends_with('\n') {
460            self.body.push('\n');
461        }
462        if !self.body.ends_with("\n\n") {
463            self.body.push('\n');
464        }
465        self.body
466            .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
467        self.bump_updated();
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn status_helpers() {
477        let s = Status::new("in progress");
478        assert!(s.is("in progress"));
479        assert!(!s.is("in_progress"));
480        assert_eq!(s.display(), "in progress");
481        assert_eq!(Status::default(), Status::new("todo"));
482    }
483
484    #[test]
485    fn status_survives_unknown_column_name() {
486        let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
487        assert_eq!(parsed.display(), "renamed-column");
488    }
489
490    #[test]
491    fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
492        assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
493        assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
494        assert_eq!(Priority::default(), Priority::Medium);
495        assert_eq!(Priority::Low.to_string(), "low");
496    }
497
498    #[test]
499    fn unknown_priority_is_preserved_not_normalized() {
500        let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
501        assert_eq!(urgent.to_string(), "urgent");
502        let yaml = serde_yaml::to_string(&urgent).unwrap();
503        assert!(yaml.contains("urgent"));
504    }
505
506    #[test]
507    fn issue_roundtrip_preserves_every_field_including_number_and_body() {
508        let mut issue = Issue {
509            number: 7,
510            title: "Add documentation for the feature".into(),
511            status: Status::new("todo"),
512            labels: vec!["docs".into(), "onboarding".into()],
513            priority: Priority::High,
514            assignee: Some("alice".into()),
515            estimate: Some("2".into()),
516            commit: Some("abc123".into()),
517            gitlab_id: Some("gl-9".into()),
518            linear_id: Some("LIN-9".into()),
519            mission: None,
520            task: None,
521            links: vec![],
522            files: vec![],
523            created: "2026-05-10".into(),
524            updated: "2026-05-10".into(),
525            filename: String::new(),
526            body: "Describe the issue here.\n\n![diagram](./assets/diagram.png)".into(),
527        };
528        let contents = issue.to_file_contents();
529        let parsed = Issue::parse(&contents).unwrap();
530        assert_eq!(parsed, issue);
531        issue.body.clear();
532        assert_ne!(parsed, issue);
533    }
534
535    #[test]
536    fn links_round_trip_preserves_entries_verbatim() {
537        let issue = Issue {
538            number: 9,
539            title: "T".into(),
540            status: Status::default(),
541            labels: vec![],
542            priority: Priority::default(),
543            assignee: None,
544            estimate: None,
545            commit: None,
546            gitlab_id: None,
547            linear_id: None,
548            mission: None,
549            task: None,
550            links: vec!["12".into(), "47".into()],
551            files: vec!["docs/x.md".into(), "specs/y/spec.md".into()],
552            created: "2026-01-01".into(),
553            updated: "2026-01-01".into(),
554            filename: String::new(),
555            body: "body".into(),
556        };
557        let contents = issue.to_file_contents();
558        let parsed = Issue::parse(&contents).unwrap();
559        assert_eq!(parsed, issue);
560        assert_eq!(parsed.links, ["12", "47"]);
561        assert_eq!(parsed.files, ["docs/x.md", "specs/y/spec.md"]);
562    }
563
564    #[test]
565    fn empty_links_and_files_serialize_as_inline_empty_lists() {
566        let issue = Issue {
567            number: 4,
568            title: "T".into(),
569            status: Status::default(),
570            labels: vec![],
571            priority: Priority::default(),
572            assignee: None,
573            estimate: None,
574            commit: None,
575            gitlab_id: None,
576            linear_id: None,
577            mission: None,
578            task: None,
579            links: vec![],
580            files: vec![],
581            created: "2026-01-01".into(),
582            updated: "2026-01-01".into(),
583            filename: String::new(),
584            body: String::new(),
585        };
586        let fm = issue.frontmatter();
587        assert!(fm.contains("links: []"), "got:\n{fm}");
588        assert!(fm.contains("files: []"), "got:\n{fm}");
589    }
590
591    #[test]
592    fn non_numeric_link_entries_parse_without_error() {
593        let yaml = "\
594number: 1
595title: T
596status: todo
597links:
598- '12'
599- unknown-token
600- 47
601created: '2026-01-01'
602updated: '2026-01-01'
603";
604        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
605        assert_eq!(issue.links, ["12", "unknown-token", "47"]);
606        let parsed = Issue::parse(&format!("---\n{yaml}---\n\nbody\n")).unwrap();
607        assert_eq!(parsed.links.len(), 3);
608        assert_eq!(parsed.links[1], "unknown-token");
609    }
610
611    #[test]
612    fn missing_dates_default_to_today() {
613        let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
614        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
615        assert_eq!(issue.created, today());
616        assert_eq!(issue.updated, today());
617    }
618
619    #[test]
620    fn body_is_preserved_verbatim() {
621        let issue = Issue {
622            number: 1,
623            title: "T".into(),
624            status: Status::default(),
625            labels: vec![],
626            priority: Priority::default(),
627            assignee: None,
628            estimate: None,
629            commit: None,
630            gitlab_id: None,
631            linear_id: None,
632            mission: None,
633            task: None,
634            links: vec![],
635            files: vec![],
636            created: "2026-01-02".into(),
637            updated: "2026-01-02".into(),
638            filename: String::new(),
639            body: "line one\n\nline two\n```\ncode\n```".into(),
640        };
641        let contents = issue.to_file_contents();
642        let parsed = Issue::parse(&contents).unwrap();
643        assert_eq!(parsed.body, issue.body);
644    }
645
646    #[test]
647    fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
648        let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
649        let issue: Issue = serde_yaml::from_str(yaml).unwrap();
650        assert_eq!(issue.assignee.as_deref(), Some("alice"));
651        let rerendered = issue.frontmatter();
652        assert!(
653            rerendered.contains("assignee: \"@alice\"")
654                || rerendered.contains("assignee: '@alice'")
655        );
656    }
657
658    #[test]
659    fn full_identity_assignee_round_trips_verbatim() {
660        let issue = Issue {
661            number: 5,
662            title: "T".into(),
663            status: Status::default(),
664            labels: vec![],
665            priority: Priority::default(),
666            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
667            estimate: None,
668            commit: None,
669            gitlab_id: None,
670            linear_id: None,
671            mission: None,
672            task: None,
673            links: vec![],
674            files: vec![],
675            created: "2026-01-01".into(),
676            updated: "2026-01-01".into(),
677            filename: String::new(),
678            body: String::new(),
679        };
680        let fm = issue.frontmatter();
681        assert!(
682            fm.contains("Herbert Maschke <thyseus@pm.me>"),
683            "identity must be stored verbatim, got:\n{fm}"
684        );
685        assert!(
686            !fm.contains("@Herbert Maschke"),
687            "identity must not be @-prefixed, got:\n{fm}"
688        );
689        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
690        assert_eq!(parsed, issue);
691    }
692
693    #[test]
694    fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
695        let issue = Issue {
696            number: 6,
697            title: "T".into(),
698            status: Status::default(),
699            labels: vec![],
700            priority: Priority::default(),
701            assignee: Some("alice".into()),
702            estimate: None,
703            commit: None,
704            gitlab_id: None,
705            linear_id: None,
706            mission: None,
707            task: None,
708            links: vec![],
709            files: vec![],
710            created: "2026-01-01".into(),
711            updated: "2026-01-01".into(),
712            filename: String::new(),
713            body: String::new(),
714        };
715        let fm = issue.frontmatter();
716        assert!(
717            fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
718            "legacy @-prefixed bytes expected, got:\n{fm}"
719        );
720        let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
721        assert_eq!(parsed.assignee.as_deref(), Some("alice"));
722    }
723
724    #[test]
725    fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
726        let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
727        let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
728        let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
729        let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
730        assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
731        assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
732    }
733
734    #[test]
735    fn angle_bracket_identity_without_name_written_verbatim() {
736        let issue = Issue {
737            number: 8,
738            title: "T".into(),
739            status: Status::default(),
740            labels: vec![],
741            priority: Priority::default(),
742            assignee: Some("<thyseus@pm.me>".into()),
743            estimate: None,
744            commit: None,
745            gitlab_id: None,
746            linear_id: None,
747            mission: None,
748            task: None,
749            links: vec![],
750            files: vec![],
751            created: "2026-01-01".into(),
752            updated: "2026-01-01".into(),
753            filename: String::new(),
754            body: String::new(),
755        };
756        let fm = issue.frontmatter();
757        assert!(
758            fm.contains("<thyseus@pm.me>"),
759            "identity-like value must be verbatim, got:\n{fm}"
760        );
761        assert!(
762            !fm.contains("@<thyseus"),
763            "identity-like value must not be @-prefixed, got:\n{fm}"
764        );
765    }
766
767    #[test]
768    fn all_none_frontmatter_only_contains_required_keys() {
769        let issue = Issue {
770            number: 4,
771            title: "T".into(),
772            status: Status::default(),
773            labels: vec![],
774            priority: Priority::default(),
775            assignee: None,
776            estimate: None,
777            commit: None,
778            gitlab_id: None,
779            linear_id: None,
780            mission: None,
781            task: None,
782            links: vec![],
783            files: vec![],
784            created: "2026-01-01".into(),
785            updated: "2026-01-01".into(),
786            filename: String::new(),
787            body: String::new(),
788        };
789        let fm = issue.frontmatter();
790        assert!(fm.contains("number:"));
791        assert!(fm.contains("title:"));
792        assert!(fm.contains("status:"));
793        assert!(fm.contains("labels:"));
794        assert!(fm.contains("priority:"));
795        assert!(fm.contains("created:"));
796        assert!(fm.contains("updated:"));
797        for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
798            assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
799        }
800    }
801
802    #[test]
803    fn filename_stem_matches_contract_format() {
804        let issue = Issue {
805            number: 1,
806            title: "Fix !Login crash?".into(),
807            status: Status::default(),
808            labels: vec![],
809            priority: Priority::default(),
810            assignee: None,
811            estimate: None,
812            commit: None,
813            gitlab_id: None,
814            linear_id: None,
815            mission: None,
816            task: None,
817            links: vec![],
818            files: vec![],
819            created: "2026-05-10".into(),
820            updated: "2026-05-10".into(),
821            filename: String::new(),
822            body: String::new(),
823        };
824        assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
825    }
826
827    #[test]
828    fn bump_updated_stamps_today() {
829        let mut issue = Issue {
830            number: 1,
831            title: "T".into(),
832            status: Status::default(),
833            labels: vec![],
834            priority: Priority::default(),
835            assignee: None,
836            estimate: None,
837            commit: None,
838            gitlab_id: None,
839            linear_id: None,
840            mission: None,
841            task: None,
842            links: vec![],
843            files: vec![],
844            created: "2020-01-01".into(),
845            updated: "2020-01-01".into(),
846            filename: String::new(),
847            body: String::new(),
848        };
849        issue.bump_updated();
850        assert_eq!(issue.updated, today());
851        assert_ne!(issue.updated, "2020-01-01");
852    }
853
854    #[test]
855    fn parse_rejects_malformed_frontmatter() {
856        assert!(Issue::parse("no delimiter\n").is_err());
857        assert!(Issue::parse("---\na: 1\n").is_err()); // unterminated
858        assert!(Issue::parse("---\nbroken: [").is_err()); // bad yaml, unterminated
859    }
860
861    #[test]
862    fn today_is_an_iso_calendar_date() {
863        let t = today();
864        assert_eq!(t.len(), 10);
865        let parts: Vec<&str> = t.split('-').collect();
866        assert_eq!(parts.len(), 3);
867        assert_eq!(parts[0].len(), 4);
868        assert_eq!(parts[1].len(), 2);
869        assert_eq!(parts[2].len(), 2);
870    }
871
872    #[test]
873    fn civil_from_days_epoch_is_1970_01_01() {
874        assert_eq!(civil_from_days(0), (1970, 1, 1));
875    }
876
877    // ------------------------------------------------------------------
878    // WP01 — Comment data model & body lifecycle
879    // ------------------------------------------------------------------
880
881    fn issue(body: &str) -> Issue {
882        Issue {
883            number: 1,
884            title: "T".into(),
885            status: Status::default(),
886            labels: vec![],
887            priority: Priority::default(),
888            assignee: None,
889            estimate: None,
890            commit: None,
891            gitlab_id: None,
892            linear_id: None,
893            mission: None,
894            task: None,
895            links: vec![],
896            files: vec![],
897            created: "2026-01-01".into(),
898            updated: "2026-01-01".into(),
899            filename: String::new(),
900            body: body.to_string(),
901        }
902    }
903
904    // --- T001: parse body → description + comments ---
905
906    #[test]
907    fn comments_empty_when_no_section() {
908        let i = issue("Some description text.\n");
909        assert_eq!(i.description(), "Some description text.");
910        assert!(i.comments().is_empty());
911    }
912
913    #[test]
914    fn comments_parsed_from_section() {
915        let i = issue(
916            "Hello\n\n## Comments\n\n\
917             ### @alice (2026-09-13)\n\n\
918             First comment.\n\n\
919             ### bob (2026-09-14)\n\n\
920             Second comment.\n",
921        );
922        assert_eq!(i.description(), "Hello");
923        let cs = i.comments();
924        assert_eq!(cs.len(), 2);
925        assert_eq!(cs[0].author, "@alice");
926        assert_eq!(cs[0].date, "2026-09-13");
927        assert_eq!(cs[1].author, "bob");
928        assert_eq!(cs[1].date, "2026-09-14");
929    }
930
931    #[test]
932    fn code_fence_boundary_not_treated_as_boundary() {
933        let body = "Description\n\n\
934             ```\n\
935             ## Comments\n\
936             ```\n\n\
937             ## Comments\n\n\
938             ### eve (2026-10-01)\n\n\
939             real comment\n";
940        let i = issue(body);
941        let cs = i.comments();
942        assert_eq!(cs.len(), 1);
943        assert_eq!(cs[0].author, "eve");
944        assert_eq!(cs[0].date, "2026-10-01");
945        assert_eq!(cs[0].text, "\nreal comment");
946        assert!(
947            i.description().contains("## Comments"),
948            "code-blocked heading must be in description"
949        );
950    }
951
952    #[test]
953    fn blank_lines_preserved_in_comment_text() {
954        let i = issue(
955            "## Comments\n\n\
956             ### @alice (2026-09-13)\n\n\
957             line one\n\nline two\n\n\n\
958             line three\n",
959        );
960        let cs = i.comments();
961        assert_eq!(cs.len(), 1);
962        let text = &cs[0].text;
963        assert!(
964            text.contains("\nline two"),
965            "internal blank lines must be preserved: got {text:?}"
966        );
967        assert!(
968            text.contains("line three"),
969            "must contain all lines: got {text:?}"
970        );
971    }
972
973    #[test]
974    fn comments_empty_when_section_has_no_heads() {
975        let i = issue("## Comments\n\n\n");
976        assert!(i.comments().is_empty());
977    }
978
979    // --- T002: add_comment ---
980
981    #[test]
982    fn add_first_comment_to_empty_body() {
983        let mut i = issue("");
984        i.add_comment("@alice", "Hello world");
985        assert_eq!(i.description(), "");
986        let cs = i.comments();
987        assert_eq!(cs.len(), 1);
988        assert_eq!(cs[0].author, "@alice");
989        assert_eq!(cs[0].text, "\nHello world");
990        assert!(i.body.contains("## Comments"));
991    }
992
993    #[test]
994    fn second_comment_appended_after_first() {
995        let mut i = issue("");
996        i.add_comment("@alice", "first");
997        i.add_comment("bob", "second");
998        let cs = i.comments();
999        assert_eq!(cs.len(), 2);
1000        let pos_a = i.body.find("### @alice");
1001        let pos_b = i.body.find("### bob");
1002        assert!(pos_a < pos_b, "first comment must precede second");
1003    }
1004
1005    #[test]
1006    fn whitespace_only_comment_discarded() {
1007        let mut i = issue("Hello\n");
1008        let before = i.body.clone();
1009        i.add_comment("@alice", "   \n  ");
1010        assert_eq!(i.body, before, "whitespace-only text must not change body");
1011    }
1012
1013    #[test]
1014    fn description_unchanged_after_add_comment() {
1015        let mut i = issue("Description text.\n");
1016        i.add_comment("@alice", "a comment");
1017        assert_eq!(i.description(), "Description text.");
1018    }
1019
1020    #[test]
1021    fn literal_comments_heading_in_code_block_not_touched() {
1022        let mut i = issue("```\n## Comments\n```\n");
1023        i.add_comment("@alice", "new");
1024        assert!(
1025            i.body.contains("```\n## Comments\n```"),
1026            "code-block heading must be preserved: {0:?}",
1027            i.body
1028        );
1029        assert!(i.body.contains("### @alice"), "must contain new comment");
1030    }
1031
1032    // --- T003: roundtrip / NFR-005 ---
1033
1034    #[test]
1035    fn roundtrip_preserves_comment_data() {
1036        let mut i = issue(
1037            "## Comments\n\n\
1038             ### @alice (2026-09-13)\n\n\
1039             Original comment.\n",
1040        );
1041        i.add_comment("bob", "Second comment");
1042        let file = i.to_file_contents();
1043        let parsed = Issue::parse(&file).unwrap();
1044        assert_eq!(parsed.description(), i.description());
1045        let cs = parsed.comments();
1046        assert_eq!(cs.len(), 2);
1047        assert_eq!(cs[0].author, "@alice");
1048        assert_eq!(cs[1].author, "bob");
1049        assert_eq!(cs[1].text, "\nSecond comment");
1050    }
1051
1052    #[test]
1053    fn three_comments_in_file_order() {
1054        let mut i = issue("");
1055        i.add_comment("a", "one");
1056        i.add_comment("b", "two");
1057        i.add_comment("c", "three");
1058        let cs = i.comments();
1059        assert_eq!(
1060            cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
1061            ["a", "b", "c"]
1062        );
1063        assert_eq!(
1064            i.body,
1065            format!(
1066                "## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
1067                today()
1068            )
1069        );
1070    }
1071
1072    #[test]
1073    fn preserves_hand_authored_comment_markdown() {
1074        let body = "## Comments\n\n\
1075             ### @alice (2026-09-13)\n\n\
1076             **bold** and _italic_\n\n\
1077             - item 1\n- item 2\n\n";
1078        let i = issue(body);
1079        let cs = i.comments();
1080        assert_eq!(cs.len(), 1);
1081        assert!(cs[0].text.contains("**bold**"));
1082        assert!(cs[0].text.contains("- item 1"));
1083    }
1084
1085    #[test]
1086    fn description_code_block_heading_not_boundary() {
1087        let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
1088        let before = i.body.clone();
1089        i.add_comment("eve", "real");
1090        assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
1091        let cs = i.comments();
1092        assert_eq!(cs.len(), 1);
1093        assert_eq!(cs[0].author, "eve");
1094        assert!(
1095            i.body.starts_with(&before),
1096            "code-block heading must be preserved"
1097        );
1098    }
1099}