use std::collections::{BTreeMap, HashSet};
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::relationships::{IssueView, Relationship};
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,
}
}
pub fn is_undefined(&self) -> bool {
matches!(self, Priority::Other(s) if s.is_empty())
}
}
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)
}
}
}
fn skip_planning_list(v: &Option<Vec<String>>) -> bool {
v.as_deref().is_none_or(|items| items.is_empty())
}
fn se_planning_list<S: Serializer>(
v: &Option<Vec<String>>,
s: S,
) -> std::result::Result<S::Ok, S::Error> {
let items: Vec<&String> = match v {
Some(items) => {
items
.iter()
.fold((Vec::new(), HashSet::new()), |(mut out, mut seen), item| {
if seen.insert(item.as_str()) {
out.push(item);
}
(out, seen)
})
.0
}
None => Vec::new(),
};
items.serialize(s)
}
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}")
}
pub fn utc_now_rfc3339() -> 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 rem = secs % 86_400;
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
pub const KNOWN_FRONTMATTER_KEYS: &[&str] = &[
"number",
"id",
"title",
"status",
"labels",
"type",
"priority",
"assignee",
"estimate",
"commit",
"gitlab_id",
"linear_id",
"mission",
"task",
"relationships",
"related_issues",
"parent",
"series",
"health",
"files",
"cycles",
"milestones",
"projects",
"epics",
"reviewer",
"start_date",
"due_date",
"entered_at",
"source",
"time_entries",
"created",
"updated",
"links",
];
pub fn capture_extra_frontmatter(frontmatter: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut current: Option<(String, String)> = None;
for line in frontmatter.lines() {
match top_level_key(line) {
Some(key) => {
if let Some((name, block)) = current.take() {
out.insert(name, block);
}
if KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()) {
continue;
}
current = Some((key, line.to_string()));
}
None => {
if let Some((_, block)) = current.as_mut() {
if line.trim().is_empty() {
current = None;
} else {
block.push('\n');
block.push_str(line);
}
}
}
}
}
if let Some((name, block)) = current.take() {
out.insert(name, block);
}
out
}
fn top_level_key(line: &str) -> Option<String> {
if !line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
return None;
}
let colon = line.find(':')?;
let key = &line[..colon];
if key.is_empty()
|| !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return None;
}
Some(key.to_string())
}
fn frontmatter_has_retired_links_key(frontmatter: &str) -> bool {
if let Ok(mapping) = serde_yaml::from_str::<serde_yaml::Mapping>(frontmatter) {
let key = serde_yaml::Value::String("links".into());
return match mapping.get(&key) {
Some(value) => !links_value_is_empty(value),
None => false,
};
}
frontmatter.lines().any(|line| {
if line.starts_with(' ') || line.starts_with('\t') {
return false;
}
line.split_once(':')
.map(|(key, _)| key.trim() == "links")
.unwrap_or(false)
})
}
fn links_value_is_empty(value: &serde_yaml::Value) -> bool {
match value {
serde_yaml::Value::Null => true,
serde_yaml::Value::Sequence(items) => items.is_empty(),
serde_yaml::Value::String(s) => s.trim().is_empty() || s.trim() == "[]",
serde_yaml::Value::Mapping(m) => m.is_empty(),
serde_yaml::Value::Tagged(t) => links_value_is_empty(&t.value),
_ => false,
}
}
pub fn validate_date(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
for &c in &b[0..4] {
if !c.is_ascii_digit() {
return false;
}
}
for &c in &[b[5], b[6], b[8], b[9]] {
if !c.is_ascii_digit() {
return false;
}
}
let year = u32::from(b[0] - b'0') * 1000
+ u32::from(b[1] - b'0') * 100
+ u32::from(b[2] - b'0') * 10
+ u32::from(b[3] - b'0');
let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
if year < 1970 || !(1..=12).contains(&month) {
return false;
}
day >= 1 && day <= days_in_month(year, month)
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
if leap { 29 } else { 28 }
}
_ => 0,
}
}
fn validate_date_msg(s: &str) -> std::result::Result<(), String> {
if validate_date(s) {
Ok(())
} else {
Err(format!(
"`{s}` is not a `YYYY-MM-DD` calendar date (year >= 1970)"
))
}
}
pub fn validate_issue_dates(start_date: Option<&str>, due_date: Option<&str>) -> Result<()> {
if let Some(start) = start_date
&& validate_date_msg(start).is_err()
{
return Err(TixError::Config(format!(
"start_date {start:?} must be a `YYYY-MM-DD` calendar date"
)));
}
if let Some(due) = due_date {
if validate_date_msg(due).is_err() {
return Err(TixError::Config(format!(
"due_date {due:?} must be a `YYYY-MM-DD` calendar date"
)));
}
if let Some(start) = start_date
&& !date_lte(start, due)
{
return Err(TixError::Config(format!(
"due_date {due:?} must be `>= start_date` {start:?}"
)));
}
}
Ok(())
}
pub fn date_lte(a: &str, b: &str) -> bool {
a <= b
}
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, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HealthRef {
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SeriesRef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rule: Option<SeriesRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<crate::recurrence::series::SeriesLifecycle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeriesRule {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expression: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(default, flatten)]
pub extensions: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issue {
pub number: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub status: Status,
#[serde(default)]
pub labels: Labels,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
#[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, skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related_issues: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series: Option<SeriesRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<HealthRef>,
#[serde(default)]
pub files: Vec<String>,
#[serde(
default,
skip_serializing_if = "skip_planning_list",
serialize_with = "se_planning_list"
)]
pub cycles: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "skip_planning_list",
serialize_with = "se_planning_list"
)]
pub milestones: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "skip_planning_list",
serialize_with = "se_planning_list"
)]
pub projects: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "skip_planning_list",
serialize_with = "se_planning_list"
)]
pub epics: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entered_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub time_entries: Vec<crate::time_tracking::TimeEntry>,
#[serde(default = "today")]
pub created: String,
#[serde(default = "today")]
pub updated: String,
#[serde(skip)]
pub filename: String,
#[serde(skip)]
pub body: String,
#[serde(skip)]
pub history: History,
#[serde(skip)]
pub has_retired_links_key: bool,
#[serde(skip)]
pub extra_frontmatter: BTreeMap<String, String>,
#[serde(skip)]
pub archived: bool,
}
pub const HISTORY_HEADING: &str = "## History";
const HISTORY_SEP: char = '\u{2014}';
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryEventKind {
RelationshipAdded,
RelationshipRemoved,
RelationshipRetargeted,
BlockedMoveAcknowledged,
ParentSet,
ParentChanged,
ParentCleared,
TransitionAccepted,
TriageAccepted,
TriageDeclined,
TriageDuplicated,
TriageDeferred,
Archived,
Restored,
HealthUpdated,
HealthOverridden,
ProjectAssociated,
ProjectDissociated,
EpicReferenced,
EpicDereferenced,
CycleClosed,
CycleExtended,
CycleSkipped,
CyclePaused,
CycleResumed,
CycleAdjusted,
CycleReopened,
CycleCapacityEdited,
TimeEntryCreated,
TimeEntryUpdated,
TimeEntryDeleted,
EstimateChanged,
CycleMembershipChanged,
}
impl HistoryEventKind {
pub fn as_str(self) -> &'static str {
match self {
HistoryEventKind::RelationshipAdded => "relationship_added",
HistoryEventKind::RelationshipRemoved => "relationship_removed",
HistoryEventKind::RelationshipRetargeted => "relationship_retargeted",
HistoryEventKind::BlockedMoveAcknowledged => "blocked_move_acknowledged",
HistoryEventKind::ParentSet => "parent_set",
HistoryEventKind::ParentChanged => "parent_changed",
HistoryEventKind::ParentCleared => "parent_cleared",
HistoryEventKind::TransitionAccepted => "transition_accepted",
HistoryEventKind::TriageAccepted => "triage_accepted",
HistoryEventKind::TriageDeclined => "triage_declined",
HistoryEventKind::TriageDuplicated => "triage_duplicated",
HistoryEventKind::TriageDeferred => "triage_deferred",
HistoryEventKind::Archived => "archived",
HistoryEventKind::Restored => "restored",
HistoryEventKind::HealthUpdated => "health_updated",
HistoryEventKind::HealthOverridden => "health_overridden",
HistoryEventKind::ProjectAssociated => "project_associated",
HistoryEventKind::ProjectDissociated => "project_dissociated",
HistoryEventKind::EpicReferenced => "epic_referenced",
HistoryEventKind::EpicDereferenced => "epic_dereferenced",
HistoryEventKind::CycleClosed => "cycle_closed",
HistoryEventKind::CycleExtended => "cycle_extended",
HistoryEventKind::CycleSkipped => "cycle_skipped",
HistoryEventKind::CyclePaused => "cycle_paused",
HistoryEventKind::CycleResumed => "cycle_resumed",
HistoryEventKind::CycleAdjusted => "cycle_adjusted",
HistoryEventKind::CycleReopened => "cycle_reopened",
HistoryEventKind::CycleCapacityEdited => "cycle_capacity_edited",
HistoryEventKind::TimeEntryCreated => "time_entry_created",
HistoryEventKind::TimeEntryUpdated => "time_entry_updated",
HistoryEventKind::TimeEntryDeleted => "time_entry_deleted",
HistoryEventKind::EstimateChanged => "estimate_changed",
HistoryEventKind::CycleMembershipChanged => "cycle_membership_changed",
}
}
#[must_use]
pub fn is_triage_decision(self) -> bool {
matches!(
self,
HistoryEventKind::TriageAccepted
| HistoryEventKind::TriageDeclined
| HistoryEventKind::TriageDuplicated
| HistoryEventKind::TriageDeferred
)
}
}
impl fmt::Display for HistoryEventKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for HistoryEventKind {
type Err = TixError;
fn from_str(s: &str) -> Result<Self> {
match s {
"relationship_added" => Ok(HistoryEventKind::RelationshipAdded),
"relationship_removed" => Ok(HistoryEventKind::RelationshipRemoved),
"relationship_retargeted" => Ok(HistoryEventKind::RelationshipRetargeted),
"blocked_move_acknowledged" => Ok(HistoryEventKind::BlockedMoveAcknowledged),
"parent_set" => Ok(HistoryEventKind::ParentSet),
"parent_changed" => Ok(HistoryEventKind::ParentChanged),
"parent_cleared" => Ok(HistoryEventKind::ParentCleared),
"transition_accepted" => Ok(HistoryEventKind::TransitionAccepted),
"triage_accepted" => Ok(HistoryEventKind::TriageAccepted),
"triage_declined" => Ok(HistoryEventKind::TriageDeclined),
"triage_duplicated" => Ok(HistoryEventKind::TriageDuplicated),
"triage_deferred" => Ok(HistoryEventKind::TriageDeferred),
"archived" => Ok(HistoryEventKind::Archived),
"restored" => Ok(HistoryEventKind::Restored),
"health_updated" => Ok(HistoryEventKind::HealthUpdated),
"health_overridden" => Ok(HistoryEventKind::HealthOverridden),
"project_associated" => Ok(HistoryEventKind::ProjectAssociated),
"project_dissociated" => Ok(HistoryEventKind::ProjectDissociated),
"epic_referenced" => Ok(HistoryEventKind::EpicReferenced),
"epic_dereferenced" => Ok(HistoryEventKind::EpicDereferenced),
"cycle_closed" => Ok(HistoryEventKind::CycleClosed),
"cycle_extended" => Ok(HistoryEventKind::CycleExtended),
"cycle_skipped" => Ok(HistoryEventKind::CycleSkipped),
"cycle_paused" => Ok(HistoryEventKind::CyclePaused),
"cycle_resumed" => Ok(HistoryEventKind::CycleResumed),
"cycle_adjusted" => Ok(HistoryEventKind::CycleAdjusted),
"cycle_reopened" => Ok(HistoryEventKind::CycleReopened),
"cycle_capacity_edited" => Ok(HistoryEventKind::CycleCapacityEdited),
"time_entry_created" => Ok(HistoryEventKind::TimeEntryCreated),
"time_entry_updated" => Ok(HistoryEventKind::TimeEntryUpdated),
"time_entry_deleted" => Ok(HistoryEventKind::TimeEntryDeleted),
"estimate_changed" => Ok(HistoryEventKind::EstimateChanged),
"cycle_membership_changed" => Ok(HistoryEventKind::CycleMembershipChanged),
other => Err(TixError::Parse(format!(
"unknown History event kind `{other}`"
))),
}
}
}
impl HistoryEventKind {
pub fn from_parent(kind: crate::hierarchy::ParentEventKind) -> Self {
match kind {
crate::hierarchy::ParentEventKind::ParentSet => HistoryEventKind::ParentSet,
crate::hierarchy::ParentEventKind::ParentChanged => HistoryEventKind::ParentChanged,
crate::hierarchy::ParentEventKind::ParentCleared => HistoryEventKind::ParentCleared,
}
}
#[must_use]
pub fn from_project(kind: crate::projects::ProjectEventKind) -> Self {
match kind {
crate::projects::ProjectEventKind::ProjectAssociated => {
HistoryEventKind::ProjectAssociated
}
crate::projects::ProjectEventKind::ProjectDissociated => {
HistoryEventKind::ProjectDissociated
}
}
}
#[must_use]
pub fn from_epic(kind: crate::epics::EpicEventKind) -> Self {
match kind {
crate::epics::EpicEventKind::EpicReferenced => HistoryEventKind::EpicReferenced,
crate::epics::EpicEventKind::EpicDereferenced => HistoryEventKind::EpicDereferenced,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionDecision {
pub timestamp: String,
pub actor: String,
pub from: String,
pub to: String,
pub from_category: String,
pub to_category: String,
pub override_reason: Option<String>,
}
const OVERRIDE_MARKER: &str = "); override: ";
const CATEGORY_OPEN: &str = " (";
const DETAIL_ARROW: &str = " -> ";
impl TransitionDecision {
pub fn new(
timestamp: impl Into<String>,
actor: impl Into<String>,
from: impl Into<String>,
to: impl Into<String>,
from_category: impl Into<String>,
to_category: impl Into<String>,
override_reason: Option<String>,
) -> Self {
Self {
timestamp: timestamp.into(),
actor: actor.into(),
from: from.into(),
to: to.into(),
from_category: from_category.into(),
to_category: to_category.into(),
override_reason,
}
}
pub fn detail(&self) -> String {
let mut out = format!(
"{from}{arrow}{to} ({fc}{arrow}{tc})",
from = self.from,
to = self.to,
fc = self.from_category,
tc = self.to_category,
arrow = DETAIL_ARROW,
);
if let Some(reason) = self.override_reason.as_deref().map(str::trim) {
if !reason.is_empty() {
out.push_str(OVERRIDE_MARKER.trim_start_matches(')'));
out.push_str(reason);
}
}
out
}
pub fn from_event(event: &HistoryEvent) -> Result<Self> {
if event.kind != HistoryEventKind::TransitionAccepted {
return Err(TixError::Parse(format!(
"History entry is a `{}`, not a `transition_accepted`",
event.kind
)));
}
let malformed =
|what: &str| TixError::Parse(format!("transition record {what}: `{}`", event.detail));
let (head, override_reason) = match event.detail.split_once(OVERRIDE_MARKER) {
Some((head, reason)) => (head, Some(reason.trim().to_string())),
None => (event.detail.as_str(), None),
};
let head = match &override_reason {
Some(_) => format!("{head})"),
None => head.to_string(),
};
let head = head.as_str();
let (from, rest) = head
.split_once(DETAIL_ARROW)
.ok_or_else(|| malformed("has no `from -> to`"))?;
let rest = rest
.strip_suffix(')')
.ok_or_else(|| malformed("has no closing `)` after the categories"))?;
let (to, categories) = rest
.split_once(CATEGORY_OPEN)
.ok_or_else(|| malformed("has no ` (` before the categories"))?;
let (from_category, to_category) = categories
.split_once(DETAIL_ARROW)
.ok_or_else(|| malformed("has no `from_category -> to_category`"))?;
for (name, value) in [
("from", from),
("to", to),
("from_category", from_category),
("to_category", to_category),
] {
if value.trim().is_empty() {
return Err(malformed(name));
}
}
Ok(Self {
timestamp: event.timestamp.clone(),
actor: event.actor.clone(),
from: from.to_string(),
to: to.to_string(),
from_category: from_category.to_string(),
to_category: to_category.to_string(),
override_reason,
})
}
pub fn to_event(&self) -> HistoryEvent {
HistoryEvent::new(
self.timestamp.clone(),
self.actor.clone(),
HistoryEventKind::TransitionAccepted,
self.detail(),
)
}
pub fn is_no_op(&self) -> bool {
self.from == self.to
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoryEvent {
pub timestamp: String,
pub actor: String,
pub kind: HistoryEventKind,
pub detail: String,
}
impl HistoryEvent {
pub fn new(
timestamp: impl Into<String>,
actor: impl Into<String>,
kind: HistoryEventKind,
detail: impl Into<String>,
) -> Self {
Self {
timestamp: timestamp.into(),
actor: actor.into(),
kind,
detail: detail.into(),
}
}
fn render(&self) -> String {
format!(
"- {t} {h} {a} {h} {k} {h} {d}",
t = self.timestamp.trim(),
h = HISTORY_SEP,
a = self.actor.trim(),
k = self.kind.as_str(),
d = self.detail
)
}
fn parse(line: &str) -> Result<Self> {
let item = line
.trim()
.strip_prefix("- ")
.ok_or_else(|| TixError::Parse(format!("History entry is not a list item: {line}")))?;
let parts: Vec<&str> = item.splitn(4, HISTORY_SEP).collect();
let missing =
|what: &str| TixError::Parse(format!("History entry is missing `{what}`: {line}"));
if parts.len() < 3 {
return Err(missing("a field separator"));
}
let timestamp = parts[0].trim();
if timestamp.is_empty() {
return Err(missing("timestamp"));
}
let actor = parts[1].trim().to_string();
let kind_text = parts[2].trim();
if kind_text.is_empty() {
return Err(missing("kind"));
}
let kind = kind_text.parse::<HistoryEventKind>()?;
let detail = parts.get(3).copied().unwrap_or_default().trim().to_string();
Ok(Self {
timestamp: timestamp.to_string(),
actor,
kind,
detail,
})
}
}
fn is_list_item(item: &str) -> bool {
for marker in ["- ", "* ", "+ "] {
if item.starts_with(marker) {
return true;
}
}
let Some(dot) = item.find(['.', ')']) else {
return false;
};
dot > 0
&& dot <= 2
&& item[..dot].bytes().all(|b| b.is_ascii_digit())
&& item[dot + 1..].starts_with(' ')
}
pub fn looks_like_history_entry(line: &str) -> bool {
let item = line.trim();
let Some(rest) = item.strip_prefix("- ") else {
return false;
};
rest.contains(HISTORY_SEP)
}
pub fn looks_like_an_unrecognised_entry(line: &str) -> bool {
let Some(rest) = strip_list_marker(line) else {
return false;
};
if rest.contains(HISTORY_SEP) {
return false; }
entry_shaped_by_ascii_hyphens(rest, STRICT_TAIL_FIELDS)
}
const STRICT_TAIL_FIELDS: usize = 3;
const RETENTION_TAIL_FIELDS: usize = 2;
pub fn is_history_heading(line: &str) -> bool {
line.trim_end_matches([' ', '\t']) == HISTORY_HEADING
}
fn is_entry_attempt(item: &str) -> bool {
let Some(rest) = strip_list_marker(item) else {
return false;
};
if rest.contains(HISTORY_SEP) {
return true;
}
entry_shaped_by_ascii_hyphens(rest, RETENTION_TAIL_FIELDS)
}
fn entry_shaped_by_ascii_hyphens(rest: &str, min_tail: usize) -> bool {
let Some(tail) = strip_leading_timestamp(rest) else {
return false;
};
let parts: Vec<&str> = tail
.split('-')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
if parts.len() < min_tail {
return false;
}
!starts_like_a_date(parts[0])
}
fn starts_like_a_date(token: &str) -> bool {
let digits = token.bytes().take_while(u8::is_ascii_digit).count();
digits >= 4
}
fn strip_list_marker(line: &str) -> Option<&str> {
let item = line.trim();
if is_list_item(item) {
let space = item.find(char::is_whitespace).unwrap_or(item.len());
return Some(item[space..].trim_start());
}
None
}
fn strip_leading_timestamp(rest: &str) -> Option<&str> {
let b = rest.as_bytes();
if b.len() < 10
|| !b[0..4].iter().all(u8::is_ascii_digit)
|| b[4] != b'-'
|| !b[5..7].iter().all(u8::is_ascii_digit)
|| b[7] != b'-'
|| !b[8..10].iter().all(u8::is_ascii_digit)
{
return None;
}
let mut i = 10;
if b.get(i) == Some(&b'T') {
i += 1;
while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':' || b[i] == b'.') {
i += 1;
}
match b.get(i) {
Some(b'Z') => i += 1,
Some(&b'-') => {
let mut j = i + 1;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > i + 1 {
i = j;
}
}
Some(&b'+') => {
i += 1;
while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':') {
i += 1;
}
}
_ => {}
}
}
Some(rest[i.min(rest.len())..].trim_start_matches(['-', ' ', '\t', '.']))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HistoryEntry {
Event(HistoryEvent),
Unparsed(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct History {
pub entries: Vec<HistoryEntry>,
}
impl History {
pub fn new() -> Self {
Self::default()
}
pub fn append(&mut self, event: HistoryEvent) {
self.entries.push(HistoryEntry::Event(event));
}
pub fn append_now(&mut self, actor: &str, kind: HistoryEventKind, detail: &str) {
self.append(HistoryEvent::new(utc_now_rfc3339(), actor, kind, detail));
}
pub fn events(&self) -> Vec<&HistoryEvent> {
self.entries
.iter()
.filter_map(|e| match e {
HistoryEntry::Event(event) => Some(event),
HistoryEntry::Unparsed(_) => None,
})
.collect()
}
pub fn unparsed(&self) -> Vec<&str> {
self.entries
.iter()
.filter_map(|e| match e {
HistoryEntry::Event(_) => None,
HistoryEntry::Unparsed(line) => Some(line.as_str()),
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn render(&self) -> String {
if self.is_empty() {
return String::new();
}
let mut out = String::from(HISTORY_HEADING);
out.push_str("\n\n");
for entry in &self.entries {
match entry {
HistoryEntry::Event(event) => {
out.push_str(&event.render());
out.push('\n');
}
HistoryEntry::Unparsed(line) => {
out.push_str(line);
out.push('\n');
}
}
}
out.push('\n');
out
}
pub fn parse_front(body: &str) -> (Self, String) {
let lead = body.len() - body.trim_start_matches('\n').len();
let trimmed = &body[lead..];
let Some(rest) = trimmed.strip_prefix(HISTORY_HEADING) else {
return (History::new(), body.to_string());
};
let rest = rest.trim_start_matches([' ', '\t']);
let Some(after_heading) = rest
.strip_prefix("\r\n")
.or_else(|| rest.strip_prefix('\n'))
else {
return (History::new(), body.to_string());
};
let after_heading = after_heading.trim_start_matches(['\r', '\n']);
let mut history = History::new();
let mut offset = 0usize;
let mut saw_rejected_list_item = false;
for line in after_heading.split_inclusive('\n') {
let raw_line = line;
let item = raw_line.trim();
if item.is_empty() {
offset += line.len();
continue;
}
if !looks_like_history_entry(item) {
let following: Vec<&str> = after_heading[offset + line.len()..]
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let plausible_entry = is_entry_attempt(item);
let after_all_entries = following.iter().all(|l| looks_like_history_entry(l));
let keep = if following.is_empty() {
is_list_item(item)
} else {
plausible_entry || after_all_entries
};
if !keep {
saw_rejected_list_item |= is_list_item(item);
break;
}
history.entries.push(HistoryEntry::Unparsed(
raw_line.trim_end_matches('\n').to_string(),
));
offset += line.len();
continue;
}
match HistoryEvent::parse(item) {
Ok(event) => history.entries.push(HistoryEntry::Event(event)),
Err(_) => history.entries.push(HistoryEntry::Unparsed(
raw_line.trim_end_matches('\n').to_string(),
)),
}
offset += line.len();
}
if history.entries.is_empty() && saw_rejected_list_item {
return (history, body.to_string());
}
(history, after_heading[offset..].to_string())
}
}
impl Issue {
pub fn parse(contents: &str) -> Result<Self> {
let rest = contents
.strip_prefix("---\n")
.or_else(|| contents.strip_prefix("---\r\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 line = ¤t[..line_end];
let content = line.strip_suffix('\n').unwrap_or(line);
let content = content.strip_suffix('\r').unwrap_or(content);
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")
.or_else(|| body_raw.strip_prefix("---\r\n"))
.unwrap_or(body_raw);
if let Some(after) = body
.strip_prefix("\r\n")
.or_else(|| body.strip_prefix('\n'))
{
body = after;
}
let mut issue: Issue =
serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
issue.has_retired_links_key = frontmatter_has_retired_links_key(frontmatter);
issue.extra_frontmatter = capture_extra_frontmatter(frontmatter);
let (history, remaining_body) = History::parse_front(body);
issue.history = history;
issue.body = remaining_body;
Ok(issue)
}
pub fn frontmatter(&self) -> String {
let fm =
serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
let mut fm = fm.trim_end_matches('\n').to_string();
for (key, block) in &self.extra_frontmatter {
fm.push('\n');
fm.push_str(block);
let _ = key;
}
fm.push('\n');
fm
}
pub fn to_file_contents(&self) -> String {
let history = self.history.render();
let mut out = format!("---\n{}---\n\n", self.frontmatter());
out.push_str(&history);
out.push_str(&self.body);
out
}
pub fn issue_view(&self) -> IssueView<'_> {
IssueView::new(
self.number,
&self.title,
self.status.display(),
&self.relationships,
)
.with_retired_links_key(self.has_retired_links_key)
.with_parent(self.parent)
}
pub fn filename_stem(&self) -> String {
match &self.id {
Some(id) => format!("{}-{id}-{}", self.created, slugify(&self.title)),
None => format!("{}-{}", self.created, slugify(&self.title)),
}
}
pub fn bump_updated(&mut self) {
self.updated = today();
}
pub fn set_dates(
&mut self,
start_date: Option<String>,
due_date: Option<String>,
) -> Result<()> {
validate_issue_dates(start_date.as_deref(), due_date.as_deref())?;
self.start_date = start_date;
self.due_date = due_date;
Ok(())
}
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();
}
}
impl crate::hierarchy::HierarchyNode for Issue {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
self.status.display()
}
fn parent_number(&self) -> Option<u32> {
self.parent
}
}
impl crate::projects::ProjectNode for Issue {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
self.status.display()
}
fn project_ids(&self) -> &[String] {
self.projects.as_deref().unwrap_or(&[])
}
}
impl crate::epics::EpicNode for Issue {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
self.status.display()
}
fn epic_ids(&self) -> &[String] {
self.epics.as_deref().unwrap_or(&[])
}
}
#[cfg(test)]
mod tests {
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(7), "the parent number 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(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),
("!!str 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(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(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(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"
);
}
}