use std::cmp::Ordering;
use std::str::FromStr;
use serde_yaml::Value;
use tix::model::{HistoryEventKind, Issue, KNOWN_FRONTMATTER_KEYS};
use tix::time_tracking::{TIME_ENTRY_FRONTMATTER_KEY, TimeEntry, TimeEntryExtensions};
const NO_TIME_ENTRIES: &str = "---\nnumber: 3\ntitle: Legacy issue\nstatus: todo\nlabels: []\npriority: medium\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";
const WITH_ONE_ENTRY: &str = "---\nnumber: 3\ntitle: Legacy issue\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ntime_entries:\n- id: e-1a2b3c4d\n duration_minutes: 90\n date: 2026-03-01\n author: alice\n note: reviewed the parser\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";
fn valid_entry() -> TimeEntry {
TimeEntry::new(
"e-1a2b3c4d",
90,
Some("2026-03-01".into()),
"alice",
Some("reviewed the parser".into()),
)
.expect("the fixture entry is valid")
}
fn bare(id: &str, minutes: Option<i64>, date: Option<&str>, author: &str) -> TimeEntry {
TimeEntry {
id: id.into(),
duration_minutes: minutes,
date: date.map(str::to_string),
author: author.into(),
note: None,
extensions: TimeEntryExtensions::default(),
}
}
#[test]
fn c001_nfr002_a_file_without_time_entries_round_trips_byte_identically() {
let original = NO_TIME_ENTRIES;
let issue = Issue::parse(original).expect("the legacy file parses");
assert!(
issue.time_entries.is_empty(),
"an absent `{TIME_ENTRY_FRONTMATTER_KEY}:` key is no entries"
);
assert!(
!issue
.to_file_contents()
.contains(TIME_ENTRY_FRONTMATTER_KEY),
"a file that never had an entry must not grow a `{TIME_ENTRY_FRONTMATTER_KEY}:` key"
);
let once = issue.to_file_contents();
assert_eq!(
once, original,
"the new field changed the bytes of a file that has no entries"
);
let twice = Issue::parse(&once).unwrap().to_file_contents();
assert_eq!(twice, once, "a second round trip changes nothing");
}
#[test]
fn c002_a_valid_entry_is_written_in_canonical_form_and_read_back_unchanged() {
let mut issue = Issue::parse(NO_TIME_ENTRIES).unwrap();
issue.time_entries = vec![valid_entry()];
let rendered = issue.to_file_contents();
assert_eq!(
rendered, WITH_ONE_ENTRY,
"the entry is written where the struct declares it, in canonical order"
);
assert_eq!(
rendered
.matches(&format!("{TIME_ENTRY_FRONTMATTER_KEY}:"))
.count(),
1,
"the key is written exactly once, got:\n{rendered}"
);
let reparsed = Issue::parse(&rendered).expect("the rendered file parses");
let entry = &reparsed.time_entries[0];
assert_eq!(entry.id, "e-1a2b3c4d", "the stable id survives");
assert_eq!(entry.duration_minutes, Some(90), "the minutes survive");
assert_eq!(
entry.date.as_deref(),
Some("2026-03-01"),
"the date survives"
);
assert_eq!(entry.author, "alice", "the author survives");
assert_eq!(entry.note.as_deref(), Some("reviewed the parser"));
assert_eq!(reparsed.time_entries, issue.time_entries);
assert_eq!(
reparsed.to_file_contents(),
rendered,
"a second render is byte-identical"
);
}
#[test]
fn c008_a_write_that_does_not_touch_time_changes_no_time_bytes() {
let issue = Issue::parse(WITH_ONE_ENTRY).unwrap();
let mut edited = issue.clone();
edited.title = "Renamed".into();
edited.bump_updated();
let rendered = edited.to_file_contents();
assert!(rendered.contains("title: Renamed"));
assert!(
rendered.contains(
"time_entries:\n- id: e-1a2b3c4d\n duration_minutes: 90\n date: 2026-03-01\n author: alice\n note: reviewed the parser\n"
),
"the entry block is untouched by an unrelated edit, got:\n{rendered}"
);
assert_eq!(
Issue::parse(&rendered).unwrap().time_entries,
issue.time_entries
);
assert_eq!(edited.estimate, issue.estimate);
assert_eq!(edited.status, issue.status);
assert_eq!(edited.assignee, issue.assignee);
}
#[test]
fn c002_negative_minutes_are_refused_with_their_field_and_kind() {
let invalid = bare("e-1", Some(-5), Some("2026-03-01"), "alice");
let problems = invalid.problems();
assert_eq!(
problems.len(),
1,
"only the duration is wrong: {problems:?}"
);
assert_eq!(problems[0].field(), "duration_minutes");
assert_eq!(problems[0].kind(), "invalid_duration");
assert!(
invalid.minutes().is_none(),
"a negative duration is not minutes"
);
assert_eq!(
invalid.validate().unwrap_err(),
problems,
"validate reports every problem, not just the first"
);
assert!(!invalid.is_valid());
let zero = TimeEntry {
duration_minutes: Some(0),
..invalid
};
assert!(zero.is_valid(), "zero minutes is a valid entry");
assert_eq!(zero.minutes(), Some(0));
}
#[test]
fn c002_a_missing_duration_is_refused_rather_than_read_as_zero_minutes() {
let invalid = bare("e-1", None, Some("2026-03-01"), "alice");
let problems = invalid.problems();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].field(), "duration_minutes");
assert_eq!(problems[0].kind(), "invalid_duration");
assert_eq!(
invalid.minutes(),
None,
"an absent duration is never zero minutes"
);
}
#[test]
fn c002_a_malformed_calendar_date_is_refused_with_its_field_and_kind() {
for malformed in ["2026-13-99", "2026-02-30", "01-03-2026", "2026-3-1", ""] {
let invalid = bare("e-1", Some(30), Some(malformed), "alice");
let problems = invalid.problems();
assert_eq!(problems.len(), 1, "`{malformed}`: {problems:?}");
assert_eq!(problems[0].field(), "date", "`{malformed}`");
assert_eq!(problems[0].kind(), "invalid_date", "`{malformed}`");
}
assert!(bare("e-1", Some(30), None, "alice").is_valid());
assert!(bare("e-1", Some(30), Some("2026-02-28"), "alice").is_valid());
assert!(bare("e-1", Some(30), Some("2024-02-29"), "alice").is_valid());
}
#[test]
fn c002_a_blank_id_is_refused_with_its_field_and_kind() {
for blank in ["", " ", "\t"] {
let invalid = bare(blank, Some(30), None, "alice");
let problems = invalid.problems();
assert_eq!(problems.len(), 1, "{blank:?}: {problems:?}");
assert_eq!(problems[0].field(), "id", "{blank:?}");
assert_eq!(problems[0].kind(), "malformed_entry", "{blank:?}");
}
assert!(bare("e-1", Some(30), None, "alice").is_valid());
}
#[test]
fn c002_a_blank_author_is_refused_but_an_unknown_one_is_not() {
for blank in ["", " "] {
let invalid = bare("e-1", Some(30), None, blank);
let problems = invalid.problems();
assert_eq!(problems.len(), 1, "{blank:?}: {problems:?}");
assert_eq!(problems[0].field(), "author", "{blank:?}");
assert_eq!(problems[0].kind(), "missing_author", "{blank:?}");
}
assert!(bare("e-1", Some(30), None, "Someone <someone@example.com>").is_valid());
assert!(bare("e-1", Some(30), None, "@alice").is_valid());
}
#[test]
fn c002_a_note_is_optional_but_must_fit_one_history_line() {
let mut entry = bare("e-1", Some(30), None, "alice");
entry.validate().expect("an absent note is valid");
entry.note = Some(String::new());
entry.validate().expect("an empty note is valid");
entry.note = Some("first\nsecond".into());
let problems = entry.problems();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].field(), "note");
assert_eq!(problems[0].kind(), "malformed_entry");
}
#[test]
fn c002_zero_and_large_durations_are_accepted() {
for minutes in [0, 1, 90, 525_600, i64::from(u32::MAX), i64::MAX] {
let entry = bare("e-1", Some(minutes), None, "alice");
assert!(entry.is_valid(), "{minutes} minutes must be valid");
assert_eq!(entry.minutes(), Some(minutes));
}
}
#[test]
fn c002_a_preserved_key_may_not_shadow_a_typed_field() {
let mut entry = bare("e-1", Some(30), None, "alice");
entry
.extensions
.insert("duration_minutes", Value::from(999));
let problems = entry.problems();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].field(), "extensions");
assert_eq!(problems[0].kind(), "malformed_entry");
}
#[test]
fn c005_a_malformed_entry_stays_readable_instead_of_blocking_the_issue() {
let raw = "---\nnumber: 4\ntitle: Broken but readable\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ntime_entries:\n- id: ''\n duration_minutes: -5\n date: 2026-13-99\n author: ''\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
let issue = Issue::parse(raw).expect("a malformed entry must not fail the issue parse");
assert_eq!(issue.title, "Broken but readable");
assert_eq!(issue.time_entries.len(), 1);
let entry = &issue.time_entries[0];
let fields: Vec<&str> = entry
.problems()
.iter()
.map(|problem| problem.field())
.collect();
assert_eq!(
fields,
vec!["id", "duration_minutes", "date", "author"],
"every broken field is reported, in field order"
);
assert_eq!(entry.minutes(), None);
assert!(!entry.is_valid());
assert_eq!(
Issue::parse(&issue.to_file_contents())
.unwrap()
.time_entries,
issue.time_entries
);
}
#[test]
fn c007_a_newer_builds_entry_metadata_round_trips_through_the_file() {
let raw = "---\nnumber: 5\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ntime_entries:\n- id: e-1\n duration_minutes: 45\n author: alice\n x_billing_code: ABC\n x_sessions:\n - one\n - two\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
let issue = Issue::parse(raw).expect("entry metadata this build does not model still parses");
let extensions = &issue.time_entries[0].extensions;
assert_eq!(
extensions.get("x_billing_code"),
Some(&Value::String("ABC".into())),
"a scalar extension is preserved: {extensions:?}"
);
assert_eq!(
extensions.get("x_sessions"),
Some(&Value::Sequence(vec![
Value::String("one".into()),
Value::String("two".into())
])),
"a sequence extension is preserved, not rejected for not being a string"
);
assert_eq!(issue.time_entries[0].duration_minutes, Some(45));
let rendered = issue.to_file_contents();
assert_eq!(
rendered.matches("x_billing_code:").count(),
1,
"the extension is written exactly once, got:\n{rendered}"
);
assert_eq!(rendered.matches(TIME_ENTRY_FRONTMATTER_KEY).count(), 1);
let reparsed = Issue::parse(&rendered).unwrap();
assert_eq!(reparsed.time_entries[0].extensions, *extensions);
assert_eq!(
reparsed.to_file_contents(),
rendered,
"the render is stable"
);
}
#[test]
fn c008_entry_identity_is_the_stable_id_and_ordering_is_deterministic() {
let early = TimeEntry {
date: Some("2026-01-01".into()),
..valid_entry()
};
let late = TimeEntry {
date: Some("2026-03-01".into()),
..valid_entry()
};
let undated = TimeEntry {
date: None,
..valid_entry()
};
let mut entries = vec![undated.clone(), late.clone(), early.clone()];
entries.sort();
assert_eq!(
entries,
vec![early.clone(), late.clone(), undated.clone()],
"dated entries oldest first, undated last"
);
assert_eq!(early.cmp(&early.clone()), Ordering::Equal);
assert_ne!(early.cmp(&late), Ordering::Equal);
assert_ne!(early, late);
let other_id = TimeEntry {
id: "e-9f8e7d6c".into(),
..early.clone()
};
assert_ne!(early, other_id);
assert_eq!(
early.cmp(&other_id),
Ordering::Less,
"the id breaks the tie between otherwise identical entries"
);
}
#[test]
fn c001_the_time_entry_key_is_registered_once_and_is_the_one_the_model_writes() {
assert!(
KNOWN_FRONTMATTER_KEYS.contains(&TIME_ENTRY_FRONTMATTER_KEY),
"`{TIME_ENTRY_FRONTMATTER_KEY}` must be registered in KNOWN_FRONTMATTER_KEYS, \
or the writer captures it as unknown and emits it twice"
);
assert_eq!(
KNOWN_FRONTMATTER_KEYS
.iter()
.filter(|key| **key == TIME_ENTRY_FRONTMATTER_KEY)
.count(),
1,
"registered once, not twice"
);
}
#[test]
fn c003_c004_the_time_history_kinds_round_trip_through_their_spellings() {
for (kind, spelling) in [
(HistoryEventKind::TimeEntryCreated, "time_entry_created"),
(HistoryEventKind::TimeEntryUpdated, "time_entry_updated"),
(HistoryEventKind::TimeEntryDeleted, "time_entry_deleted"),
] {
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`"
);
}
}