use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{Result, TixError};
use crate::slug::slugify;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Status(pub String);
impl Status {
pub fn new(name: impl Into<String>) -> Self {
Status(name.into())
}
pub fn is(&self, name: &str) -> bool {
self.0 == name
}
pub fn display(&self) -> &str {
&self.0
}
}
impl Default for Status {
fn default() -> Self {
Status("todo".to_string())
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum Priority {
Low,
#[default]
Medium,
High,
Other(String),
}
impl Priority {
fn as_str(&self) -> &str {
match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
Priority::Other(s) => s,
}
}
}
impl FromStr for Priority {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s.to_ascii_lowercase().as_str() {
"low" => Priority::Low,
"medium" => Priority::Medium,
"high" => Priority::High,
_ => Priority::Other(s.to_string()),
})
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for Priority {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Priority {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let raw = String::deserialize(d)?;
match raw.parse() {
Ok(parsed) => Ok(parsed),
Err(_) => Ok(Priority::Other(raw)),
}
}
}
pub type Labels = Vec<String>;
fn de_assignee<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
let opt = Option::<String>::deserialize(d)?;
Ok(opt.map(|v| v.strip_prefix('@').unwrap_or(&v).to_string()))
}
fn se_assignee<S: Serializer>(opt: &Option<String>, s: S) -> std::result::Result<S::Ok, S::Error> {
match opt {
None => s.serialize_none(),
Some(value) => {
let is_identity = value.contains('<') && value.contains('>');
let full = if is_identity || value.starts_with('@') {
value.clone()
} else {
format!("@{value}")
};
s.serialize_str(&full)
}
}
}
pub fn today() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
fn parse_comment_head(after: &str) -> (String, String) {
let (author, date) = match after.len() {
len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
let close = len - 1;
let open = after[..close].rfind(" (");
match open {
Some(open) => {
let maybe_date = &after[open + 2..close];
if maybe_date.len() == 10
&& maybe_date.as_bytes()[4] == b'-'
&& maybe_date.as_bytes()[7] == b'-'
{
(&after[..open], maybe_date)
} else {
(after, "")
}
}
None => (after, ""),
}
}
_ => (after, ""),
};
(author.trim_end().to_string(), date.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comment {
pub author: String,
pub date: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issue {
pub number: u32,
#[serde(default)]
pub title: String,
#[serde(default)]
pub status: Status,
#[serde(default)]
pub labels: Labels,
#[serde(default)]
pub priority: Priority,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "de_assignee",
serialize_with = "se_assignee"
)]
pub assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitlab_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub linear_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mission: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
#[serde(default = "today")]
pub created: String,
#[serde(default = "today")]
pub updated: String,
#[serde(skip)]
pub filename: String,
#[serde(skip)]
pub body: String,
}
impl Issue {
pub fn parse(contents: &str) -> Result<Self> {
let rest = contents
.strip_prefix("---\n")
.ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
let mut current = rest;
let mut fm_len = 0usize;
loop {
let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
let content = current[..line_end]
.strip_suffix('\n')
.unwrap_or(¤t[..line_end]);
if content == "---" {
break;
}
fm_len += line_end;
current = ¤t[line_end..];
if current.is_empty() {
return Err(TixError::Parse(
"frontmatter is not terminated by a closing `---`".into(),
));
}
}
let frontmatter = &rest[..fm_len];
let body_raw = &rest[fm_len..];
let mut body = body_raw.strip_prefix("---\n").unwrap_or(body_raw);
if let Some(after) = body.strip_prefix('\n') {
body = after;
}
let body = body.to_string();
let mut issue: Issue =
serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
issue.body = body;
Ok(issue)
}
pub fn frontmatter(&self) -> String {
let fm =
serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
fm.trim_end_matches('\n').to_string() + "\n"
}
pub fn to_file_contents(&self) -> String {
format!("---\n{}---\n\n{}", self.frontmatter(), self.body)
}
pub fn filename_stem(&self) -> String {
format!("{}-{}", self.created, slugify(&self.title))
}
pub fn bump_updated(&mut self) {
self.updated = today();
}
fn comments_boundary(body: &str) -> Option<usize> {
let mut in_fence = false;
let mut offset = 0usize;
for raw in body.split_inclusive('\n') {
let line = raw.strip_suffix('\n').unwrap_or(raw);
if line.starts_with("```") || line.starts_with("~~~") {
in_fence = !in_fence;
} else if !in_fence && line == "## Comments" {
return Some(offset);
}
offset += raw.len();
}
None
}
pub fn description(&self) -> &str {
match Self::comments_boundary(&self.body) {
Some(idx) => self.body[..idx].trim_end(),
None => self.body.trim_end(),
}
}
pub fn comments(&self) -> Vec<Comment> {
let Some(boundary) = Self::comments_boundary(&self.body) else {
return Vec::new();
};
let section = &self.body[boundary..];
let mut comments = Vec::new();
let mut offset = 0usize;
for raw in section.split_inclusive('\n') {
let line = raw.strip_suffix('\n').unwrap_or(raw);
if let Some(after) = line.strip_prefix("### ") {
let (author, date) = parse_comment_head(after);
let start = offset + raw.len();
let mut text = String::new();
for next in section[start..].split_inclusive('\n') {
if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
break;
}
text.push_str(next);
}
comments.push(Comment {
author,
date,
text: text.trim_end_matches('\n').to_string(),
});
}
offset += raw.len();
}
comments
}
pub fn add_comment(&mut self, author: &str, text: &str) {
if text.trim().is_empty() {
return;
}
if Self::comments_boundary(&self.body).is_none() {
if !self.body.trim().is_empty() {
if !self.body.ends_with('\n') {
self.body.push('\n');
}
self.body.push('\n');
}
self.body.push_str("## Comments\n");
}
if !self.body.is_empty() && !self.body.ends_with('\n') {
self.body.push('\n');
}
if !self.body.ends_with("\n\n") {
self.body.push('\n');
}
self.body
.push_str(&format!("### {author} ({})\n\n{text}\n", today()));
self.bump_updated();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_helpers() {
let s = Status::new("in progress");
assert!(s.is("in progress"));
assert!(!s.is("in_progress"));
assert_eq!(s.display(), "in progress");
assert_eq!(Status::default(), Status::new("todo"));
}
#[test]
fn status_survives_unknown_column_name() {
let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
assert_eq!(parsed.display(), "renamed-column");
}
#[test]
fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
assert_eq!(Priority::default(), Priority::Medium);
assert_eq!(Priority::Low.to_string(), "low");
}
#[test]
fn unknown_priority_is_preserved_not_normalized() {
let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
assert_eq!(urgent.to_string(), "urgent");
let yaml = serde_yaml::to_string(&urgent).unwrap();
assert!(yaml.contains("urgent"));
}
#[test]
fn issue_roundtrip_preserves_every_field_including_number_and_body() {
let mut issue = Issue {
number: 7,
title: "Add documentation for the feature".into(),
status: Status::new("todo"),
labels: vec!["docs".into(), "onboarding".into()],
priority: Priority::High,
assignee: Some("alice".into()),
estimate: Some("2".into()),
commit: Some("abc123".into()),
gitlab_id: Some("gl-9".into()),
linear_id: Some("LIN-9".into()),
mission: None,
task: None,
created: "2026-05-10".into(),
updated: "2026-05-10".into(),
filename: String::new(),
body: "Describe the issue here.\n\n".into(),
};
let contents = issue.to_file_contents();
let parsed = Issue::parse(&contents).unwrap();
assert_eq!(parsed, issue);
issue.body.clear();
assert_ne!(parsed, issue);
}
#[test]
fn missing_dates_default_to_today() {
let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
let issue: Issue = serde_yaml::from_str(yaml).unwrap();
assert_eq!(issue.created, today());
assert_eq!(issue.updated, today());
}
#[test]
fn body_is_preserved_verbatim() {
let issue = Issue {
number: 1,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-02".into(),
updated: "2026-01-02".into(),
filename: String::new(),
body: "line one\n\nline two\n```\ncode\n```".into(),
};
let contents = issue.to_file_contents();
let parsed = Issue::parse(&contents).unwrap();
assert_eq!(parsed.body, issue.body);
}
#[test]
fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
let issue: Issue = serde_yaml::from_str(yaml).unwrap();
assert_eq!(issue.assignee.as_deref(), Some("alice"));
let rerendered = issue.frontmatter();
assert!(
rerendered.contains("assignee: \"@alice\"")
|| rerendered.contains("assignee: '@alice'")
);
}
#[test]
fn full_identity_assignee_round_trips_verbatim() {
let issue = Issue {
number: 5,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let fm = issue.frontmatter();
assert!(
fm.contains("Herbert Maschke <thyseus@pm.me>"),
"identity must be stored verbatim, got:\n{fm}"
);
assert!(
!fm.contains("@Herbert Maschke"),
"identity must not be @-prefixed, got:\n{fm}"
);
let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
assert_eq!(parsed, issue);
}
#[test]
fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
let issue = Issue {
number: 6,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let fm = issue.frontmatter();
assert!(
fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
"legacy @-prefixed bytes expected, got:\n{fm}"
);
let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
assert_eq!(parsed.assignee.as_deref(), Some("alice"));
}
#[test]
fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
}
#[test]
fn angle_bracket_identity_without_name_written_verbatim() {
let issue = Issue {
number: 8,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("<thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let fm = issue.frontmatter();
assert!(
fm.contains("<thyseus@pm.me>"),
"identity-like value must be verbatim, got:\n{fm}"
);
assert!(
!fm.contains("@<thyseus"),
"identity-like value must not be @-prefixed, got:\n{fm}"
);
}
#[test]
fn all_none_frontmatter_only_contains_required_keys() {
let issue = Issue {
number: 4,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let fm = issue.frontmatter();
assert!(fm.contains("number:"));
assert!(fm.contains("title:"));
assert!(fm.contains("status:"));
assert!(fm.contains("labels:"));
assert!(fm.contains("priority:"));
assert!(fm.contains("created:"));
assert!(fm.contains("updated:"));
for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
}
}
#[test]
fn filename_stem_matches_contract_format() {
let issue = Issue {
number: 1,
title: "Fix !Login crash?".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-05-10".into(),
updated: "2026-05-10".into(),
filename: String::new(),
body: String::new(),
};
assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
}
#[test]
fn bump_updated_stamps_today() {
let mut issue = Issue {
number: 1,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2020-01-01".into(),
updated: "2020-01-01".into(),
filename: String::new(),
body: String::new(),
};
issue.bump_updated();
assert_eq!(issue.updated, today());
assert_ne!(issue.updated, "2020-01-01");
}
#[test]
fn parse_rejects_malformed_frontmatter() {
assert!(Issue::parse("no delimiter\n").is_err());
assert!(Issue::parse("---\na: 1\n").is_err()); assert!(Issue::parse("---\nbroken: [").is_err()); }
#[test]
fn today_is_an_iso_calendar_date() {
let t = today();
assert_eq!(t.len(), 10);
let parts: Vec<&str> = t.split('-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0].len(), 4);
assert_eq!(parts[1].len(), 2);
assert_eq!(parts[2].len(), 2);
}
#[test]
fn civil_from_days_epoch_is_1970_01_01() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
fn issue(body: &str) -> Issue {
Issue {
number: 1,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: body.to_string(),
}
}
#[test]
fn comments_empty_when_no_section() {
let i = issue("Some description text.\n");
assert_eq!(i.description(), "Some description text.");
assert!(i.comments().is_empty());
}
#[test]
fn comments_parsed_from_section() {
let i = issue(
"Hello\n\n## Comments\n\n\
### @alice (2026-09-13)\n\n\
First comment.\n\n\
### bob (2026-09-14)\n\n\
Second comment.\n",
);
assert_eq!(i.description(), "Hello");
let cs = i.comments();
assert_eq!(cs.len(), 2);
assert_eq!(cs[0].author, "@alice");
assert_eq!(cs[0].date, "2026-09-13");
assert_eq!(cs[1].author, "bob");
assert_eq!(cs[1].date, "2026-09-14");
}
#[test]
fn code_fence_boundary_not_treated_as_boundary() {
let body = "Description\n\n\
```\n\
## Comments\n\
```\n\n\
## Comments\n\n\
### eve (2026-10-01)\n\n\
real comment\n";
let i = issue(body);
let cs = i.comments();
assert_eq!(cs.len(), 1);
assert_eq!(cs[0].author, "eve");
assert_eq!(cs[0].date, "2026-10-01");
assert_eq!(cs[0].text, "\nreal comment");
assert!(
i.description().contains("## Comments"),
"code-blocked heading must be in description"
);
}
#[test]
fn blank_lines_preserved_in_comment_text() {
let i = issue(
"## Comments\n\n\
### @alice (2026-09-13)\n\n\
line one\n\nline two\n\n\n\
line three\n",
);
let cs = i.comments();
assert_eq!(cs.len(), 1);
let text = &cs[0].text;
assert!(
text.contains("\nline two"),
"internal blank lines must be preserved: got {text:?}"
);
assert!(
text.contains("line three"),
"must contain all lines: got {text:?}"
);
}
#[test]
fn comments_empty_when_section_has_no_heads() {
let i = issue("## Comments\n\n\n");
assert!(i.comments().is_empty());
}
#[test]
fn add_first_comment_to_empty_body() {
let mut i = issue("");
i.add_comment("@alice", "Hello world");
assert_eq!(i.description(), "");
let cs = i.comments();
assert_eq!(cs.len(), 1);
assert_eq!(cs[0].author, "@alice");
assert_eq!(cs[0].text, "\nHello world");
assert!(i.body.contains("## Comments"));
}
#[test]
fn second_comment_appended_after_first() {
let mut i = issue("");
i.add_comment("@alice", "first");
i.add_comment("bob", "second");
let cs = i.comments();
assert_eq!(cs.len(), 2);
let pos_a = i.body.find("### @alice");
let pos_b = i.body.find("### bob");
assert!(pos_a < pos_b, "first comment must precede second");
}
#[test]
fn whitespace_only_comment_discarded() {
let mut i = issue("Hello\n");
let before = i.body.clone();
i.add_comment("@alice", " \n ");
assert_eq!(i.body, before, "whitespace-only text must not change body");
}
#[test]
fn description_unchanged_after_add_comment() {
let mut i = issue("Description text.\n");
i.add_comment("@alice", "a comment");
assert_eq!(i.description(), "Description text.");
}
#[test]
fn literal_comments_heading_in_code_block_not_touched() {
let mut i = issue("```\n## Comments\n```\n");
i.add_comment("@alice", "new");
assert!(
i.body.contains("```\n## Comments\n```"),
"code-block heading must be preserved: {0:?}",
i.body
);
assert!(i.body.contains("### @alice"), "must contain new comment");
}
#[test]
fn roundtrip_preserves_comment_data() {
let mut i = issue(
"## Comments\n\n\
### @alice (2026-09-13)\n\n\
Original comment.\n",
);
i.add_comment("bob", "Second comment");
let file = i.to_file_contents();
let parsed = Issue::parse(&file).unwrap();
assert_eq!(parsed.description(), i.description());
let cs = parsed.comments();
assert_eq!(cs.len(), 2);
assert_eq!(cs[0].author, "@alice");
assert_eq!(cs[1].author, "bob");
assert_eq!(cs[1].text, "\nSecond comment");
}
#[test]
fn three_comments_in_file_order() {
let mut i = issue("");
i.add_comment("a", "one");
i.add_comment("b", "two");
i.add_comment("c", "three");
let cs = i.comments();
assert_eq!(
cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
["a", "b", "c"]
);
assert_eq!(
i.body,
format!(
"## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
today()
)
);
}
#[test]
fn preserves_hand_authored_comment_markdown() {
let body = "## Comments\n\n\
### @alice (2026-09-13)\n\n\
**bold** and _italic_\n\n\
- item 1\n- item 2\n\n";
let i = issue(body);
let cs = i.comments();
assert_eq!(cs.len(), 1);
assert!(cs[0].text.contains("**bold**"));
assert!(cs[0].text.contains("- item 1"));
}
#[test]
fn description_code_block_heading_not_boundary() {
let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
let before = i.body.clone();
i.add_comment("eve", "real");
assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
let cs = i.comments();
assert_eq!(cs.len(), 1);
assert_eq!(cs[0].author, "eve");
assert!(
i.body.starts_with(&before),
"code-block heading must be preserved"
);
}
}