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;