167 lines
5.1 KiB
Rust
167 lines
5.1 KiB
Rust
|
|
//! Date primitives for the diary subsystem (Phase 16).
|
||
|
|
//!
|
||
|
|
//! v1.1 deliberately doesn't pull in `chrono` or `time`. The diary feature
|
||
|
|
//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which
|
||
|
|
//! is small enough that the dependency cost outweighs the convenience.
|
||
|
|
//!
|
||
|
|
//! Date math uses Howard Hinnant's days-from-civil algorithm
|
||
|
|
//! (<https://howardhinnant.github.io/date_algorithms.html>) — proleptic
|
||
|
|
//! Gregorian, valid for any year representable in `i32`.
|
||
|
|
|
||
|
|
use std::cmp::Ordering;
|
||
|
|
use std::fmt;
|
||
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
|
|
||
|
|
/// A calendar day, decoupled from any time zone or wall-clock context.
|
||
|
|
///
|
||
|
|
/// Validation: `parse` and `from_ymd` reject impossible dates (month 0,
|
||
|
|
/// day 0, day > days-in-month, etc.). The struct fields are public so
|
||
|
|
/// callers can read them, but constructing one with bogus values bypasses
|
||
|
|
/// validation — use the constructors.
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
|
|
pub struct DiaryDate {
|
||
|
|
pub year: i32,
|
||
|
|
pub month: u8,
|
||
|
|
pub day: u8,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl DiaryDate {
|
||
|
|
/// Build a date from its components, validating the calendar.
|
||
|
|
pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
|
||
|
|
if !(1..=12).contains(&month) {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
if day < 1 || day > days_in_month(year, month) {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
Some(Self { year, month, day })
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra
|
||
|
|
/// whitespace, missing zero-padding, alternate separators, etc.
|
||
|
|
pub fn parse(s: &str) -> Option<Self> {
|
||
|
|
let b = s.as_bytes();
|
||
|
|
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let y = parse_digits(&b[0..4])?;
|
||
|
|
let m = parse_digits(&b[5..7])? as u8;
|
||
|
|
let d = parse_digits(&b[8..10])? as u8;
|
||
|
|
Self::from_ymd(y as i32, m, d)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render
|
||
|
|
/// numerically; the diary use case never produces those.
|
||
|
|
pub fn format(&self) -> String {
|
||
|
|
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// UTC "today" computed from `SystemTime::now()`. Phase 16's diary
|
||
|
|
/// commands intentionally use UTC — local-time semantics depend on a
|
||
|
|
/// timezone DB we don't ship and would surprise users crossing DST
|
||
|
|
/// boundaries.
|
||
|
|
pub fn today_utc() -> Self {
|
||
|
|
let secs = SystemTime::now()
|
||
|
|
.duration_since(UNIX_EPOCH)
|
||
|
|
.map(|d| d.as_secs())
|
||
|
|
.unwrap_or(0);
|
||
|
|
let days = (secs / 86_400) as i64;
|
||
|
|
from_days(days)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn next_day(&self) -> Self {
|
||
|
|
from_days(to_days(self.year, self.month, self.day) + 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn prev_day(&self) -> Self {
|
||
|
|
from_days(to_days(self.year, self.month, self.day) - 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Internal — exposed for tests that want to verify ordering against
|
||
|
|
/// the days-from-epoch representation.
|
||
|
|
pub fn to_days_epoch(&self) -> i64 {
|
||
|
|
to_days(self.year, self.month, self.day)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl PartialOrd for DiaryDate {
|
||
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||
|
|
Some(self.cmp(other))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Ord for DiaryDate {
|
||
|
|
fn cmp(&self, other: &Self) -> Ordering {
|
||
|
|
self.to_days_epoch().cmp(&other.to_days_epoch())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl fmt::Display for DiaryDate {
|
||
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
|
write!(f, "{}", self.format())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn parse_digits(b: &[u8]) -> Option<u32> {
|
||
|
|
if b.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let mut n: u32 = 0;
|
||
|
|
for ch in b {
|
||
|
|
if !ch.is_ascii_digit() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?;
|
||
|
|
}
|
||
|
|
Some(n)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_leap(year: i32) -> bool {
|
||
|
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn days_in_month(year: i32, month: u8) -> u8 {
|
||
|
|
match month {
|
||
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||
|
|
4 | 6 | 9 | 11 => 30,
|
||
|
|
2 => {
|
||
|
|
if is_leap(year) {
|
||
|
|
29
|
||
|
|
} else {
|
||
|
|
28
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => 0,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0.
|
||
|
|
fn to_days(year: i32, month: u8, day: u8) -> i64 {
|
||
|
|
let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 };
|
||
|
|
let era = y.div_euclid(400);
|
||
|
|
let yoe = y.rem_euclid(400);
|
||
|
|
let m = month as i64;
|
||
|
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
|
||
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||
|
|
era * 146_097 + doe - 719_468
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Inverse of [`to_days`].
|
||
|
|
fn from_days(days: i64) -> DiaryDate {
|
||
|
|
let z = days + 719_468;
|
||
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||
|
|
let doe = (z - era * 146_097) as u64;
|
||
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||
|
|
let y = yoe as i64 + 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 u8;
|
||
|
|
let m_int = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
|
|
let year = y + if m_int <= 2 { 1 } else { 0 };
|
||
|
|
DiaryDate {
|
||
|
|
year: year as i32,
|
||
|
|
month: m_int as u8,
|
||
|
|
day: d,
|
||
|
|
}
|
||
|
|
}
|