#[allow(clippy::wildcard_imports)]
use super::*;
use crate::relationships::RelationKind;
use crate::time_tracking::{TimeEntry, TimeEntryExtensions};
#[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() {
let undefined: Priority = "".parse().unwrap();
assert!(undefined.is_undefined());
assert_eq!(undefined.to_string(), "", "and it writes back as ''");
assert!(
!Priority::default().is_undefined(),
"the default is medium, not unset"
);
assert!(
!Priority::Other("urgent".into()).is_undefined(),
"only the empty string is unset"
);
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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-05-10".into(),
type_: None,
updated: "2026-05-10".into(),
filename: String::new(),
body: "Describe the issue here.\n\n".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 typed_relationships_round_trip_preserves_entries_verbatim() {
let mut issue = Issue {
projects: None,
id: None,
number: 9,
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,
relationships: vec![
Relationship::new(9, 12, RelationKind::Blocks, "Twelve"),
Relationship::new(9, 47, RelationKind::Related, "Forty seven"),
],
related_issues: vec![],
history: History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec!["docs/x.md".into(), "specs/y/spec.md".into()],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
let contents = issue.to_file_contents();
let parsed = Issue::parse(&contents).unwrap();
assert_eq!(parsed.relationships.len(), 2);
assert_eq!(parsed.relationships[0].kind, RelationKind::Blocks);
assert_eq!(parsed.relationships[0].display, "blocks #12 Twelve");
assert_eq!(parsed.relationships[0].id, issue.relationships[0].id);
assert_eq!(parsed.files, ["docs/x.md", "specs/y/spec.md"]);
assert_eq!(parsed.to_file_contents(), contents);
assert!(
parsed
.relationships
.iter()
.all(|r| r.kind != RelationKind::BlockedBy)
);
issue.relationships.clear();
}
#[test]
fn related_issues_round_trip_verbatim_and_omit_when_empty() {
let original = "---\nnumber: 3\ntitle: T\nstatus: todo\nlabels: []\n\
priority: medium\nfiles: []\nrelated_issues:\n- '42'\n\
- '../other/2026-01-01-x.md'\n- 'https://gitlab.com/foo/bar#7'\n\
created: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
let issue = Issue::parse(original).unwrap();
assert_eq!(
issue.related_issues,
vec![
"42".to_string(),
"../other/2026-01-01-x.md".to_string(),
"https://gitlab.com/foo/bar#7".to_string(),
]
);
assert!(
capture_extra_frontmatter(&issue.frontmatter()).is_empty(),
"a modelled key must not also be captured as unknown"
);
let rendered = issue.to_file_contents();
assert_eq!(
rendered.matches("\nrelated_issues:").count(),
1,
"written exactly once:\n{rendered}"
);
assert_eq!(Issue::parse(&rendered).unwrap(), issue);
let mut empty = issue.clone();
empty.related_issues.clear();
assert!(!empty.frontmatter().contains("related_issues"));
}
#[test]
fn type_round_trips_verbatim_and_omits_when_absent() {
let original = "---\nnumber: 3\ntitle: T\nstatus: todo\nlabels: []\n\
type: bug\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
let issue = Issue::parse(original).unwrap();
assert_eq!(issue.type_.as_deref(), Some("bug"));
assert!(
capture_extra_frontmatter(&issue.frontmatter()).is_empty(),
"a modelled key must not also be captured as unknown"
);
let rendered = issue.to_file_contents();
assert_eq!(rendered.matches("\ntype:").count(), 1, "{rendered}");
assert_eq!(Issue::parse(&rendered).unwrap(), issue);
let mut none = issue.clone();
none.type_ = None;
assert!(!none.frontmatter().contains("type:"));
}
#[test]
fn empty_relationships_is_omitted_while_files_keeps_its_inline_empty_list() {
let issue = Issue {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
history: History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
let fm = issue.frontmatter();
assert!(!fm.contains("relationships"), "got:\n{fm}");
assert!(fm.contains("files: []"), "got:\n{fm}");
}
#[test]
fn an_unknown_relation_kind_in_frontmatter_is_rejected() {
let yaml = "\
number: 1
title: T
status: todo
relationships:
- id: rel_1
from: 1
to: 2
kind: depends_on
created: '2026-01-01'
updated: '2026-01-01'
";
let err = serde_yaml::from_str::<Issue>(yaml).unwrap_err();
assert!(
err.to_string().contains("depends_on"),
"the unknown kind is named: {err}"
);
}
#[test]
fn a_legacy_links_key_parses_but_sets_the_retired_flag() {
let yaml = "\
number: 1
title: T
status: todo
links:
- '12'
- 47
created: '2026-01-01'
updated: '2026-01-01'
";
let issue = Issue::parse(&format!("---\n{yaml}---\n\nbody\n")).unwrap();
assert!(issue.has_retired_links_key);
assert!(
issue.relationships.is_empty(),
"C-001: legacy values are never reinterpreted as typed edges"
);
}
#[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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-02".into(),
type_: None,
updated: "2026-01-02".into(),
filename: String::new(),
body: "line one\n\nline two\n```\ncode\n```".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 mut issue = Issue {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-05-10".into(),
type_: None,
updated: "2026-05-10".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
issue.id = Some("A1B2C3D4".into());
assert_eq!(issue.filename_stem(), "2026-05-10-A1B2C3D4-fix-login-crash");
}
#[test]
fn bump_updated_stamps_today() {
let mut issue = Issue {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2020-01-01".into(),
type_: None,
updated: "2020-01-01".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
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 a_crlf_issue_file_parses() {
let crlf = "---\r\nnumber: 7\r\ntitle: T\r\nstatus: todo\r\ncreated: 2026-01-01\r\nupdated: 2026-01-01\r\n---\r\n\r\n## History\r\n\r\n- 2026-01-01T00:00:00Z - alice - relationship_added\r\n";
let issue = Issue::parse(crlf).unwrap();
assert_eq!(issue.number, 7);
assert_eq!(issue.title, "T");
assert_eq!(issue.history.len(), 1, "the CRLF History section is parsed");
assert!(issue.body.trim().is_empty(), "body: {:?}", issue.body);
}
#[test]
fn an_lf_issue_is_unaffected_by_the_crlf_tolerance() {
let lf = "---\nnumber: 7\ntitle: T\nstatus: todo\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
let issue = Issue::parse(lf).unwrap();
assert_eq!(issue.number, 7);
assert_eq!(issue.body.trim(), "body");
}
#[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 {
projects: None,
id: None,
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,
relationships: vec![],
related_issues: vec![],
history: History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: body.to_string(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
}
}
#[test]
fn comments_empty_when_no_section() {
let i = issue("Some description text.\n");
assert_eq!(i.description(), "Some description text.");
assert_eq!(i.comments(), [] as [crate::model::Comment; 0]);
}
#[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_eq!(i.comments(), [] as [crate::model::Comment; 0]);
}
#[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"
);
}
#[test]
fn planning_fields_round_trip() {
let issue = Issue {
projects: None,
id: None,
number: 12,
title: "Ship cycle one".into(),
status: Status::new("in progress"),
labels: vec!["release".into()],
priority: Priority::High,
assignee: Some("alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec!["specs/p/spec.md".into()],
cycles: Some(vec!["Cycle 1".into(), "Sprint hardening".into()]),
milestones: Some(vec!["v1.0".into()]),
reviewer: Some("Bob <bob@example.com>".into()),
start_date: Some("2026-09-07".into()),
due_date: Some("2026-09-18".into()),
created: "2026-09-07".into(),
type_: None,
updated: "2026-09-07".into(),
filename: String::new(),
body: "body".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
let contents = issue.to_file_contents();
let parsed = Issue::parse(&contents).unwrap();
assert_eq!(parsed, issue);
assert_eq!(
parsed.cycles.clone().unwrap(),
["Cycle 1", "Sprint hardening"]
);
assert_eq!(parsed.milestones.clone().unwrap(), ["v1.0"]);
assert_eq!(parsed.reviewer.as_deref(), Some("Bob <bob@example.com>"));
assert_eq!(parsed.start_date.as_deref(), Some("2026-09-07"));
assert_eq!(parsed.due_date.as_deref(), Some("2026-09-18"));
}
#[test]
fn planning_fields_absent_on_legacy_serialize_nothing() {
let issue = Issue {
projects: None,
id: None,
number: 3,
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,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: "body".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
let fm = issue.frontmatter();
for needle in ["cycles", "milestones", "reviewer", "start_date", "due_date"] {
assert!(!fm.contains(needle), "{needle} must be omitted, got:\n{fm}");
}
let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
assert!(parsed.cycles.is_none());
assert!(parsed.milestones.is_none());
assert!(parsed.reviewer.is_none());
assert!(parsed.start_date.is_none());
assert!(parsed.due_date.is_none());
}
#[test]
fn empty_planning_lists_serialize_as_omitted() {
let issue = Issue {
projects: None,
id: None,
number: 3,
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,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: Some(vec![]),
milestones: Some(vec![]),
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: "body".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
let fm = issue.frontmatter();
assert!(!fm.contains("cycles"), "{fm}");
assert!(!fm.contains("milestones"), "{fm}");
}
#[test]
fn duplicate_planning_list_entries_collapse_on_write() {
let issue = Issue {
projects: None,
id: None,
number: 3,
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,
relationships: vec![],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
files: vec![],
cycles: Some(vec!["A".into(), "A".into(), "B".into()]),
milestones: Some(vec!["m".into(), "m".into()]),
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: String::new(),
body: "body".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
parent: None,
series: None,
health: None,
archived: false,
entered_at: None,
source: None,
};
let fm = issue.frontmatter();
let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
assert_eq!(parsed.cycles.clone().unwrap(), ["A", "B"]);
assert_eq!(parsed.milestones.clone().unwrap(), ["m"]);
assert_eq!(fm.matches("- A").count(), 1, "{fm}");
assert_eq!(fm.matches("- m").count(), 1, "{fm}");
}
#[test]
fn legacy_fixture_round_trips_with_only_the_retired_key_removed() {
for name in ["legacy-01.md", "legacy-02.md"] {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
let original = std::fs::read_to_string(&path).unwrap();
let issue = Issue::parse(&original).unwrap();
assert!(
!issue.has_retired_links_key,
"{name} carries an *empty* retired key, so there is nothing to report"
);
assert!(
issue.relationships.is_empty(),
"C-001: the legacy value is never read as typed edges"
);
let kept: Vec<&str> = original
.lines()
.filter(|line| !line.starts_with("links:"))
.collect();
let expected = format!("{}\n", kept.join("\n"));
let round = issue.to_file_contents();
assert_eq!(
round, expected,
"{name} must differ from the legacy file by exactly the retired `links:` line"
);
assert!(
!round.contains("links:"),
"the retired key is never re-emitted"
);
assert_eq!(
Issue::parse(&round).unwrap().to_file_contents(),
round,
"{name} must be byte-stable once in the new format"
);
assert!(issue.cycles.is_none() && issue.milestones.is_none());
assert!(issue.reviewer.is_none());
assert!(issue.start_date.is_none() && issue.due_date.is_none());
}
}
#[test]
fn a_file_in_the_current_format_round_trips_byte_identically() {
let original = concat!(
"---\n",
"number: 7\n",
"title: Current\n",
"status: todo\n",
"labels: []\n",
"priority: medium\n",
"created: '2026-01-01'\n",
"updated: '2026-01-01'\n",
"---\n",
"\n",
"## History\n",
"\n",
"- 2026-01-02T03:04:05Z \u{2014} alice \u{2014} relationship_added \u{2014} created blocks: blocks #9 Nine\n",
"\n",
"Prose that follows the section.\n",
);
let issue = Issue::parse(original).unwrap();
assert!(!issue.has_retired_links_key);
assert_eq!(issue.history.len(), 1, "the History section is parsed");
assert_eq!(issue.history.events()[0].actor, "alice");
assert_eq!(
issue.history.events()[0].kind,
HistoryEventKind::RelationshipAdded
);
assert!(
issue.body.contains("Prose that follows"),
"prose after the section is preserved in the body: {:?}",
issue.body
);
let rendered = issue.to_file_contents();
assert!(rendered.contains("## History"), "{rendered}");
assert!(
rendered.contains(" \u{2014} alice \u{2014} relationship_added \u{2014} "),
"the rendered entry uses the documented spaced form: {rendered}"
);
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered,
"a file in the current format round-trips byte-identically"
);
}
#[test]
fn validate_date_accepts_real_calendar_dates() {
assert!(validate_date("2026-09-18"));
assert!(validate_date("1970-01-01"));
assert!(validate_date("2024-02-29")); assert!(!validate_date("2023-02-29")); assert!(!validate_date("2026-02-30"));
assert!(!validate_date("2026-13-01"));
assert!(!validate_date("2026-00-01"));
assert!(!validate_date("2026-01-00"));
assert!(!validate_date("2026-1-01"));
assert!(!validate_date("2026-01-1"));
assert!(!validate_date("1969-12-31")); assert!(!validate_date("2026/01/01"));
assert!(!validate_date("today"));
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2026, 4), 30);
assert_eq!(days_in_month(2026, 1), 31);
}
#[test]
fn lexicographic_order_equals_chronological_order() {
assert!(date_lte("2026-01-05", "2026-01-12"));
assert!(date_lte("2026-01-05", "2026-01-05"));
assert!(!date_lte("2026-01-12", "2026-01-05"));
assert!(date_lte("2025-12-31", "2026-01-01"));
}
#[test]
fn validate_issue_dates_enforces_format_and_ordering() {
assert!(validate_issue_dates(None, None).is_ok());
assert!(validate_issue_dates(Some("2026-09-07"), None).is_ok());
assert!(validate_issue_dates(None, Some("2026-09-18")).is_ok());
assert!(validate_issue_dates(Some("2026-09-07"), Some("2026-09-18")).is_ok());
assert!(validate_issue_dates(Some("2026-09-18"), Some("2026-09-18")).is_ok());
let e = validate_issue_dates(Some("2026-09-18"), Some("2026-09-07")).unwrap_err();
assert!(e.to_string().contains("due_date"), "naming both: {e}");
assert!(e.to_string().contains("start_date"), "naming both: {e}");
assert!(validate_issue_dates(Some("2026-13-01"), None).is_err());
assert!(validate_issue_dates(None, Some("2026-02-30")).is_err());
}
#[test]
fn malformed_planning_fields_load_without_panic() {
let invalid = "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nstart_date: not-a-date\ndue_date: \"2026-02-30\"\nreviewer: nobody\ncycles:\n- \"\"\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n";
let issue = Issue::parse(invalid).unwrap();
assert_eq!(issue.start_date.as_deref(), Some("not-a-date"));
assert_eq!(issue.due_date.as_deref(), Some("2026-02-30"));
assert_eq!(issue.reviewer.as_deref(), Some("nobody"));
assert_eq!(issue.cycles.clone().unwrap(), [""]);
}
#[test]
fn set_dates_validates_before_mutating() {
let mut issue = issue("body");
issue
.set_dates(Some("2026-09-07".into()), Some("2026-09-18".into()))
.unwrap();
assert_eq!(issue.start_date.as_deref(), Some("2026-09-07"));
assert_eq!(issue.due_date.as_deref(), Some("2026-09-18"));
let before = issue.clone();
let err = issue.set_dates(Some("2026-09-18".into()), Some("2026-09-07".into()));
assert!(err.is_err());
assert_eq!(issue, before, "failed set_dates must not partially mutate");
}
fn frontmatter_with(extra: &str) -> String {
format!("---\nnumber: 1\ntitle: T\nstatus: todo\n{extra}---\n\nBody.\n")
}
#[test]
fn retired_key_detection_matches_only_the_exact_key_name() {
for (extra, expect) in [
("links:\n- '12'\n", true),
("related_links: []\n", false),
("links_count: 3\n", false),
("# links: []\n", false),
("\"links\":\n- '12'\n", true),
("links: []\n", false),
("links: ~\n", false),
("priority: medium\n", false),
("meta:\n links: []\n", false),
("notes: |\n links: see the docs\n", false),
] {
let issue = Issue::parse(&frontmatter_with(extra)).unwrap();
assert_eq!(
issue.has_retired_links_key, expect,
"frontmatter line {extra:?}"
);
}
}
#[test]
fn retired_key_detection_agrees_with_itself_on_the_fallback_path() {
for (extra, expect) in [
("meta:\n links: []\n", false),
("meta:\n links: []\nzzz: 1\nzzz: 2\n", false),
("links: []\nzzz: 1\nzzz: 2\n", true),
] {
let issue = Issue::parse(&frontmatter_with(extra)).unwrap();
assert_eq!(
issue.has_retired_links_key, expect,
"frontmatter {extra:?} must be judged the same way on both paths"
);
}
}
#[test]
fn a_history_heading_inside_prose_stays_prose() {
let raw = "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\nProse first.\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d\n";
let issue = Issue::parse(raw).unwrap();
assert!(
issue.history.is_empty(),
"a mid-body heading is not History"
);
assert!(
issue.body.contains("## History"),
"and it stays in the body: {:?}",
issue.body
);
let rendered = issue.to_file_contents();
assert!(rendered.contains("## History"));
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered
);
}
#[test]
fn the_heading_rule_ignores_trailing_whitespace_and_nothing_else() {
let event = "2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d";
let with = |heading: &str| {
format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n{heading}\n\n- {event}\n")
};
for (heading, recognised, why) in [
("## History", true, "the exact heading"),
(
"## History\t",
true,
"a trailing tab is trailing whitespace",
),
("## History ", true, "so are trailing spaces"),
(
" ## History",
false,
"leading whitespace is explicitly not ignored",
),
(
"## History extra words",
false,
"nothing else may introduce the section",
),
] {
let issue = Issue::parse(&with(heading)).unwrap();
assert_eq!(issue.history.is_empty(), !recognised, "{heading:?} — {why}");
if recognised {
assert_eq!(issue.history.len(), 1, "{heading:?} holds one event");
} else {
assert!(
issue.body.contains("History"),
"{heading:?} stays body prose: {:?}",
issue.body
);
}
}
}
#[test]
fn a_non_section_history_heading_is_undiagnosed() {
for heading in [" ## History", "## History extra words"] {
let raw = format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n{heading}\n\nBody.\n");
let issue = Issue::parse(&raw).unwrap();
assert!(issue.history.is_empty(), "{heading:?} is not a section");
assert!(
!is_history_heading(&format!(
"{heading}
"
)),
"{heading:?} is not the History heading at all"
);
}
}
#[test]
fn a_front_position_section_survives_when_nothing_is_retained() {
let raw = "---\nnumber: 9\ntitle: T\nstatus: todo\n---\n\n## History\n\n- step one\n\nNow the real body text, with no entry shape at all.\n\n- 2026-01-01T00:00:00Z \u{2014} Bob \u{2014} transition_accepted \u{2014} todo -> done\n";
let issue = Issue::parse(raw).unwrap();
assert!(
issue.history.is_empty(),
"no entry was recognised: {:?}",
issue.history
);
for line in [
"## History",
"- step one",
"Now the real body text, with no entry shape at all.",
"- 2026-01-01T00:00:00Z \u{2014} Bob \u{2014} transition_accepted \u{2014} todo -> done",
] {
assert!(
issue.body.contains(line),
"the body must keep {line:?}: {:?}",
issue.body
);
}
let rendered = issue.to_file_contents();
assert!(
rendered.contains("## History"),
"the heading survives rendering:\n{rendered}"
);
let reparsed = Issue::parse(&rendered).unwrap();
assert!(reparsed.history.is_empty());
assert_eq!(reparsed.body, issue.body, "body survives the round trip");
}
#[test]
fn a_blank_actor_round_trips_because_its_position_is_fixed() {
let event = HistoryEvent::new(
"2026-01-01T00:00:00Z",
"",
HistoryEventKind::RelationshipAdded,
"d",
);
let line = event.render();
assert!(
line.contains("\u{2014} \u{2014}"),
"render writes a blank, fixed-position actor field: {line}"
);
assert_eq!(
HistoryEvent::parse(&line).expect("the writer's own output must parse"),
event
);
}
#[test]
fn a_blank_line_inside_the_section_does_not_end_it() {
let raw = concat!(
"---\n",
"number: 1\n",
"title: T\n",
"status: todo\n",
"---\n",
"\n",
"## History\n",
"\n",
"- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} first\n",
"\n",
"- 2026-01-01T00:00:01Z \u{2014} a \u{2014} relationship_added \u{2014} second\n",
"\n",
"Tail prose.\n",
);
let issue = Issue::parse(raw).unwrap();
assert_eq!(
issue.history.events().len(),
2,
"both entries are History events, not one event plus prose"
);
assert_eq!(issue.history.unparsed().len(), 0);
assert_eq!(
issue.body, "Tail prose.\n",
"the prose after the section is still the body"
);
let mut issue = issue;
issue
.history
.append_now("a", HistoryEventKind::RelationshipRemoved, "third");
let rendered = issue.to_file_contents();
let first = rendered.find("first").unwrap();
let second = rendered.find("second").unwrap();
let third = rendered.find("third").unwrap();
assert!(
first < second && second < third,
"file order is append order"
);
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered
);
}
#[test]
fn m3_body_bullets_after_the_section_stay_body() {
let raw = concat!(
"---\n",
"number: 1\n",
"title: T\n",
"status: todo\n",
"---\n",
"\n",
"## History\n",
"\n",
"- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d1\n",
"\n",
"- Reproduction: run it\n",
"- Expected: it works\n",
);
let issue = Issue::parse(raw).unwrap();
assert_eq!(issue.history.events().len(), 1, "only the real event");
assert!(
issue.history.unparsed().is_empty(),
"prose bullets are not mistaken for entries: {:?}",
issue.history.unparsed()
);
assert_eq!(
issue.body, "- Reproduction: run it\n- Expected: it works\n",
"the bullets stay body prose"
);
let rendered = issue.to_file_contents();
assert!(rendered.contains("- Reproduction: run it"));
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered
);
}
#[test]
fn m3_a_stray_line_inside_the_section_keeps_the_events_after_it() {
let raw = concat!(
"---\n",
"number: 1\n",
"title: T\n",
"status: todo\n",
"---\n",
"\n",
"## History\n",
"\n",
"- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d1\n",
" garbage hand edit \n",
"- 2026-01-01T00:00:01Z \u{2014} a \u{2014} relationship_added \u{2014} d2\n",
);
let mut issue = Issue::parse(raw).unwrap();
assert_eq!(
issue.history.events().len(),
2,
"the event after the stray line is still an event"
);
let unparsed = issue.history.unparsed();
assert_eq!(unparsed.len(), 1, "and the stray line is kept, not dropped");
assert_eq!(
unparsed[0], " garbage hand edit ",
"preserved byte-for-byte, including indentation and trailing space"
);
let rendered = issue.to_file_contents();
assert!(
rendered.contains(" garbage hand edit \n"),
"re-emitted exactly: {:?}",
rendered
);
assert!(
!rendered.contains("garbage hand edit \n\n"),
"and no blank line is injected after it: {:?}",
rendered
);
issue
.history
.append_now("bob", HistoryEventKind::RelationshipAdded, "d3");
let rendered = issue.to_file_contents();
let d1 = rendered.find("d1").unwrap();
let d2 = rendered.find("d2").unwrap();
let d3 = rendered.find("d3").unwrap();
assert!(d1 < d2 && d2 < d3, "file order is still append order");
}
#[test]
fn m3_a_bullet_that_looks_like_an_entry_is_recognised_for_diagnosis() {
assert!(looks_like_history_entry(
"- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d"
));
assert!(
!looks_like_history_entry("- 2026-01-01T00:00:00Z - a - relationship_added - d"),
"an ASCII-hyphen line is not recognised, so it stays in the body"
);
assert!(
!looks_like_history_entry("- Reproduction: run it"),
"ordinary prose is not an entry"
);
assert!(!looks_like_history_entry("just a paragraph"));
assert!(!looks_like_history_entry("-no space after the dash"));
assert!(looks_like_an_unrecognised_entry(
"- 2026-01-01T00:00:00Z - a - relationship_added - d"
));
assert!(!looks_like_an_unrecognised_entry(
"- 2026-01-01T00:00:00Z \u{2014} a \u{2014} relationship_added \u{2014} d"
));
assert!(
!looks_like_an_unrecognised_entry("- Reproduction: run it"),
"and ordinary prose is still not accused"
);
}
#[test]
fn f5_every_list_marker_is_recognised_and_reported() {
for marker in ["* ", "+ ", "1. ", "2) "] {
let line = format!(
"{marker}2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d"
);
assert!(
!looks_like_history_entry(&line),
"{marker:?} is not the canonical form, so it is not an event"
);
let raw = format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{line}\n");
let issue = Issue::parse(&raw).unwrap();
assert!(
issue.history.events().is_empty(),
"{marker:?}: no event is fabricated"
);
assert_eq!(
issue.history.unparsed(),
vec![line.clone()],
"{marker:?}: retained byte-for-byte, marker intact"
);
assert_eq!(issue.body, "", "{marker:?}: and not demoted to the body");
}
let canonical = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d";
assert!(looks_like_history_entry(canonical));
let canonical_raw =
format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{canonical}\n");
assert_eq!(
Issue::parse(&canonical_raw).unwrap().history.events().len(),
1,
"the canonical form is an event"
);
for marker in ["- ", "* ", "+ ", "1. "] {
let line = format!("{marker}hand edited note");
let raw = format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{line}\n");
let issue = Issue::parse(&raw).unwrap();
assert!(
issue.history.events().is_empty(),
"{marker:?}: no event is fabricated"
);
assert_eq!(
issue.history.unparsed(),
vec![line.clone()],
"{marker:?}: retained verbatim in the section"
);
assert!(
issue.body.is_empty(),
"{marker:?}: and not demoted into the body, got {:?}",
issue.body
);
}
for marker in ["- ", "* ", "+ ", "1. "] {
let line = format!("{marker}2026-01-01T00:00:00Z - alice - relationship_added - d");
assert!(
looks_like_an_unrecognised_entry(&line),
"{marker:?}: reported as a mistyped entry"
);
assert!(
!looks_like_an_unrecognised_entry(&format!("2026. {line}")),
"a bare sentence is never an entry"
);
}
for prose in [
"just a closing paragraph",
"Reproduction: run it",
"see the thread for context",
] {
let raw = format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n");
let issue = Issue::parse(&raw).unwrap();
assert!(issue.history.is_empty(), "{prose:?}: no section content");
assert!(
issue.body.contains(prose),
"{prose:?}: it is body prose: {:?}",
issue.body
);
}
}
#[test]
fn an_unparseable_entry_never_fails_the_parse() {
let bad_kind = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d";
let raw = format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{bad_kind}\n");
let issue = Issue::parse(&raw).expect("one bad line must not fail the file");
assert!(issue.history.events().is_empty(), "no event was fabricated");
assert_eq!(issue.history.unparsed(), vec![bad_kind], "kept verbatim");
assert_eq!(issue.body, "", "nothing leaked into the body");
let ascii = "- 2026-01-01T00:00:00Z - alice - relationship_added - ascii";
let raw2 = format!("---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{ascii}\n");
let issue2 = Issue::parse(&raw2).expect("and it must not fail the file either");
assert!(issue2.history.events().is_empty(), "no event is fabricated");
assert_eq!(issue2.history.unparsed(), vec![ascii], "kept verbatim");
assert_eq!(issue2.body, "", "and not demoted into the body");
let rendered = issue.to_file_contents();
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered,
"and the file round-trips unchanged"
);
}
#[test]
fn a_history_entry_keeps_an_em_dash_inside_its_detail() {
let raw = "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} note \u{2014} dash inside\n\nTail.\n";
let issue = Issue::parse(raw).unwrap();
assert_eq!(issue.history.len(), 1);
let event = &issue.history.events()[0];
assert_eq!(event.timestamp, "2026-01-01T00:00:00Z");
assert_eq!(event.actor, "alice");
assert_eq!(event.kind, HistoryEventKind::RelationshipAdded);
assert_eq!(event.detail, "note \u{2014} dash inside");
let rendered = issue.to_file_contents();
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered,
"the entry round-trips byte-identically"
);
assert_eq!(
Issue::parse(&rendered).unwrap().history.events()[0].detail,
"note \u{2014} dash inside",
"and the detail is stable across the round trip"
);
}
fn wp02_file(fm: &str) -> String {
format!("---\n{fm}---\n\nBody text.")
}
const WP02_LEGACY_FM: &str = "number: 3\ntitle: Legacy issue\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n";
const WP02_PARENT_FM: &str = "number: 3\ntitle: Child\nstatus: todo\nlabels: []\npriority: medium\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n";
#[test]
fn t006_a_file_with_a_parent_round_trips_byte_stably() {
let original = wp02_file(WP02_PARENT_FM);
let issue = Issue::parse(&original).expect("a parent parses");
assert_eq!(
issue.parent,
Some(crate::id::IssueRef::Number(7)),
"the parent alias is read as data"
);
let once = issue.to_file_contents();
assert_eq!(
once, original,
"the parent key is written where it was read"
);
let twice = Issue::parse(&once).unwrap().to_file_contents();
assert_eq!(twice, once, "a second round trip changes nothing");
}
#[test]
fn t006_a_legacy_file_with_no_parent_key_serialises_to_its_pre_mission_bytes() {
let original = wp02_file(WP02_LEGACY_FM);
let issue = Issue::parse(&original).unwrap();
assert_eq!(issue.parent, None, "an absent key is no parent");
let rendered = issue.to_file_contents();
assert_eq!(rendered, original);
assert!(
!rendered.contains("parent:"),
"a file that never had a parent must not grow a `parent:` key"
);
assert_eq!(
Issue::parse(&rendered).unwrap().to_file_contents(),
rendered
);
}
#[test]
fn t006_the_parent_key_is_written_exactly_once() {
let rendered = Issue::parse(&wp02_file(WP02_PARENT_FM))
.unwrap()
.to_file_contents();
assert_eq!(
rendered.matches("\nparent:").count(),
1,
"`parent:` must be written once, got:\n{rendered}"
);
let reparsed = Issue::parse(&rendered).unwrap();
assert_eq!(reparsed.to_file_contents(), rendered);
}
#[test]
fn known_frontmatter_keys_cover_every_written_key() {
let mut issue = Issue::parse(&wp02_file(
"number: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n",
))
.unwrap();
issue.id = Some("A1B2C3D4".into());
issue.assignee = Some("@alice".into());
issue.estimate = Some("1h".into());
issue.commit = Some("abc1234".into());
issue.gitlab_id = Some("7".into());
issue.linear_id = Some("9".into());
issue.mission = Some("m".into());
issue.task = Some("T001".into());
issue.reviewer = Some("@bob".into());
issue.start_date = Some("2026-01-01".into());
issue.due_date = Some("2026-02-01".into());
issue.entered_at = Some("2026-03-01T10:00:00Z".into());
issue.source = Some("import".into());
issue.parent = Some(crate::id::IssueRef::Number(2));
issue.cycles = Some(vec!["A".into()]);
issue.milestones = Some(vec!["m".into()]);
issue.projects = Some(vec!["p-1".into()]);
issue.epics = Some(vec!["e-7c4a91".into()]);
issue.files = vec!["docs/a.md".into()];
issue.labels = vec!["one".into(), "two".into()];
issue.relationships = vec![Relationship::new(1, 2, RelationKind::Blocks, "T")];
issue.related_issues = vec!["#1".into()];
issue.type_ = Some("bug".into());
issue.health = Some(HealthRef {
state: Some("on_track".into()),
source: Some("current".into()),
policy_version: Some("health-v1".into()),
updated_at: Some("2026-03-01T10:00:00Z".into()),
});
issue.series = Some(SeriesRef {
id: "s-abc".into(),
name: Some("Nightly".into()),
rule: Some(SeriesRule {
kind: Some("daily".into()),
anchor: Some("2026-01-01".into()),
..SeriesRule::default()
}),
..SeriesRef::default()
});
issue.time_entries = vec![TimeEntry {
id: "e-1a2b3c4d".into(),
duration_minutes: Some(90),
date: Some("2026-03-01".into()),
author: "@alice".into(),
note: Some("reviewed the parser".into()),
extensions: TimeEntryExtensions::default(),
}];
let frontmatter = issue.frontmatter();
let written: Vec<String> = frontmatter.lines().filter_map(top_level_key).collect();
assert_eq!(
capture_extra_frontmatter(&frontmatter),
BTreeMap::new(),
"a fully-populated canonical file must have NO unknown keys: a key the \
model owns is being captured as unknown and will be emitted twice"
);
let mut sorted = written.clone();
sorted.sort();
let before = sorted.len();
sorted.dedup();
assert_eq!(
before,
sorted.len(),
"a key is written more than once in:\n{frontmatter}"
);
for key in &written {
assert!(
KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()),
"`{key}` is written by the model but absent from KNOWN_FRONTMATTER_KEYS, \
so capture_extra_frontmatter treats it as unknown and the writer emits \
it twice. Add it to the list."
);
}
for key in KNOWN_FRONTMATTER_KEYS {
if *key == "links" {
continue; }
assert!(
written.iter().any(|k| k == key),
"`{key}` is listed as known but no field writes it, so the entry is \
wrong in the other direction: a key wrongly listed as known is \
dropped on write. Either model it or remove it from the list."
);
}
assert_eq!(
written.len(),
KNOWN_FRONTMATTER_KEYS.len() - 1,
"the fixture must exercise every known key except the retired one; \
got {written:?} against {KNOWN_FRONTMATTER_KEYS:?}"
);
for key in KNOWN_FRONTMATTER_KEYS {
assert!(
!key.is_empty() && !key.contains(char::is_whitespace),
"`{key}` is not a usable top-level key"
);
}
}
#[test]
fn t006_an_explicitly_null_parent_is_the_absent_state() {
let issue = Issue::parse(&wp02_file("number: 3\ntitle: T\nstatus: todo\nparent:\n")).unwrap();
assert_eq!(issue.parent, None);
assert!(!issue.to_file_contents().contains("parent:"));
}
#[test]
fn t006_an_empty_string_parent_is_rejected_rather_than_read_as_a_reference() {
let err = Issue::parse(&wp02_file("number: 3\ntitle: T\nparent: \"\"\n"))
.expect_err("an empty-string parent is malformed, not a reference");
assert!(
err.to_string().contains("parent"),
"the error must name the offending field, got: {err}"
);
}
#[test]
fn t006_a_malformed_parent_value_is_rejected() {
for (label, value) in [
("an empty string", "\"\""),
("a single-quoted number", "'7'"),
("a zero-padded number", "007"),
("an underscore-separated number", "1_000"),
("a float", "7.5"),
("a float written as an integer", "7.0"),
("scientific notation", "1e2"),
("a list", "[7]"),
("a map", "{a: 1}"),
("a bool", "true"),
("a negative", "-1"),
("a value beyond u32", "4294967296"),
] {
let parsed = Issue::parse(&wp02_file(&format!(
"number: 3\ntitle: T\nparent: {value}\n"
)));
assert!(
parsed.is_err(),
"{label} (`parent: {value}`) must be rejected, got {parsed:?}"
);
}
}
#[test]
fn t006_the_accepted_scalar_forms_are_pinned_because_they_are_surprising() {
for (value, expected) in [
("0x1F", 31),
("0o17", 15),
("0b111", 7),
("+7", 7),
("0", 0),
] {
let issue = Issue::parse(&wp02_file(&format!(
"number: 3\ntitle: T\nparent: {value}\n"
)))
.unwrap_or_else(|e| panic!("`parent: {value}` is expected to parse: {e}"));
assert_eq!(
issue.parent,
Some(crate::id::IssueRef::Number(expected)),
"`parent: {value}` is pinned to {expected}"
);
}
for value in ["null", "~"] {
let issue = Issue::parse(&wp02_file(&format!(
"number: 3\ntitle: T\nparent: {value}\n"
)))
.unwrap();
assert_eq!(issue.parent, None, "`parent: {value}` is the absent state");
}
}
#[test]
fn t006_at_most_one_parent_is_expressible_because_the_field_is_not_a_list() {
let err = Issue::parse(&wp02_file("number: 3\ntitle: T\nparent: 7\nparent: 8\n"))
.expect_err("two parents are not representable");
assert!(err.to_string().contains("parent"), "got: {err}");
}
#[test]
fn t006_the_parent_key_is_distinct_from_mission_and_task() {
let mut issue = Issue::parse(&wp02_file(
"number: 3\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nmission: some-mission\ntask: T003\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n",
))
.unwrap();
issue.relationships = vec![Relationship::new(3, 8, RelationKind::Blocks, "Eight")];
assert!(
issue.relationships.len() == 1,
"the fixture must carry a relationship under test"
);
let fm = issue.frontmatter();
for key in ["mission:", "task:", "relationships:", "parent:"] {
assert_eq!(
fm.matches(key).count(),
1,
"`{key}` must appear exactly once, got:\n{fm}"
);
}
assert_eq!(issue.mission.as_deref(), Some("some-mission"));
assert_eq!(issue.task.as_deref(), Some("T003"));
assert_eq!(issue.parent, Some(crate::id::IssueRef::Number(7)));
issue.parent = None;
let fm = issue.frontmatter();
assert!(!fm.contains("parent:"));
assert!(fm.contains("mission: some-mission"));
assert!(fm.contains("task: T003"));
}
#[test]
fn t007_the_three_parent_event_kinds_round_trip_through_the_file() {
for (kind, spelling) in [
(HistoryEventKind::ParentSet, "parent_set"),
(HistoryEventKind::ParentChanged, "parent_changed"),
(HistoryEventKind::ParentCleared, "parent_cleared"),
] {
assert_eq!(kind.as_str(), spelling);
assert_eq!(
HistoryEventKind::from_str(spelling).unwrap(),
kind,
"`{spelling}` must parse back to the same kind"
);
}
assert!(HistoryEventKind::from_str("parent_changed_to").is_err());
for (kind, spelling) in [
(HistoryEventKind::ParentSet, "parent_set"),
(HistoryEventKind::ParentChanged, "parent_changed"),
(HistoryEventKind::ParentCleared, "parent_cleared"),
] {
let yaml = serde_yaml::to_string(&kind).unwrap();
assert_eq!(
yaml.trim(),
spelling,
"the derived serde spelling must match `as_str`"
);
let back: HistoryEventKind = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back, kind, "`{spelling}` must round trip through serde");
}
}
#[test]
fn t007_parent_events_survive_a_history_section_round_trip() {
let original = "---\nnumber: 3\ntitle: Child\nstatus: todo\nlabels: []\npriority: medium\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-02T03:04:05Z \u{2014} alice \u{2014} parent_set \u{2014} parent none -> #7\n\nProse after the section.\n";
let issue = Issue::parse(original).unwrap();
assert_eq!(issue.history.len(), 1);
assert_eq!(issue.history.events()[0].kind, HistoryEventKind::ParentSet);
assert_eq!(issue.to_file_contents(), original, "byte-stable");
}
#[test]
fn t007_issue_view_carries_the_parent_so_both_services_read_one_projection() {
let issue = Issue::parse(&wp02_file(WP02_PARENT_FM)).unwrap();
let view = issue.issue_view();
assert_eq!(view.parent, Some(crate::id::IssueRef::Number(7)));
assert_eq!(
crate::hierarchy::HierarchyNode::parent_number(&view),
Some(7)
);
}
fn a_decision() -> TransitionDecision {
TransitionDecision::new(
"2026-09-29T12:00:00Z",
"Herbert Maschke <thyseus@pm.me>",
"todo",
"in progress",
"unstarted",
"active",
None,
)
}
#[test]
fn t012_the_kind_spells_parses_and_renders() {
assert_eq!(
HistoryEventKind::TransitionAccepted.as_str(),
"transition_accepted"
);
assert_eq!(
"transition_accepted".parse::<HistoryEventKind>().unwrap(),
HistoryEventKind::TransitionAccepted
);
assert_eq!(
HistoryEventKind::TransitionAccepted.to_string(),
"transition_accepted"
);
}
#[test]
fn t012_an_unknown_kind_is_rejected_rather_than_coerced() {
let err = "transition_attempted"
.parse::<HistoryEventKind>()
.unwrap_err();
assert!(
err.to_string().contains("unknown History event kind"),
"{err}"
);
}
#[test]
fn t012_the_detail_grammar_renders_and_recovers_every_field() {
let decision = a_decision();
assert_eq!(
decision.detail(),
"todo -> in progress (unstarted -> active)",
"the rendered form is the contract, so it is asserted literally"
);
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert_eq!(recovered, decision, "a lossless round trip");
}
#[test]
fn t012_an_override_reason_round_trips() {
let mut decision = a_decision();
decision.override_reason = Some("reopening: filed against the wrong epic".into());
assert_eq!(
decision.detail(),
"todo -> in progress (unstarted -> active); override: reopening: filed \
against the wrong epic"
);
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert_eq!(
recovered.override_reason.as_deref(),
Some("reopening: filed against the wrong epic")
);
}
#[test]
fn t012_a_reason_containing_the_bare_marker_survives() {
let mut decision = a_decision();
decision.override_reason = Some("typo; override: actually the right one".into());
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert_eq!(
recovered.override_reason.as_deref(),
Some("typo; override: actually the right one")
);
assert_eq!(recovered.to, "in progress", "and the fields still line up");
}
#[test]
fn t012_a_reason_containing_the_anchored_marker_survives() {
let mut decision = a_decision();
decision.override_reason = Some("quoting it: ); override: and again".into());
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert_eq!(
recovered.override_reason.as_deref(),
Some("quoting it: ); override: and again"),
"the FIRST anchored marker is the split point, and the reason is the rest"
);
assert_eq!(recovered.from, "todo");
assert_eq!(recovered.to, "in progress");
}
#[test]
fn t012_a_reason_containing_an_arrow_survives() {
let mut decision = a_decision();
decision.override_reason = Some("note: a -> b was meant to be a -> c".into());
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert_eq!(
recovered.override_reason.as_deref(),
Some("note: a -> b was meant to be a -> c")
);
assert_eq!(
recovered.from, "todo",
"the head is unaffected by the reason"
);
assert_eq!(recovered.to, "in progress");
}
#[test]
fn t012_a_blank_reason_renders_as_no_marker_at_all() {
let mut decision = a_decision();
decision.override_reason = Some(" ".into());
assert_eq!(
decision.detail(),
"todo -> in progress (unstarted -> active)"
);
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert!(recovered.override_reason.is_none());
}
#[test]
fn t012_a_column_named_with_an_arrow_is_a_documented_limit_not_a_silent_mis_split() {
let mut decision = a_decision();
decision.from = "a -> b".into();
decision.to = "in progress".into();
let recovered = TransitionDecision::from_event(&decision.to_event()).unwrap();
assert_eq!(recovered.from, "a", "the documented mis-split, pinned");
assert_eq!(recovered.to_category, "active", "categories stay intact");
assert_eq!(recovered.from_category, "unstarted");
}
#[test]
fn t012_a_malformed_detail_is_an_error_not_a_partial_record() {
for bad in [
"no arrows here",
"todo -> in progress", "todo -> in progress (unstarted)", "todo -> in progress (unstarted -> active", " -> in progress (unstarted -> active)", ] {
let event = HistoryEvent::new(
"2026-09-29T12:00:00Z",
"alice",
HistoryEventKind::TransitionAccepted,
bad,
);
assert!(
TransitionDecision::from_event(&event).is_err(),
"`{bad}` must not parse into a partial record"
);
}
}
#[test]
fn t012_from_event_refuses_a_different_kind() {
let event = HistoryEvent::new(
"2026-09-29T12:00:00Z",
"alice",
HistoryEventKind::ParentSet,
"todo -> in progress (unstarted -> active)",
);
let err = TransitionDecision::from_event(&event).unwrap_err();
assert!(
err.to_string().contains("not a `transition_accepted`"),
"a parent event is not a transition, however well-formed its detail: {err}"
);
}
#[test]
fn t012_the_decision_survives_a_full_file_round_trip_byte_for_byte() {
let mut decision = a_decision();
decision.override_reason = Some("reopening".into());
const FILE: &str = "---\nnumber: 1\ntitle: \"T\"\nstatus: \"in progress\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.";
let mut issue = Issue::parse(FILE).unwrap();
issue.history.append(decision.to_event());
let rendered = issue.to_file_contents();
let reparsed = Issue::parse(&rendered).unwrap();
assert_eq!(
reparsed.to_file_contents(),
rendered,
"byte-stable through the file, like every other History kind"
);
let events = reparsed.history.events();
assert_eq!(events.len(), 1);
assert_eq!(
TransitionDecision::from_event(events[0]).unwrap(),
decision,
"and every field survives the file, not just the rendered text"
);
}
#[test]
fn t012_a_same_column_record_is_recognisable_as_a_no_op() {
let mut decision = a_decision();
decision.to = "todo".into();
assert!(decision.is_no_op());
let mut real = decision.clone();
real.to = "in progress".into();
assert!(!real.is_no_op());
}
fn project_file(projects_block: &str) -> String {
format!(
"---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n{projects_block}created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
)
}
#[test]
fn t011_projects_is_an_optional_id_list_that_round_trips_two_associations() {
let original = project_file("projects:\n- p-3f9a2c\n- p-7b1e04\n");
let issue = Issue::parse(&original).unwrap();
assert_eq!(
issue.projects,
Some(vec!["p-3f9a2c".to_string(), "p-7b1e04".to_string()]),
"the field holds several project ids"
);
let rendered = issue.to_file_contents();
assert_eq!(
rendered, original,
"two associations round-trip byte-stably"
);
assert_eq!(
Issue::parse(&rendered).unwrap().projects,
issue.projects,
"and survive a second read"
);
}
#[test]
fn t011_an_issue_with_no_projects_serializes_with_no_key() {
let no_key = project_file("");
assert!(!no_key.contains("projects:"));
let issue = Issue::parse(&no_key).unwrap();
assert_eq!(issue.projects, None, "an absent key reads as absent");
let rendered = issue.to_file_contents();
assert_eq!(
rendered, no_key,
"a file that never had the field is untouched by a round trip"
);
assert!(!rendered.contains("projects:"));
}
#[test]
fn t011_an_empty_projects_list_is_written_as_absent() {
let issue = Issue::parse(&project_file("projects: []\n")).unwrap();
let rendered = issue.to_file_contents();
assert!(
!rendered.contains("projects:"),
"the key is omitted when empty, exactly like cycles/milestones:\n{rendered}"
);
assert_eq!(Issue::parse(&rendered).unwrap().projects, None);
}
#[test]
fn t011_a_malformed_projects_value_is_rejected_not_coerced() {
let scalar = project_file("projects: p-3f9a2c\n");
assert!(
Issue::parse(&scalar).is_err(),
"a bare scalar is not a list of ids"
);
let mapping = project_file("projects:\n- id: p-1\n");
assert!(
Issue::parse(&mapping).is_err(),
"each entry must be an id string, not a map"
);
}
#[test]
fn t011_projects_is_distinct_from_cycles_milestones_mission_and_task() {
let mut issue = issue("Body.");
issue.cycles = Some(vec!["c-one".into()]);
issue.milestones = Some(vec!["m-one".into()]);
issue.mission = Some("mission-x".into());
issue.task = Some("WP03".into());
issue.projects = Some(vec!["p-one".into()]);
let rendered = issue.to_file_contents();
for key in ["projects:", "cycles:", "milestones:", "mission:", "task:"] {
assert_eq!(
rendered.matches(key).count(),
1,
"`{key}` must appear exactly once, never aliased onto another field:\n{rendered}"
);
}
let back = Issue::parse(&rendered).unwrap();
assert_eq!(back.projects, Some(vec!["p-one".to_string()]));
assert_eq!(back.cycles, Some(vec!["c-one".to_string()]));
assert_eq!(back.milestones, Some(vec!["m-one".to_string()]));
assert_eq!(back.mission, Some("mission-x".to_string()));
assert_eq!(back.task, Some("WP03".to_string()));
}
#[test]
fn t011_an_issue_exposes_the_project_node_view_the_service_reads() {
use crate::projects::ProjectNode;
let mut issue = issue("Body.");
issue.number = 9;
issue.title = "Nine".into();
issue.status = Status::new("doing");
issue.projects = Some(vec!["p-1".into()]);
assert_eq!(ProjectNode::number(&issue), 9);
assert_eq!(ProjectNode::title(&issue), "Nine");
assert_eq!(ProjectNode::status(&issue), "doing");
assert_eq!(ProjectNode::project_ids(&issue), &["p-1".to_string()][..]);
issue.projects = None;
assert!(
ProjectNode::project_ids(&issue).is_empty(),
"absent is the empty slice, not a panic"
);
}
#[test]
fn t012_project_kinds_render_and_parse() {
assert_eq!(
HistoryEventKind::ProjectAssociated.as_str(),
"project_associated"
);
assert_eq!(
HistoryEventKind::ProjectAssociated.to_string(),
"project_associated"
);
assert_eq!(
"project_associated".parse::<HistoryEventKind>().unwrap(),
HistoryEventKind::ProjectAssociated
);
assert_eq!(
HistoryEventKind::ProjectDissociated.as_str(),
"project_dissociated"
);
assert_eq!(
"project_dissociated".parse::<HistoryEventKind>().unwrap(),
HistoryEventKind::ProjectDissociated
);
}
#[test]
fn t012_from_project_maps_both_service_kinds() {
use crate::projects::ProjectEventKind;
assert_eq!(
HistoryEventKind::from_project(ProjectEventKind::ProjectAssociated),
HistoryEventKind::ProjectAssociated
);
assert_eq!(
HistoryEventKind::from_project(ProjectEventKind::ProjectDissociated),
HistoryEventKind::ProjectDissociated
);
}
#[test]
fn t012_a_project_event_round_trips_through_the_file() {
let mut issue = issue("Body.");
issue.history.append(HistoryEvent::new(
"2026-01-01T00:00:00Z",
"alice",
HistoryEventKind::ProjectAssociated,
"project `p-1` associated",
));
let rendered = issue.to_file_contents();
let back = Issue::parse(&rendered).unwrap();
let events = back.history.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
assert_eq!(events[0].detail, "project `p-1` associated");
assert_eq!(
back.to_file_contents(),
rendered,
"byte-stable through the file, like every other History kind"
);
}
#[test]
fn t012_an_unknown_project_style_kind_is_rejected() {
assert!(
"projects_changed".parse::<HistoryEventKind>().is_err(),
"an unknown kind must be rejected, never coerced to a default"
);
}
#[test]
fn t013_epics_is_an_optional_id_list_that_round_trips_two_references() {
let original = project_file("epics:\n- e-7c4a91\n- e-2b8f30\n");
let issue = Issue::parse(&original).unwrap();
assert_eq!(
issue.epics,
Some(vec!["e-7c4a91".to_string(), "e-2b8f30".to_string()]),
"the field holds several epic ids"
);
let rendered = issue.to_file_contents();
assert_eq!(rendered, original, "two references round-trip byte-stably");
assert_eq!(
Issue::parse(&rendered).unwrap().epics,
issue.epics,
"and survive a second read"
);
}
#[test]
fn t013_an_issue_with_no_epics_serializes_with_no_key() {
let no_key = project_file("");
assert!(!no_key.contains("epics:"));
let issue = Issue::parse(&no_key).unwrap();
assert_eq!(issue.epics, None, "an absent key reads as absent");
let rendered = issue.to_file_contents();
assert_eq!(
rendered, no_key,
"a file that never had the field is untouched by a round trip"
);
assert!(!rendered.contains("epics:"));
}
#[test]
fn t013_an_empty_epics_list_is_written_as_absent() {
let issue = Issue::parse(&project_file("epics: []\n")).unwrap();
let rendered = issue.to_file_contents();
assert!(
!rendered.contains("epics:"),
"the key is omitted when empty, exactly like projects/cycles/milestones:\n{rendered}"
);
assert_eq!(Issue::parse(&rendered).unwrap().epics, None);
}
#[test]
fn t013_a_malformed_epics_value_is_rejected_not_coerced() {
let scalar = project_file("epics: e-7c4a91\n");
assert!(
Issue::parse(&scalar).is_err(),
"a bare scalar is not a list of ids"
);
let mapping = project_file("epics:\n- id: e-1\n");
assert!(
Issue::parse(&mapping).is_err(),
"each entry must be an id string, not a map"
);
}
#[test]
fn t013_epics_is_distinct_from_projects_cycles_milestones_mission_task_and_files() {
let mut issue = issue("Body.");
issue.cycles = Some(vec!["c-one".into()]);
issue.milestones = Some(vec!["m-one".into()]);
issue.mission = Some("mission-x".into());
issue.task = Some("WP03".into());
issue.projects = Some(vec!["p-one".into()]);
issue.epics = Some(vec!["e-one".into()]);
issue.files = vec!["docs/a.md".into()];
let rendered = issue.to_file_contents();
for key in [
"epics:",
"projects:",
"cycles:",
"milestones:",
"mission:",
"task:",
"files:",
] {
assert_eq!(
rendered.matches(key).count(),
1,
"`{key}` must appear exactly once, never aliased onto another field:\n{rendered}"
);
}
let back = Issue::parse(&rendered).unwrap();
assert_eq!(back.epics, Some(vec!["e-one".to_string()]));
assert_eq!(back.projects, Some(vec!["p-one".to_string()]));
assert_eq!(back.cycles, Some(vec!["c-one".to_string()]));
assert_eq!(back.milestones, Some(vec!["m-one".to_string()]));
assert_eq!(back.mission, Some("mission-x".to_string()));
assert_eq!(back.task, Some("WP03".to_string()));
assert_eq!(back.files, vec!["docs/a.md".to_string()]);
}
#[test]
fn t013_an_issue_exposes_the_epic_node_view_the_service_reads() {
use crate::epics::EpicNode;
let mut issue = issue("Body.");
issue.number = 9;
issue.title = "Nine".into();
issue.status = Status::new("doing");
issue.epics = Some(vec!["e-1".into()]);
assert_eq!(EpicNode::number(&issue), 9);
assert_eq!(EpicNode::title(&issue), "Nine");
assert_eq!(EpicNode::status(&issue), "doing");
assert_eq!(EpicNode::epic_ids(&issue), &["e-1".to_string()][..]);
issue.epics = None;
assert!(
EpicNode::epic_ids(&issue).is_empty(),
"absent is the empty slice, not a panic"
);
}
#[test]
fn t014_epic_kinds_render_and_parse() {
assert_eq!(HistoryEventKind::EpicReferenced.as_str(), "epic_referenced");
assert_eq!(
HistoryEventKind::EpicReferenced.to_string(),
"epic_referenced"
);
assert_eq!(
"epic_referenced".parse::<HistoryEventKind>().unwrap(),
HistoryEventKind::EpicReferenced
);
assert_eq!(
HistoryEventKind::EpicDereferenced.as_str(),
"epic_dereferenced"
);
assert_eq!(
"epic_dereferenced".parse::<HistoryEventKind>().unwrap(),
HistoryEventKind::EpicDereferenced
);
}
#[test]
fn t014_from_epic_maps_both_service_kinds() {
use crate::epics::EpicEventKind;
assert_eq!(
HistoryEventKind::from_epic(EpicEventKind::EpicReferenced),
HistoryEventKind::EpicReferenced
);
assert_eq!(
HistoryEventKind::from_epic(EpicEventKind::EpicDereferenced),
HistoryEventKind::EpicDereferenced
);
}
#[test]
fn t014_an_epic_event_round_trips_through_the_file() {
let mut issue = issue("Body.");
issue.history.append(HistoryEvent::new(
"2026-01-01T00:00:00Z",
"alice",
HistoryEventKind::EpicReferenced,
"epic `e-7c4a91` referenced",
));
let rendered = issue.to_file_contents();
let back = Issue::parse(&rendered).unwrap();
let events = back.history.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
assert_eq!(
back.to_file_contents(),
rendered,
"byte-stable through the file, like every other History kind"
);
}
#[test]
fn t014_an_unknown_epic_style_kind_is_rejected() {
assert!(
"epics_changed".parse::<HistoryEventKind>().is_err(),
"an unknown kind must be rejected, never coerced to a default"
);
}
#[test]
fn t046_the_two_new_event_kinds_round_trip_through_their_spellings() {
for (kind, spelling) in [
(HistoryEventKind::EstimateChanged, "estimate_changed"),
(
HistoryEventKind::CycleMembershipChanged,
"cycle_membership_changed",
),
] {
assert_eq!(kind.as_str(), spelling);
assert_eq!(
HistoryEventKind::from_str(spelling).unwrap(),
kind,
"`{spelling}` must parse back to the same kind"
);
let yaml = serde_yaml::to_string(&kind).unwrap();
assert_eq!(
yaml.trim(),
spelling,
"the derived serde spelling must match `as_str`"
);
}
}
#[test]
fn t046_the_new_kinds_survive_a_full_file_round_trip() {
let mut issue = issue("Body.");
issue.history.append(HistoryEvent::new(
"2026-01-01T00:00:00Z",
"alice",
HistoryEventKind::EstimateChanged,
"estimate: none -> 3",
));
issue.history.append(HistoryEvent::new(
"2026-01-02T00:00:00Z",
"alice",
HistoryEventKind::CycleMembershipChanged,
"cycles: none -> Default",
));
let rendered = issue.to_file_contents();
let back = Issue::parse(&rendered).unwrap();
let events = back.history.events();
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, HistoryEventKind::EstimateChanged);
assert_eq!(events[1].kind, HistoryEventKind::CycleMembershipChanged);
assert_eq!(
back.to_file_contents(),
rendered,
"byte-stable through the file, like every other History kind"
);
}