use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Date {
year: u16,
month: u8,
day: u8,
}
impl Date {
pub fn new(year: u16, month: u8, day: u8) -> Result<Self, DateTimeError> {
if year > 9999 {
return Err(DateTimeError::OutOfRange("year"));
}
if !(1..=12).contains(&month) {
return Err(DateTimeError::OutOfRange("month"));
}
if day < 1 || day > days_in_month(year, month) {
return Err(DateTimeError::OutOfRange("day"));
}
Ok(Self { year, month, day })
}
pub fn parse(s: &str) -> Result<Self, DateTimeError> {
let &[y0, y1, y2, y3, b'-', m0, m1, b'-', d0, d1] = s.as_bytes() else {
return Err(DateTimeError::Malformed);
};
Self::new(
parse_digits(&[y0, y1, y2, y3])? as u16,
parse_digits(&[m0, m1])? as u8,
parse_digits(&[d0, d1])? as u8,
)
}
pub fn year(&self) -> u16 {
self.year
}
pub fn month(&self) -> u8 {
self.month
}
pub fn day(&self) -> u8 {
self.day
}
}
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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DateTime {
pub date: Date,
pub hour: u8,
pub minute: u8,
pub second: u8,
pub nanosecond: u32,
pub offset_minutes: Option<i16>,
}
impl DateTime {
pub fn parse_strict(s: &str) -> Result<Self, DateTimeError> {
if !matches!(
s.as_bytes(),
[
_,
_,
_,
_,
_,
_,
_,
_,
_,
_,
b'T',
_,
_,
_,
_,
_,
_,
_,
_,
b'.',
_,
_,
_,
b'Z'
]
) {
return Err(DateTimeError::Malformed);
}
let dt = Self::parse_lenient(s)?;
debug_assert_eq!(dt.offset_minutes, Some(0));
Ok(dt)
}
pub fn parse_lenient(s: &str) -> Result<Self, DateTimeError> {
let &[
y0,
y1,
y2,
y3,
b'-',
mo0,
mo1,
b'-',
d0,
d1,
b'T' | b' ',
h0,
h1,
b':',
mi0,
mi1,
b':',
s0,
s1,
ref rest @ ..,
] = s.as_bytes()
else {
return Err(DateTimeError::Malformed);
};
let date = Date::new(
parse_digits(&[y0, y1, y2, y3])? as u16,
parse_digits(&[mo0, mo1])? as u8,
parse_digits(&[d0, d1])? as u8,
)?;
let hour = parse_digits(&[h0, h1])? as u8;
let minute = parse_digits(&[mi0, mi1])? as u8;
let second = parse_digits(&[s0, s1])? as u8;
if hour > 23 || minute > 59 || second > 59 {
return Err(DateTimeError::OutOfRange("time of day"));
}
let (nanosecond, after_frac): (u32, &[u8]) = match rest.split_first() {
Some((&b'.', tail)) => {
let n_digits = tail.iter().take_while(|&&c| c.is_ascii_digit()).count();
if n_digits == 0 || n_digits > 9 {
return Err(DateTimeError::Malformed);
}
let (frac_bytes, remainder) = tail.split_at(n_digits);
let frac = parse_digits(frac_bytes)?;
(frac * 10u32.pow(9 - n_digits as u32), remainder)
}
_ => (0, rest),
};
let offset_minutes = match after_frac {
[] => None,
[b'Z'] => Some(0),
&[sign @ (b'+' | b'-'), oh0, oh1, b':', om0, om1] => {
make_offset(sign, [oh0, oh1], parse_digits(&[om0, om1])?)?
}
&[sign @ (b'+' | b'-'), oh0, oh1, om0, om1] => {
make_offset(sign, [oh0, oh1], parse_digits(&[om0, om1])?)?
}
&[sign @ (b'+' | b'-'), oh0, oh1] => make_offset(sign, [oh0, oh1], 0)?,
_ => return Err(DateTimeError::Malformed),
};
Ok(Self {
date,
hour,
minute,
second,
nanosecond,
offset_minutes,
})
}
}
impl fmt::Display for DateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}T{:02}:{:02}:{:02}",
self.date, self.hour, self.minute, self.second
)?;
if self.nanosecond.is_multiple_of(1_000_000) {
write!(f, ".{:03}", self.nanosecond / 1_000_000)?;
} else {
write!(f, ".{:09}", self.nanosecond)?;
}
match self.offset_minutes {
None | Some(0) => f.write_str("Z"),
Some(o) => {
let (sign, abs) = if o < 0 { ('-', -o) } else { ('+', o) };
write!(f, "{sign}{:02}:{:02}", abs / 60, abs % 60)
}
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum DateTimeError {
#[error("malformed date/datetime text")]
Malformed,
#[error("{0} out of range")]
OutOfRange(&'static str),
}
fn make_offset(sign: u8, hour_digits: [u8; 2], minute: u32) -> Result<Option<i16>, DateTimeError> {
let hour = parse_digits(&hour_digits)?;
if hour > 23 || minute > 59 {
return Err(DateTimeError::OutOfRange("utc offset"));
}
let signum: i16 = if sign == b'+' { 1 } else { -1 };
Ok(Some(signum * (hour as i16 * 60 + minute as i16)))
}
fn parse_digits(b: &[u8]) -> Result<u32, DateTimeError> {
let mut v: u32 = 0;
for &c in b {
if !c.is_ascii_digit() {
return Err(DateTimeError::Malformed);
}
v = v * 10 + (c - b'0') as u32;
}
Ok(v)
}
fn days_in_month(year: u16, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: u16) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn date_parse_and_display() {
let d = Date::parse("2026-07-24").unwrap();
assert_eq!((d.year(), d.month(), d.day()), (2026, 7, 24));
assert_eq!(d.to_string(), "2026-07-24");
Date::parse("2024-02-29").unwrap(); assert_eq!(
Date::parse("2023-02-29"),
Err(DateTimeError::OutOfRange("day"))
);
assert!(
Date::parse("1900-02-29").is_err(),
"1900 is not a leap year"
);
assert!(Date::parse("2000-02-29").is_ok(), "2000 is a leap year");
assert_eq!(
Date::parse("2026-13-01"),
Err(DateTimeError::OutOfRange("month"))
);
assert_eq!(Date::parse("2026-7-24"), Err(DateTimeError::Malformed));
assert_eq!(Date::parse("2026/07/24"), Err(DateTimeError::Malformed));
}
#[test]
fn strict_datetime() {
let dt = DateTime::parse_strict("2026-07-24T12:34:56.789Z").unwrap();
assert_eq!(dt.hour, 12);
assert_eq!(dt.nanosecond, 789_000_000);
assert_eq!(dt.offset_minutes, Some(0));
assert_eq!(dt.to_string(), "2026-07-24T12:34:56.789Z");
for s in [
"2026-07-24T12:34:56Z",
"2026-07-24T12:34:56.789",
"2026-07-24 12:34:56.789Z",
"2026-07-24T12:34:56.789+00:00",
"2026-07-24T12:34:56.7890Z",
] {
assert!(DateTime::parse_strict(s).is_err(), "{s}");
}
}
#[test]
fn lenient_datetime() {
let no_frac = DateTime::parse_lenient("2026-07-24T12:34:56Z").unwrap();
assert_eq!(no_frac.nanosecond, 0);
assert_eq!(no_frac.to_string(), "2026-07-24T12:34:56.000Z");
let no_zone = DateTime::parse_lenient("2026-07-24 12:34:56.5").unwrap();
assert_eq!(no_zone.nanosecond, 500_000_000);
assert_eq!(no_zone.offset_minutes, None);
let offset = DateTime::parse_lenient("2026-07-24T12:34:56+02:00").unwrap();
assert_eq!(offset.offset_minutes, Some(120));
assert_eq!(offset.to_string(), "2026-07-24T12:34:56.000+02:00");
let compact = DateTime::parse_lenient("2026-07-24T12:34:56-0930").unwrap();
assert_eq!(compact.offset_minutes, Some(-(9 * 60 + 30)));
let nanos = DateTime::parse_lenient("2026-07-24T12:34:56.123456789Z").unwrap();
assert_eq!(nanos.nanosecond, 123_456_789);
assert_eq!(nanos.to_string(), "2026-07-24T12:34:56.123456789Z");
for s in [
"2026-07-24T24:00:00Z",
"2026-07-24T12:60:00Z",
"2026-07-24T12:34:56.Z",
"2026-07-24T12:34:56.1234567890Z",
"2026-07-24T12:34:56+25:00",
"not a datetime",
"",
] {
assert!(DateTime::parse_lenient(s).is_err(), "{s}");
}
}
}