Files
nuwiki/crates/nuwiki-core/src/date.rs
T

167 lines
5.1 KiB
Rust
Raw Normal View History

//! 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,
}
}