use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
pub use crate::zone::{Rules, Zone};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
pub year: i32,
pub month: u32,
pub day: u32,
}
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
fn unix_now() -> i64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(since) => since.as_secs() as i64,
Err(before) => -(before.duration().as_secs() as i64),
}
}
impl Date {
pub const fn new(year: i32, month: u32, day: u32) -> Date {
Date { year, month, day }
}
pub const fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
pub const fn days_in_month(year: i32, month: u32) -> u32 {
match month {
2 if Date::is_leap_year(year) => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
pub const fn is_valid(self) -> bool {
self.month >= 1
&& self.month <= 12
&& self.day >= 1
&& self.day <= Date::days_in_month(self.year, self.month)
}
pub fn today_utc() -> Date {
Date::from_days(unix_now().div_euclid(86_400))
}
pub fn today_local() -> Date {
DateTime::now_local().date
}
pub fn today_in(zone: &Zone) -> Date {
DateTime::now_in(zone).date
}
pub fn days(self) -> i64 {
let year = self.year as i64 - (self.month <= 2) as i64;
let era = year.div_euclid(400);
let yoe = year - era * 400;
let month = self.month as i64;
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub fn from_days(days: i64) -> Date {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = (yoe + era * 400 + (month <= 2) as i64) as i32;
Date { year, month, day }
}
pub fn weekday(self) -> u32 {
(self.days() + 3).rem_euclid(7) as u32
}
#[must_use]
pub fn add_days(self, days: i64) -> Date {
Date::from_days(self.days() + days)
}
#[must_use]
pub fn add_months(self, months: i32) -> Date {
let index = self.year as i64 * 12 + self.month as i64 - 1 + months as i64;
let year = index.div_euclid(12) as i32;
let month = index.rem_euclid(12) as u32 + 1;
let day = self.day.min(Date::days_in_month(year, month));
Date { year, month, day }
}
#[must_use]
pub fn first_of_month(self) -> Date {
Date { day: 1, ..self }
}
#[must_use]
pub fn last_of_month(self) -> Date {
Date {
day: Date::days_in_month(self.year, self.month),
..self
}
}
pub fn month_name(self) -> &'static str {
MONTHS[(self.month.clamp(1, 12) - 1) as usize]
}
pub const fn at(self, time: Time) -> DateTime {
DateTime { date: self, time }
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
impl FromStr for Date {
type Err = String;
fn from_str(text: &str) -> Result<Date, String> {
let text = text.trim();
if text.eq_ignore_ascii_case("today") {
return Ok(Date::today_utc());
}
let bad = || format!("{text:?} is not a date (YYYY-MM-DD)");
let mut parts = text.split('-');
let (Some(year), Some(month), Some(day), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(bad());
};
let number = |part: &str, most: usize| {
(!part.is_empty() && part.len() <= most && part.bytes().all(|b| b.is_ascii_digit()))
.then(|| part.parse::<u32>().ok())
.flatten()
};
let year = (year.len() == 4).then(|| number(year, 4)).flatten();
let (Some(year @ 1..), Some(month), Some(day)) = (year, number(month, 2), number(day, 2))
else {
return Err(bad());
};
let date = Date::new(year as i32, month, day);
if !date.is_valid() {
return Err(format!("{text:?} is not a day of the calendar"));
}
Ok(date)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Time {
pub hour: u32,
pub minute: u32,
pub second: u32,
}
impl Time {
pub const MIDNIGHT: Time = Time::new(0, 0, 0);
pub const LAST: Time = Time::new(23, 59, 59);
pub const fn new(hour: u32, minute: u32, second: u32) -> Time {
Time {
hour,
minute,
second,
}
}
pub const fn is_valid(self) -> bool {
self.hour < 24 && self.minute < 60 && self.second < 60
}
pub const fn seconds(self) -> u32 {
self.hour * 3600 + self.minute * 60 + self.second
}
pub const fn from_seconds(seconds: i64) -> Time {
let seconds = seconds.rem_euclid(86_400) as u32;
Time::new(seconds / 3600, seconds / 60 % 60, seconds % 60)
}
pub fn now_utc() -> Time {
Time::from_seconds(unix_now())
}
pub fn now_local() -> Time {
DateTime::now_local().time
}
pub const fn twelve_hour(self) -> (u32, bool) {
let pm = self.hour >= 12;
let hour = match self.hour % 12 {
0 => 12,
hour => hour,
};
(hour, pm)
}
pub fn format(self, seconds: bool, twelve_hour: bool) -> String {
let tail = if seconds {
format!(":{:02}", self.second)
} else {
String::new()
};
if twelve_hour {
let (hour, pm) = self.twelve_hour();
let half = if pm { "PM" } else { "AM" };
format!("{hour}:{:02}{tail} {half}", self.minute)
} else {
format!("{:02}:{:02}{tail}", self.hour, self.minute)
}
}
}
impl fmt::Display for Time {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.format(self.second != 0, false))
}
}
impl FromStr for Time {
type Err = String;
fn from_str(text: &str) -> Result<Time, String> {
let text = text.trim();
if text.eq_ignore_ascii_case("now") {
return Ok(Time::now_utc());
}
let bad = || format!("{text:?} is not a time (HH:MM)");
let lower = text.to_ascii_lowercase();
let (clock, half) = if let Some(rest) = lower.strip_suffix("am") {
(rest.trim_end().trim_end_matches('.'), Some(false))
} else if let Some(rest) = lower.strip_suffix("pm") {
(rest.trim_end().trim_end_matches('.'), Some(true))
} else {
(lower.as_str(), None)
};
let parts: Vec<&str> = clock.split(':').collect();
if parts.len() > 3 || (parts.len() == 1 && half.is_none()) {
return Err(bad());
}
let mut numbers = [0u32; 3];
for (slot, part) in numbers.iter_mut().zip(&parts) {
if part.is_empty() || part.len() > 2 || !part.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad());
}
*slot = part.parse().map_err(|_| bad())?;
}
let [mut hour, minute, second] = numbers;
if let Some(pm) = half {
if !(1..=12).contains(&hour) {
return Err(format!("{text:?}: a 12-hour clock goes from 1 to 12"));
}
hour = hour % 12 + if pm { 12 } else { 0 };
}
let time = Time::new(hour, minute, second);
if !time.is_valid() {
return Err(format!("{text:?} is not a time of day"));
}
Ok(time)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DateTime {
pub date: Date,
pub time: Time,
}
impl DateTime {
pub const fn new(date: Date, time: Time) -> DateTime {
DateTime { date, time }
}
pub fn now_utc() -> DateTime {
let now = unix_now();
DateTime::new(
Date::from_days(now.div_euclid(86_400)),
Time::from_seconds(now),
)
}
pub fn now_local() -> DateTime {
DateTime::now_in(&Zone::Local)
}
pub fn now_in(zone: &Zone) -> DateTime {
DateTime::from_unix(unix_now(), zone)
}
pub fn from_unix(unix: i64, zone: &Zone) -> DateTime {
let local = unix + zone.offset_at(unix) as i64;
DateTime::new(
Date::from_days(local.div_euclid(86_400)),
Time::from_seconds(local),
)
}
pub fn to_unix(self, zone: &Zone) -> i64 {
let wall = self.date.days() * 86_400 + self.time.seconds() as i64;
let around = [
zone.offset_at(wall - 172_800),
zone.offset_at(wall),
zone.offset_at(wall + 172_800),
];
around
.iter()
.map(|&offset| wall - offset as i64)
.filter(|&unix| (unix + zone.offset_at(unix) as i64) == wall)
.min()
.unwrap_or(wall - around[0] as i64)
}
#[must_use]
pub fn add_seconds(self, seconds: i64) -> DateTime {
DateTime::from_unix(self.to_unix(&Zone::Utc) + seconds, &Zone::Utc)
}
pub const fn is_valid(self) -> bool {
self.date.is_valid() && self.time.is_valid()
}
pub fn format(self, seconds: bool, twelve_hour: bool) -> String {
format!("{} {}", self.date, self.time.format(seconds, twelve_hour))
}
}
impl fmt::Display for DateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.date, self.time)
}
}
impl FromStr for DateTime {
type Err = String;
fn from_str(text: &str) -> Result<DateTime, String> {
let text = text.trim();
if text.eq_ignore_ascii_case("now") {
return Ok(DateTime::now_utc());
}
let split = text
.char_indices()
.skip(1)
.find(|&(at, c)| {
c == ' ' || (matches!(c, 'T' | 't') && text.as_bytes()[at - 1].is_ascii_digit())
})
.map(|(at, _)| (&text[..at], &text[at + 1..]));
match split {
Some((date, time)) => Ok(DateTime::new(date.parse()?, time.parse()?)),
None => Ok(DateTime::new(text.parse()?, Time::MIDNIGHT)),
}
}
}
impl From<Date> for DateTime {
fn from(date: Date) -> DateTime {
DateTime::new(date, Time::MIDNIGHT)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leap_years_and_month_lengths() {
assert!(Date::is_leap_year(2000));
assert!(Date::is_leap_year(2024));
assert!(!Date::is_leap_year(1900));
assert!(!Date::is_leap_year(2026));
assert_eq!(Date::days_in_month(2024, 2), 29);
assert_eq!(Date::days_in_month(2026, 2), 28);
assert_eq!(Date::days_in_month(2026, 4), 30);
assert_eq!(Date::days_in_month(2026, 12), 31);
}
#[test]
fn weekdays_of_known_dates() {
assert_eq!(Date::new(2026, 10, 7).weekday(), 2);
assert_eq!(Date::new(2000, 2, 29).weekday(), 1);
assert_eq!(Date::new(1970, 1, 1).weekday(), 3);
assert_eq!(Date::new(1969, 12, 31).weekday(), 2);
assert_eq!(Date::new(2026, 10, 1).weekday(), 3);
}
#[test]
fn days_round_trip() {
assert_eq!(Date::new(1970, 1, 1).days(), 0);
assert_eq!(Date::new(2000, 3, 1).days(), 11_017);
assert_eq!(Date::new(1969, 12, 31).days(), -1);
for days in -800_000..800_000 {
if days % 997 == 0 {
assert_eq!(Date::from_days(days).days(), days);
}
}
assert_eq!(Date::new(2000, 2, 28).add_days(1), Date::new(2000, 2, 29));
assert_eq!(Date::new(2026, 12, 31).add_days(1), Date::new(2027, 1, 1));
assert_eq!(Date::new(2026, 3, 1).add_days(-1), Date::new(2026, 2, 28));
}
#[test]
fn months_keep_the_day_where_they_can() {
assert_eq!(Date::new(2026, 1, 31).add_months(1), Date::new(2026, 2, 28));
assert_eq!(Date::new(2024, 1, 31).add_months(1), Date::new(2024, 2, 29));
assert_eq!(
Date::new(2026, 1, 15).add_months(-1),
Date::new(2025, 12, 15)
);
assert_eq!(
Date::new(2026, 10, 7).add_months(14),
Date::new(2027, 12, 7)
);
assert_eq!(Date::new(2026, 10, 7).to_string(), "2026-10-07");
}
#[test]
fn dates_parse_and_refuse() {
assert_eq!("2026-10-17".parse(), Ok(Date::new(2026, 10, 17)));
assert_eq!(" 2026-1-5 ".parse(), Ok(Date::new(2026, 1, 5)));
assert_eq!("2024-02-29".parse(), Ok(Date::new(2024, 2, 29)));
for bad in [
"",
"2026",
"2026-10",
"2026-13-01",
"2026-02-29",
"2026-10-32",
"2026-10-0",
"26-10-17x",
"2026/10/17",
"2026-10-17-1",
"+2026-10-17",
] {
assert!(bad.parse::<Date>().is_err(), "{bad:?}");
}
assert!("today".parse::<Date>().unwrap().is_valid());
}
#[test]
fn times_parse_on_either_clock() {
assert_eq!("14:30".parse(), Ok(Time::new(14, 30, 0)));
assert_eq!("9:05:07".parse(), Ok(Time::new(9, 5, 7)));
assert_eq!("2:30 pm".parse(), Ok(Time::new(14, 30, 0)));
assert_eq!("2PM".parse(), Ok(Time::new(14, 0, 0)));
assert_eq!("12:05am".parse(), Ok(Time::new(0, 5, 0)));
assert!("12 p.m.".parse::<Time>().is_err());
assert_eq!("12pm".parse(), Ok(Time::new(12, 0, 0)));
for bad in [
"", "14", "24:00", "12:60", "1:2:3:4", "13pm", "0am", "a:b", "-1:00",
] {
assert!(bad.parse::<Time>().is_err(), "{bad:?}");
}
assert_eq!(Time::new(14, 30, 0).to_string(), "14:30");
assert_eq!(Time::new(14, 30, 5).to_string(), "14:30:05");
assert_eq!(Time::new(0, 5, 0).format(false, true), "12:05 AM");
assert_eq!(Time::new(12, 5, 9).format(true, true), "12:05:09 PM");
assert_eq!(Time::from_seconds(-1), Time::LAST);
}
#[test]
fn date_times_parse_and_order() {
let at = DateTime::new(Date::new(2026, 10, 17), Time::new(14, 30, 0));
assert_eq!("2026-10-17 14:30".parse(), Ok(at));
assert_eq!("2026-10-17T14:30".parse(), Ok(at));
assert_eq!("2026-10-17 2:30 pm".parse(), Ok(at));
assert_eq!(
"2026-10-17".parse(),
Ok(DateTime::from(Date::new(2026, 10, 17)))
);
assert!("2026-10-17 25:00".parse::<DateTime>().is_err());
assert!("2026-10-17 x".parse::<DateTime>().is_err());
assert!("today 14:30".parse::<DateTime>().is_ok());
assert!(at < DateTime::new(Date::new(2026, 10, 17), Time::new(14, 30, 1)));
assert!(at > DateTime::new(Date::new(2026, 10, 16), Time::LAST));
assert_eq!(at.to_string(), "2026-10-17 14:30");
assert!(DateTime::now_utc().is_valid());
}
#[test]
fn moments_round_trip_across_a_change_of_summer_time() {
let cet = Zone::posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
let utc = |y, m, d, h, min| DateTime::new(Date::new(y, m, d), Time::new(h, min, 0));
let before = utc(2026, 3, 29, 0, 30).to_unix(&Zone::Utc);
let after = utc(2026, 3, 29, 1, 30).to_unix(&Zone::Utc);
assert_eq!(DateTime::from_unix(before, &cet), utc(2026, 3, 29, 1, 30));
assert_eq!(DateTime::from_unix(after, &cet), utc(2026, 3, 29, 3, 30));
assert_eq!(utc(2026, 3, 29, 3, 30).to_unix(&cet), after);
assert_eq!(utc(2026, 3, 29, 2, 30).to_unix(&cet), after);
let first = utc(2026, 10, 25, 0, 30).to_unix(&Zone::Utc);
let second = utc(2026, 10, 25, 1, 30).to_unix(&Zone::Utc);
assert_eq!(DateTime::from_unix(first, &cet), utc(2026, 10, 25, 2, 30));
assert_eq!(DateTime::from_unix(second, &cet), utc(2026, 10, 25, 2, 30));
assert_eq!(utc(2026, 10, 25, 2, 30).to_unix(&cet), first);
let day = utc(2026, 10, 26, 0, 0).to_unix(&cet) - utc(2026, 10, 25, 0, 0).to_unix(&cet);
assert_eq!(day, 25 * 3600);
}
#[test]
fn the_local_zone_follows_summer_time() {
if std::env::var_os("RICH_INTERACT_TZ_CHILD").is_none() {
let child = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"datetime::tests::the_local_zone_follows_summer_time",
"--quiet",
])
.env("RICH_INTERACT_TZ_CHILD", "1")
.env("TZ", "CET-1CEST,M3.5.0,M10.5.0/3")
.output()
.unwrap();
let said = String::from_utf8_lossy(&child.stdout);
assert!(child.status.success(), "{said}");
return;
}
let utc = |m, d, h| DateTime::new(Date::new(2026, m, d), Time::new(h, 0, 0));
let winter = utc(1, 15, 12).to_unix(&Zone::Utc);
let summer = utc(7, 15, 12).to_unix(&Zone::Utc);
assert_eq!(Zone::Local.offset_at(winter), 3600);
assert_eq!(Zone::Local.offset_at(summer), 7200);
assert_eq!(DateTime::from_unix(summer, &Zone::Local), utc(7, 15, 14));
assert_eq!(
utc(10, 25, 2).to_unix(&Zone::Local),
utc(10, 25, 0).to_unix(&Zone::Utc)
);
}
}