use crate::sink::Sink;
use crate::text::write_u64;
const MAX_YEAR: i32 = 999_999;
const DAY_MS: i64 = 86_400_000;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Date {
year: i32,
month: u8,
day: u8,
}
const fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
const fn month_days(year: i32, month: u8) -> u8 {
match month {
2 if is_leap(year) => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
impl Date {
pub const fn new(year: i32, month: u8, day: u8) -> Option<Date> {
if year < -MAX_YEAR || year > MAX_YEAR || month < 1 || month > 12 {
return None;
}
if day < 1 || day > month_days(year, month) {
return None;
}
Some(Date { year, month, day })
}
pub const fn year(self) -> i32 {
self.year
}
pub const fn month(self) -> u8 {
self.month
}
pub const fn day(self) -> u8 {
self.day
}
pub const fn days_since_epoch(self) -> i64 {
let m = self.month as i64;
let y = self.year as i64 - if m <= 2 { 1 } else { 0 };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + self.day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub const fn from_days_since_epoch(days: i64) -> Option<Date> {
if days < -400_000_000 || days > 400_000_000 {
return None;
}
let z = days + 719_468;
let era = (if z >= 0 { z } else { z - 146_096 }) / 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;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 };
if year < -(MAX_YEAR as i64) || year > MAX_YEAR as i64 {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let (year, month, day) = (year as i32, month as u8, day as u8);
Date::new(year, month, day)
}
pub const fn weekday(self) -> u8 {
let w = (self.days_since_epoch() + 3).rem_euclid(7);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let w = w as u8;
w + 1
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Time {
hour: u8,
minute: u8,
second: u8,
millisecond: u16,
}
impl Time {
pub const MIDNIGHT: Time = Time {
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
};
pub const fn new(hour: u8, minute: u8, second: u8, millisecond: u16) -> Option<Time> {
if hour > 23 || minute > 59 || second > 59 || millisecond > 999 {
return None;
}
Some(Time {
hour,
minute,
second,
millisecond,
})
}
pub const fn hour(self) -> u8 {
self.hour
}
pub const fn minute(self) -> u8 {
self.minute
}
pub const fn second(self) -> u8 {
self.second
}
pub const fn millisecond(self) -> u16 {
self.millisecond
}
pub const fn ms_of_day(self) -> i64 {
((self.hour as i64 * 60 + self.minute as i64) * 60 + self.second as i64) * 1000
+ self.millisecond as i64
}
pub const fn from_ms_of_day(ms: i64) -> Option<Time> {
if ms < 0 || ms >= DAY_MS {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let (h, m, s, milli) = (
(ms / 3_600_000) as u8,
(ms / 60_000 % 60) as u8,
(ms / 1000 % 60) as u8,
(ms % 1000) as u16,
);
Time::new(h, m, s, milli)
}
}
const MAX_OFFSET: i32 = 86_400;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub struct DateTime<'a> {
pub date: Date,
pub time: Time,
pub offset: Option<i32>,
pub zone: Option<&'a str>,
pub options: DateTimeOptions<'a>,
}
impl<'a> DateTime<'a> {
pub const fn floating(date: Date, time: Time) -> DateTime<'a> {
DateTime {
date,
time,
offset: None,
zone: None,
options: DateTimeOptions::NONE,
}
}
pub const fn from_epoch_ms(ms: i64) -> Option<DateTime<'a>> {
let Some(date) = Date::from_days_since_epoch(ms.div_euclid(DAY_MS)) else {
return None;
};
let Some(time) = Time::from_ms_of_day(ms.rem_euclid(DAY_MS)) else {
return None;
};
Some(DateTime {
date,
time,
offset: Some(0),
zone: None,
options: DateTimeOptions::NONE,
})
}
pub const fn to_epoch_ms(&self) -> Option<i64> {
match self.offset {
Some(o) => Some(
self.date.days_since_epoch() * DAY_MS + self.time.ms_of_day() - o as i64 * 1000,
),
None => None,
}
}
pub const fn with_offset(self, seconds: i32) -> Option<Self> {
if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
return None;
}
Some(DateTime {
offset: Some(seconds),
..self
})
}
#[must_use]
pub const fn in_zone(self, zone: &'a str) -> Self {
DateTime {
zone: Some(zone),
..self
}
}
pub fn write_iso(&self, out: &mut dyn Sink) {
let y = self.date.year;
if (0..=9999).contains(&y) {
write_padded(y.unsigned_abs(), 4, out);
} else {
out.push_str(if y < 0 { "-" } else { "+" });
write_padded(y.unsigned_abs(), 6, out);
}
out.push_str("-");
write_padded(u32::from(self.date.month), 2, out);
out.push_str("-");
write_padded(u32::from(self.date.day), 2, out);
out.push_str("T");
write_padded(u32::from(self.time.hour), 2, out);
out.push_str(":");
write_padded(u32::from(self.time.minute), 2, out);
out.push_str(":");
write_padded(u32::from(self.time.second), 2, out);
if self.time.millisecond != 0 {
out.push_str(".");
write_padded(u32::from(self.time.millisecond), 3, out);
}
match self.offset {
Some(0) => out.push_str("Z"),
Some(o) => write_offset(o, out),
None => {}
}
if let Some(z) = self.zone {
out.push_str("[");
out.push_str(z);
out.push_str("]");
}
}
}
fn write_padded(n: u32, width: u32, out: &mut dyn Sink) {
let mut digits = 1;
let mut m = n;
while m >= 10 {
m /= 10;
digits += 1;
}
while digits < width {
out.push_str("0");
digits += 1;
}
write_u64(u64::from(n), out);
}
pub(crate) fn write_offset(seconds: i32, out: &mut dyn Sink) {
out.push_str(if seconds < 0 { "-" } else { "+" });
let s = seconds.unsigned_abs();
write_padded(s / 3600, 2, out);
out.push_str(":");
write_padded(s / 60 % 60, 2, out);
if !s.is_multiple_of(60) {
out.push_str(":");
write_padded(s % 60, 2, out);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
#[non_exhaustive]
pub struct DateTimeOptions<'a> {
pub date: Option<DateStyle>,
pub time: Option<TimePrecision>,
pub time_zone_style: Option<ZoneStyle>,
pub time_zone: Option<ZoneOption<'a>>,
pub hour12: Option<bool>,
pub calendar: Option<&'a str>,
}
impl DateTimeOptions<'_> {
pub const NONE: DateTimeOptions<'static> = DateTimeOptions {
date: None,
time: None,
time_zone_style: None,
time_zone: None,
hour12: None,
calendar: None,
};
pub const fn is_resolved(&self) -> bool {
self.date.is_some() || self.time.is_some()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct DateStyle {
pub fields: DateFields,
pub length: DateLength,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum DateFields {
Weekday,
DayWeekday,
MonthDay,
MonthDayWeekday,
YearMonthDay,
YearMonthDayWeekday,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum DateLength {
Long,
Medium,
Short,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum TimePrecision {
Hour,
Minute,
Second,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum ZoneStyle {
Long,
Short,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
#[non_exhaustive]
pub enum ZoneOption<'a> {
Input,
Utc,
Offset(i32),
Named(&'a str),
}
const MAX_ZONE_NAME: usize = 64;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct TimeZone {
repr: Repr,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Repr {
Utc,
Offset(i32),
Named { len: u8, name: [u8; MAX_ZONE_NAME] },
}
impl TimeZone {
pub const UTC: TimeZone = TimeZone { repr: Repr::Utc };
pub const fn offset(seconds: i32) -> Option<TimeZone> {
if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
return None;
}
Some(TimeZone {
repr: Repr::Offset(seconds),
})
}
pub fn named(name: &str) -> Option<TimeZone> {
if !is_zone_name(name) || name.len() > MAX_ZONE_NAME {
return None;
}
let mut buf = [0u8; MAX_ZONE_NAME];
for (b, &c) in buf.iter_mut().zip(name.as_bytes()) {
*b = c;
}
Some(TimeZone {
repr: Repr::Named {
len: u8::try_from(name.len()).ok()?,
name: buf,
},
})
}
pub fn as_option(&self) -> ZoneOption<'_> {
match &self.repr {
Repr::Utc => ZoneOption::Utc,
Repr::Offset(s) => ZoneOption::Offset(*s),
Repr::Named { len, name } => ZoneOption::Named(
name.get(..usize::from(*len))
.and_then(|b| core::str::from_utf8(b).ok())
.unwrap_or(""),
),
}
}
}
impl core::fmt::Debug for TimeZone {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self.as_option() {
ZoneOption::Named(n) => f.debug_tuple("TimeZone").field(&n).finish(),
other => f.debug_tuple("TimeZone").field(&other).finish(),
}
}
}
impl Default for TimeZone {
fn default() -> Self {
TimeZone::UTC
}
}
pub fn is_zone_name(s: &str) -> bool {
let b = s.as_bytes();
if b.is_empty() {
return false;
}
let mut start = 0;
let mut i = 0;
while i <= b.len() {
let end = i == b.len();
if end || b.get(i) == Some(&b'/') {
let part = b.get(start..i).unwrap_or(&[]);
if !zone_part(part) {
return false;
}
start = i + 1;
}
i += 1;
}
true
}
fn zone_part(p: &[u8]) -> bool {
let initial = |c: u8| c.is_ascii_alphabetic() || c == b'.' || c == b'_';
match p {
[] | [b'.'] | [b'.', b'.'] => false,
[first, rest @ ..] => {
initial(*first)
&& rest
.iter()
.all(|&c| initial(c) || c.is_ascii_digit() || c == b'-' || c == b'+')
}
}
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct DateTimeRequest<'r> {
pub epoch_ms: i64,
pub zone: ZoneOption<'r>,
pub options: &'r DateTimeOptions<'r>,
}
impl<'r> DateTimeRequest<'r> {
pub const fn new(
epoch_ms: i64,
zone: ZoneOption<'r>,
options: &'r DateTimeOptions<'r>,
) -> DateTimeRequest<'r> {
DateTimeRequest {
epoch_ms,
zone,
options,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use alloc::string::String;
#[test]
fn civil_days_round_trip() {
for days in [-719_468i64, -1, 0, 1, 10_957, 13_150, 2_932_896, -2_932_897] {
let d = Date::from_days_since_epoch(days).unwrap();
assert_eq!(d.days_since_epoch(), days, "{d:?}");
}
let d = Date::new(2006, 1, 2).unwrap();
assert_eq!(d.days_since_epoch(), 13_150);
assert_eq!(d.weekday(), 1); assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), 4);
assert!(Date::new(2023, 2, 29).is_none());
assert!(Date::new(2024, 2, 29).is_some());
assert!(Date::new(1900, 2, 29).is_none());
assert!(Date::new(2000, 2, 29).is_some());
assert!(Date::new(1_000_000, 1, 1).is_none());
}
#[test]
fn epoch_round_trip() {
for ms in [
0i64,
1,
-1,
1_136_214_246_000,
-62_135_596_800_000,
253_402_300_799_999,
] {
let dt = DateTime::from_epoch_ms(ms).unwrap();
assert_eq!(dt.to_epoch_ms(), Some(ms));
}
let dt = DateTime::from_epoch_ms(1_136_214_246_000).unwrap();
let mut s = String::new();
dt.write_iso(&mut s);
assert_eq!(s, "2006-01-02T15:04:06Z");
let dt = dt.with_offset(-7 * 3600).unwrap().in_zone("America/Denver");
s.clear();
dt.write_iso(&mut s);
assert_eq!(s, "2006-01-02T15:04:06-07:00[America/Denver]");
assert_eq!(dt.to_epoch_ms(), Some(1_136_214_246_000 + 7 * 3_600_000));
assert!(DateTime::from_epoch_ms(i64::MAX).is_none());
assert!(dt.with_offset(86_400).is_none());
}
#[test]
fn iso_text() {
let mut s = String::new();
let dt = DateTime::floating(
Date::new(-44, 3, 15).unwrap(),
Time::new(9, 5, 0, 7).unwrap(),
);
dt.write_iso(&mut s);
assert_eq!(s, "-000044-03-15T09:05:00.007");
}
#[test]
fn zones() {
for good in [
"UTC",
"America/New_York",
"Etc/GMT+5",
"America/Argentina/ComodRivadavia",
"._x",
] {
assert!(is_zone_name(good), "{good}");
let z = TimeZone::named(good).unwrap();
assert_eq!(z.as_option(), ZoneOption::Named(good));
}
for bad in ["", "/", "a/", "/a", "a//b", ".", "a/..", "1a", "a b", "é"] {
assert!(!is_zone_name(bad), "{bad}");
assert!(TimeZone::named(bad).is_none());
}
assert_eq!(TimeZone::default().as_option(), ZoneOption::Utc);
assert_eq!(
TimeZone::offset(3600).unwrap().as_option(),
ZoneOption::Offset(3600)
);
assert!(TimeZone::offset(-86_400).is_none());
}
}