use crate::change::Change;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Date {
days: i64,
}
#[derive(Clone, Debug)]
pub struct Context {
now: Date,
change: Option<Change>,
orphaned: std::collections::BTreeSet<String>,
}
impl Context {
pub const fn at(now: Date) -> Self {
Context {
now,
change: None,
orphaned: std::collections::BTreeSet::new(),
}
}
pub fn over(now: Date, change: Change) -> Self {
Context {
now,
change: Some(change),
orphaned: std::collections::BTreeSet::new(),
}
}
pub fn change(&self) -> Option<&Change> {
self.change.as_ref()
}
pub fn from_system_clock() -> Option<Self> {
let elapsed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
Some(Context {
now: Date::from_days(i64::try_from(elapsed.as_secs() / 86_400).ok()?),
change: None,
orphaned: std::collections::BTreeSet::new(),
})
}
pub fn scoped_to(self, change: Change) -> Self {
Context {
change: Some(change),
..self
}
}
pub fn with_orphaned(self, orphaned: std::collections::BTreeSet<String>) -> Self {
Context { orphaned, ..self }
}
pub fn orphaned(&self) -> &std::collections::BTreeSet<String> {
&self.orphaned
}
pub fn now(&self) -> Date {
self.now
}
}
impl Date {
pub fn parse(text: &str) -> Option<Self> {
let text = text.trim();
let bytes = text.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
let year: i64 = text[0..4].parse().ok()?;
let month: u32 = text[5..7].parse().ok()?;
let day: u32 = text[8..10].parse().ok()?;
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return None;
}
Some(Date {
days: days_from_civil(year, month, day),
})
}
pub const fn from_days(days: i64) -> Self {
Date { days }
}
pub const fn plus_days(&self, days: i64) -> Self {
Date {
days: self.days + days,
}
}
pub fn days_since(&self, origin: Date) -> i64 {
self.days - origin.days
}
pub fn render(&self) -> String {
let (year, month, day) = civil_from_days(self.days);
format!("{year:04}-{month:02}-{day:02}")
}
}
impl std::fmt::Display for Date {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}
fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let month = i64::from(month);
let day = i64::from(day);
let year = year - i64::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let days = days + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let mp = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
(
year + i64::from(month <= 2),
month as u32,
u32::try_from(day).unwrap_or(1),
)
}
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => match (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
true => 29,
false => 28,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_date_round_trips_through_the_day_count() {
for text in [
"1970-01-01",
"2000-02-29",
"2026-08-12",
"1900-03-01",
"2100-12-31",
] {
assert_eq!(Date::parse(text).expect(text).render(), text);
}
}
#[test]
fn the_epoch_is_day_zero_and_the_arithmetic_is_whole_days() {
let epoch = Date::parse("1970-01-01").expect("the epoch");
assert_eq!(Date::parse("1970-01-02").expect("d").days_since(epoch), 1);
assert_eq!(
Date::parse("2026-08-12")
.expect("d")
.days_since(Date::parse("2026-07-13").expect("d")),
30
);
assert_eq!(epoch.days_since(Date::parse("1970-01-31").expect("d")), -30);
}
#[test]
fn a_value_that_is_not_a_date_reads_as_nothing() {
for text in [
"",
"2026-8-12",
"2026/08/12",
"2026-13-01",
"2026-02-30",
"2025-02-29",
"2026-08-12T09:00:00Z",
"yesterday",
] {
assert_eq!(Date::parse(text), None, "{text} read as a date");
}
}
}