Skip to main content

tix/
model.rs

1//! Issue data model and file-surface types.
2//!
3//! Mirrors `contracts/file-format.md` and `data-model.md` exactly. The
4//! [`Issue`] struct is the Serde surface for the YAML frontmatter of an
5//! issue file; the markdown body is carried in `body` and round-trips
6//! verbatim. [`Status`], [`Priority`] and [`Labels`] are the value types
7//! that appear in frontmatter.
8
9use std::collections::{BTreeMap, HashSet};
10use std::fmt;
11use std::str::FromStr;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::error::{Result, TixError};
17use crate::relationships::{IssueView, Relationship};
18use crate::slug::slugify;
19
20mod dates;
21mod frontmatter;
22mod history;
23mod issue;
24mod refs;
25pub use dates::*;
26pub use frontmatter::*;
27pub use history::*;
28pub use issue::*;
29pub use refs::*;
30
31/// A board column name. A newtype over the user-defined column strings from
32/// `tix.toml`; unknown names survive deserialization because columns are
33/// config-defined (a renamed column renders as a "Mismatched" pseudo-column,
34/// never a crash).
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct Status(pub String);
37
38impl Status {
39    /// Create a status from a column name.
40    pub fn new(name: impl Into<String>) -> Self {
41        Status(name.into())
42    }
43
44    /// Whether this status equals `name` (exact, case-sensitive).
45    pub fn is(&self, name: &str) -> bool {
46        self.0 == name
47    }
48
49    /// The column name this status refers to.
50    pub fn display(&self) -> &str {
51        &self.0
52    }
53}
54
55impl Default for Status {
56    fn default() -> Self {
57        Status("todo".to_string())
58    }
59}
60
61impl fmt::Display for Status {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(&self.0)
64    }
65}
66
67/// Issue priority.
68///
69/// The documented set is `low` / `medium` / `high` with `medium` as the
70/// default (spec + data-model). Any other value is preserved as-is for
71/// forward compatibility (data-model: "unknown values accepted as-is",
72/// "preserved ... but not normalized"), so e.g. `urgent` round-trips
73/// unchanged.
74#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
75pub enum Priority {
76    Low,
77    #[default]
78    Medium,
79    High,
80    /// An unknown priority string, preserved verbatim (forward-compatible).
81    ///
82    /// The empty string is *not* treated as unknown: it is an explicitly unset
83    /// priority, which `tix lint` accepts and which round-trips as `''`. It stays
84    /// in this variant rather than getting a variant of its own because
85    /// `Priority` is public API on a published crate, and adding a variant to a
86    /// non-`#[non_exhaustive]` enum is a semver break for every downstream
87    /// `match`. Use [`Priority::is_undefined`] rather than comparing the string.
88    Other(String),
89}
90
91impl Priority {
92    fn as_str(&self) -> &str {
93        match self {
94            Priority::Low => "low",
95            Priority::Medium => "medium",
96            Priority::High => "high",
97            Priority::Other(s) => s,
98        }
99    }
100
101    /// Whether the priority is explicitly **unset** — written `priority: ''`.
102    ///
103    /// Distinct from [`Priority::default`], which is `Medium`. An issue can
104    /// therefore say "no priority" without being read as "medium", and without
105    /// the value being silently rewritten on the next write.
106    ///
107    /// `tix lint` accepts this state; see [`crate::commands::lint`] for the
108    /// check it is exempt from.
109    pub fn is_undefined(&self) -> bool {
110        matches!(self, Priority::Other(s) if s.is_empty())
111    }
112}
113
114impl FromStr for Priority {
115    type Err = std::convert::Infallible;
116
117    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
118        Ok(match s.to_ascii_lowercase().as_str() {
119            "low" => Priority::Low,
120            "medium" => Priority::Medium,
121            "high" => Priority::High,
122            _ => Priority::Other(s.to_string()),
123        })
124    }
125}
126
127impl fmt::Display for Priority {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133impl Serialize for Priority {
134    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
135        s.serialize_str(self.as_str())
136    }
137}
138
139impl<'de> Deserialize<'de> for Priority {
140    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
141        let raw = String::deserialize(d)?;
142        match raw.parse() {
143            Ok(parsed) => Ok(parsed),
144            Err(_) => Ok(Priority::Other(raw)),
145        }
146    }
147}
148
149/// Issue labels. Free-form strings kept exactly as authored (case and
150/// spaces preserved); default `[]`.
151pub type Labels = Vec<String>;
152
153/// Assignee file-encoding rule (FR-027 / FR-014, `contracts/file-format.md`
154/// rule 5).
155///
156/// - **In memory**: a manual handle is always bare (`alice`, never
157///   `@alice`); a git-derived identity is the full `Name <email>` string,
158///   verbatim.
159/// - **Read** ([`de_assignee`]): a single leading `@` is stripped, so both
160///   the legacy `@alice` bytes and the bare `alice` bytes normalize to the
161///   same in-memory handle. A full identity / identity-like value never
162///   starts with `@` and passes through untouched.
163/// - **Write** ([`se_assignee`]): a value carrying an identity form (both `<`
164///   and `>` present, e.g. `Name <email>`) is persisted verbatim — no forced
165///   `@` and no mangling of the angle brackets. Any other value is a manual
166///   handle and is persisted with a single leading `@`, byte-identical to the
167///   legacy `@alice` files (C-010) and to what `contracts/cli.md` conformance
168///   pins for `--assignee @alice`.
169/// - `None` persists as `null` (omitted by `skip_serializing_if`).
170///
171/// The in-memory value stays display-neutral: the `@` cosmetic a CLI/TUI
172/// renders around handles is their concern, not the model's.
173fn de_assignee<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
174    let opt = Option::<String>::deserialize(d)?;
175    Ok(opt.map(|v| v.strip_prefix('@').unwrap_or(&v).to_string()))
176}
177
178/// Serialize an assignee per the encoding rule documented on [`de_assignee`]:
179/// identity-like values verbatim, manual handles `@`-prefixed (legacy bytes).
180fn se_assignee<S: Serializer>(opt: &Option<String>, s: S) -> std::result::Result<S::Ok, S::Error> {
181    match opt {
182        None => s.serialize_none(),
183        Some(value) => {
184            let is_identity = value.contains('<') && value.contains('>');
185            let full = if is_identity || value.starts_with('@') {
186                value.clone()
187            } else {
188                format!("@{value}")
189            };
190            s.serialize_str(&full)
191        }
192    }
193}
194
195/// Serialization rule for the optional planning lists `cycles`/`milestones`
196/// (`contracts/file-format-planning.md`): the key is omitted when absent or
197/// empty (only non-empty lists serialize), and duplicate entries are
198/// collapsed on write preserving first-seen order.
199fn skip_planning_list(v: &Option<Vec<String>>) -> bool {
200    v.as_deref().is_none_or(|items| items.is_empty())
201}
202
203fn se_planning_list<S: Serializer>(
204    v: &Option<Vec<String>>,
205    s: S,
206) -> std::result::Result<S::Ok, S::Error> {
207    let items: Vec<&String> = match v {
208        Some(items) => {
209            items
210                .iter()
211                .fold((Vec::new(), HashSet::new()), |(mut out, mut seen), item| {
212                    if seen.insert(item.as_str()) {
213                        out.push(item);
214                    }
215                    (out, seen)
216                })
217                .0
218        }
219        None => Vec::new(),
220    };
221    items.serialize(s)
222}
223
224/// The key of a top-level YAML mapping entry, or `None` for a continuation line.
225///
226/// Only column 0 counts. An indented `key:` is part of a nested mapping, and a
227/// `- item` is a sequence entry; treating either as a top-level key would end the
228/// previous block early and capture a fragment.
229fn top_level_key(line: &str) -> Option<String> {
230    if !line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
231        return None;
232    }
233    let colon = line.find(':')?;
234    let key = &line[..colon];
235    if key.is_empty()
236        || !key
237            .chars()
238            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
239    {
240        return None;
241    }
242    Some(key.to_string())
243}
244
245fn frontmatter_has_retired_links_key(frontmatter: &str) -> bool {
246    if let Ok(mapping) = serde_yaml::from_str::<serde_yaml::Mapping>(frontmatter) {
247        let key = serde_yaml::Value::String("links".into());
248        return match mapping.get(&key) {
249            Some(value) => !links_value_is_empty(value),
250            None => false,
251        };
252    }
253    frontmatter.lines().any(|line| {
254        if line.starts_with(' ') || line.starts_with('\t') {
255            return false;
256        }
257        line.split_once(':')
258            .map(|(key, _)| key.trim() == "links")
259            .unwrap_or(false)
260    })
261}
262
263/// Whether a `links:` value carries no entries, in the shapes the old model
264/// wrote and the shapes a hand-edit can produce: `[]`, `~`/`null`, an empty
265/// scalar, and a block sequence with no items (`links:\n` followed by the next
266/// key). Anything else is populated and therefore a finding.
267fn links_value_is_empty(value: &serde_yaml::Value) -> bool {
268    match value {
269        serde_yaml::Value::Null => true,
270        serde_yaml::Value::Sequence(items) => items.is_empty(),
271        serde_yaml::Value::String(s) => s.trim().is_empty() || s.trim() == "[]",
272        serde_yaml::Value::Mapping(m) => m.is_empty(),
273        serde_yaml::Value::Tagged(t) => links_value_is_empty(&t.value),
274        _ => false,
275    }
276}
277
278/// Real day count for a Gregorian month (leap-aware).
279fn days_in_month(year: u32, month: u32) -> u32 {
280    match month {
281        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
282        4 | 6 | 9 | 11 => 30,
283        2 => {
284            let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
285            if leap { 29 } else { 28 }
286        }
287        _ => 0,
288    }
289}
290
291/// Message-bearing [`validate_date`] for surfaces that want to explain what
292/// was wrong. The error names the offending value; callers add field context.
293fn validate_date_msg(s: &str) -> std::result::Result<(), String> {
294    if validate_date(s) {
295        Ok(())
296    } else {
297        Err(format!(
298            "`{s}` is not a `YYYY-MM-DD` calendar date (year >= 1970)"
299        ))
300    }
301}
302
303/// Convert days since the Unix epoch to a `(year, month, day)` civil date
304/// (Howard Hinnant's `civil_from_days`).
305fn civil_from_days(z: i64) -> (i64, u32, u32) {
306    let z = z + 719_468;
307    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
308    let doe = z - era * 146_097; // [0, 146096]
309    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
310    let y = yoe + era * 400;
311    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
312    let mp = (5 * doy + 2) / 153; // [0, 11]
313    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
314    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
315    (if m <= 2 { y + 1 } else { y }, m, d)
316}
317
318/// Parse a `### <author> (<YYYY-MM-DD>)` comment head into its parts.
319///
320/// The date is the trailing `(<YYYY-MM-DD>)` group; the author is everything
321/// before it. A head without a well-formed trailing date still yields the
322/// author (the date defaults to `""`), so malformed heads survive the parse
323/// without crashing (`contracts/comments-format.md` rule "preserve verbatim").
324fn parse_comment_head(after: &str) -> (String, String) {
325    let (author, date) = match after.len() {
326        len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
327            let close = len - 1;
328            let open = after[..close].rfind(" (");
329            match open {
330                Some(open) => {
331                    let maybe_date = &after[open + 2..close];
332                    if maybe_date.len() == 10
333                        && maybe_date.as_bytes()[4] == b'-'
334                        && maybe_date.as_bytes()[7] == b'-'
335                    {
336                        (&after[..open], maybe_date)
337                    } else {
338                        (after, "")
339                    }
340                }
341                None => (after, ""),
342            }
343        }
344        _ => (after, ""),
345    };
346    (author.trim_end().to_string(), date.to_string())
347}
348
349// ---------------------------------------------------------------------------
350// Append-only History section (WP02 T007)
351// ---------------------------------------------------------------------------
352
353/// The separator between the four fields of a rendered History entry
354/// (U+2014 em dash).
355const HISTORY_SEP: char = '\u{2014}';
356
357/// The marker separating the transition record from its override reason.
358///
359/// **Includes the closing paren**, and that is load-bearing rather than
360/// cosmetic. The obvious marker is `"; override: "`, split with `rsplit_once` so a
361/// reason containing the marker survives — and it does not: the *last* occurrence
362/// of a bare `"; override: "` inside a reason like `typo; override: actually…` is
363/// the one that splits, which leaves the head ending in `; override: typo` and no
364/// closing paren, so the parse fails outright. WP03's first version did exactly
365/// that and its own test caught it.
366///
367/// Anchoring the marker to the `)` that closes the head makes the split
368/// **unambiguous in both directions**: the head is the only `)` followed by the
369/// marker, so `split_once` at the *first* occurrence always takes the right one.
370/// A reason may therefore contain the bare marker, a bare `")"`, and even the full
371/// anchored marker, and still round-trip.
372///
373/// That last case is worth spelling out because WP03's first two attempts both got
374/// it wrong in opposite directions. `rsplit_once` on the *bare* marker took the
375/// last occurrence, so a reason containing `"; override: "` truncated and the parse
376/// failed outright. `rsplit_once` on the *anchored* marker would be no better: for
377/// a reason containing `"); override: "` the last occurrence is the one inside the
378/// reason. `split_once` on the anchored marker is the only combination of the two
379/// that is correct, and `t012_a_reason_containing_the_anchored_marker_survives`
380/// exists to keep it that way.
381const OVERRIDE_MARKER: &str = "); override: ";
382/// The marker opening the bracketed category pair.
383const CATEGORY_OPEN: &str = " (";
384/// The arrow between two names in the `detail` grammar.
385const DETAIL_ARROW: &str = " -> ";
386
387/// Whether a line is a **recognised** History entry attempt.
388/// Whether a line is **shaped like a markdown list item**, regardless of whether
389/// it is a valid entry.
390///
391/// Used to decide where an unrecognised line belongs: a list item inside a
392/// History section is far more likely a hand-edited entry than prose, and the two
393/// are not told apart by anything else, because the section has no terminator.
394///
395/// All four common markers count, not just `- `. This is not a widening of the
396/// entry grammar — recognition still requires U+2014, so nothing here can be read
397/// as a relationship. With `- ` alone, an entry written
398/// `* 2026-01-01T00:00:00Z — a — kind — d` was treated as prose, so the section
399/// ended, the heading was deleted, and the next append was rendered above it.
400fn is_list_item(item: &str) -> bool {
401    for marker in ["- ", "* ", "+ "] {
402        if item.starts_with(marker) {
403            return true;
404        }
405    }
406    // An ordered marker: digits then `.` or `)` then a space. Bounded to two
407    // digits, or `2026. Q1 goals` reads as an ordered-list item and a paragraph
408    // of ordinary body prose gets retained in the audit log.
409    let Some(dot) = item.find(['.', ')']) else {
410        return false;
411    };
412    dot > 0
413        && dot <= 2
414        && item[..dot].bytes().all(|b| b.is_ascii_digit())
415        && item[dot + 1..].starts_with(' ')
416}
417
418/// Tail fields required to call a line a mistyped entry when **reporting** it.
419///
420/// Four fields in total, because that is the form carrying a detail; the shorter
421/// three-field form is indistinguishable from a dated changelog line
422/// (`- 2026-01-01 - Bob - Done`), and `tix lint` exits non-zero on a finding.
423const STRICT_TAIL_FIELDS: usize = 3;
424
425/// Tail fields required to call a line an entry **attempt** when deciding whether
426/// to retain it.
427///
428/// Two, i.e. three fields in total, which is the shortest form `HistoryEvent::parse`
429/// accepts — `detail` is optional there. A retention test stricter than the
430/// recognition path is backwards: a valid three-field entry would be demoted to
431/// body text and then destroyed by the next `tix edit --body`, while the
432/// malformed four-field one survived. Cycle 6 found that inversion.
433const RETENTION_TAIL_FIELDS: usize = 2;
434
435fn is_entry_attempt(item: &str) -> bool {
436    let Some(rest) = strip_list_marker(item) else {
437        return false;
438    };
439    if rest.contains(HISTORY_SEP) {
440        // Correct separators. A `*`- or `1.`-marked entry is not *recognised* —
441        // both `looks_like_history_entry` and `HistoryEvent::parse` require the
442        // `- ` prefix — but it is unmistakably an entry attempt, and treating it
443        // as prose cost a well-formed audit line. Cycle 6 found this.
444        return true;
445    }
446    entry_shaped_by_ascii_hyphens(rest, RETENTION_TAIL_FIELDS)
447}
448
449/// Whether `rest` is a plausible entry body whose separators were typed as ASCII
450/// hyphens: a date, then at least `min_tail` further hyphen-separated fields.
451///
452/// The tail splits on hyphens with optional surrounding spaces rather than on the
453/// literal `" - "`, because an entry typed with ASCII hyphens is often typed
454/// without the spaces too: `- 2026-01-01T00:00:00Z-alice-kind-d`. An earlier
455/// version required `" - "` and so missed the case this tool exists to catch,
456/// while the recognition path trims after the marker and accepted the same
457/// whitespace — the identical line was "not an entry" and never mentioned.
458fn entry_shaped_by_ascii_hyphens(rest: &str, min_tail: usize) -> bool {
459    let Some(tail) = strip_leading_timestamp(rest) else {
460        return false;
461    };
462    let parts: Vec<&str> = tail
463        .split('-')
464        .map(str::trim)
465        .filter(|p| !p.is_empty())
466        .collect();
467    if parts.len() < min_tail {
468        return false;
469    }
470    // The field right after the timestamp is an actor or a kind, and neither is a
471    // date. Rejecting a numeric one is what keeps a date *range* out:
472    // `- 2026-01-01..2026-01-05 was the freeze` otherwise splits into three
473    // parts and reads as a mistyped entry.
474    !starts_like_a_date(parts[0])
475}
476
477/// Whether a token opens with a run of four or more digits — a year, a serial
478/// number, a date. Used only to reject prose that would otherwise split into
479/// enough hyphen-separated fields to look like an entry.
480fn starts_like_a_date(token: &str) -> bool {
481    let digits = token.bytes().take_while(u8::is_ascii_digit).count();
482    digits >= 4
483}
484
485/// The text after any accepted list marker, with whatever whitespace a hand put
486/// after it removed.
487///
488/// `HistoryEvent::parse` trims after the prefix too, so this must as well:
489/// otherwise `-  2026-…—…` and `-\t2026-…—…` are recognised as real entries while
490/// their ASCII-hyphen equivalents are silently unreported.
491fn strip_list_marker(line: &str) -> Option<&str> {
492    let item = line.trim();
493    if is_list_item(item) {
494        let space = item.find(char::is_whitespace).unwrap_or(item.len());
495        return Some(item[space..].trim_start());
496    }
497    None
498}
499
500/// The remainder of a line whose first field begins with a plausible ISO date, or
501/// `None` if it does not.
502///
503/// A date is the only field shape that separates a mistyped entry from an
504/// ordinary dated bullet, and requiring it is what keeps `tix lint` quiet on
505/// prose. Plausibility is deliberately not checked: rejecting a hand-typed
506/// offset like `+02:00` would create a false negative in the one tool whose job
507/// is to catch exactly this mistake, so implausible shapes are reported rather
508/// than filtered.
509fn strip_leading_timestamp(rest: &str) -> Option<&str> {
510    let b = rest.as_bytes();
511    if b.len() < 10
512        || !b[0..4].iter().all(u8::is_ascii_digit)
513        || b[4] != b'-'
514        || !b[5..7].iter().all(u8::is_ascii_digit)
515        || b[7] != b'-'
516        || !b[8..10].iter().all(u8::is_ascii_digit)
517    {
518        return None;
519    }
520    let mut i = 10;
521    if b.get(i) == Some(&b'T') {
522        i += 1;
523        while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':' || b[i] == b'.') {
524            i += 1;
525        }
526        match b.get(i) {
527            Some(b'Z') => i += 1,
528            // A `-` is a zone separator only when digits follow it; otherwise it
529            // is the first field separator and the timestamp ended at the time.
530            Some(&b'-') => {
531                let mut j = i + 1;
532                while j < b.len() && b[j].is_ascii_digit() {
533                    j += 1;
534                }
535                if j > i + 1 {
536                    i = j;
537                }
538            }
539            Some(&b'+') => {
540                i += 1;
541                while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':') {
542                    i += 1;
543                }
544            }
545            _ => {}
546        }
547    }
548    // \`.\` too: a date *range* continues with \`..\`, and leaving it glued to
549    // the next token makes \`- 2026-01-01..2026-01-05 was the freeze\` split into
550    // three fields.
551    Some(rest[i.min(rest.len())..].trim_start_matches(['-', ' ', '\t', '.']))
552}
553
554// ---------------------------------------------------------------------------
555// The hierarchy service's view of an `Issue`
556// ---------------------------------------------------------------------------
557
558// ---------------------------------------------------------------------------
559// The project service's view of an `Issue`
560// ---------------------------------------------------------------------------
561
562// ---------------------------------------------------------------------------
563// The epic service's view of an `Issue`
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests;