use core::fmt;
use core::str::FromStr;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
pub year: u16,
pub month: u8,
pub day: u8,
}
impl Date {
pub fn new(year: u16, month: u8, day: u8) -> Option<Date> {
if year > 9999 {
return None;
}
let max = Date::days_in_month(year, month)?;
if day == 0 || day > max {
return None;
}
Some(Date { year, month, day })
}
pub fn is_leap_year(year: u16) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
pub fn days_in_month(year: u16, month: u8) -> Option<u8> {
Some(match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if Date::is_leap_year(year) => 29,
2 => 28,
_ => return None,
})
}
}
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, PartialOrd, Ord, Hash)]
pub struct Time {
pub hour: u8,
pub minute: u8,
pub second: u8,
pub nanosecond: u32,
}
impl Time {
pub fn new(hour: u8, minute: u8, second: u8, nanosecond: u32) -> Option<Time> {
if hour > 23 || minute > 59 || second > 60 || nanosecond > 999_999_999 {
return None;
}
Some(Time {
hour,
minute,
second,
nanosecond,
})
}
}
impl fmt::Display for Time {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
if self.nanosecond != 0 {
let mut digits = [b'0'; 9];
let mut rest = self.nanosecond;
for slot in digits.iter_mut().rev() {
*slot = b'0' + (rest % 10) as u8;
rest /= 10;
}
let end = digits
.iter()
.rposition(|digit| *digit != b'0')
.map_or(0, |last| last + 1);
f.write_str(".")?;
f.write_str(core::str::from_utf8(&digits[..end]).expect("ASCII digits"))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Offset {
Z,
Custom {
minutes: i16,
},
}
impl fmt::Display for Offset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Offset::Z => f.write_str("Z"),
Offset::Custom { minutes } => {
let (sign, minutes) = if *minutes < 0 {
('-', -*minutes)
} else {
('+', *minutes)
};
write!(f, "{}{:02}:{:02}", sign, minutes / 60, minutes % 60)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DatetimeKind {
OffsetDatetime,
LocalDatetime,
LocalDate,
LocalTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Datetime {
pub date: Option<Date>,
pub time: Option<Time>,
pub offset: Option<Offset>,
}
impl Datetime {
pub fn offset_datetime(date: Date, time: Time, offset: Offset) -> Datetime {
Datetime {
date: Some(date),
time: Some(time),
offset: Some(offset),
}
}
pub fn local_datetime(date: Date, time: Time) -> Datetime {
Datetime {
date: Some(date),
time: Some(time),
offset: None,
}
}
pub fn local_date(date: Date) -> Datetime {
Datetime {
date: Some(date),
time: None,
offset: None,
}
}
pub fn local_time(time: Time) -> Datetime {
Datetime {
date: None,
time: Some(time),
offset: None,
}
}
pub fn kind(&self) -> DatetimeKind {
match (
self.date.is_some(),
self.time.is_some(),
self.offset.is_some(),
) {
(true, true, true) => DatetimeKind::OffsetDatetime,
(true, true, false) => DatetimeKind::LocalDatetime,
(false, true, _) => DatetimeKind::LocalTime,
_ => DatetimeKind::LocalDate,
}
}
}
impl fmt::Display for Datetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(date) = &self.date {
write!(f, "{date}")?;
if self.time.is_some() {
f.write_str("T")?;
}
}
if let Some(time) = &self.time {
write!(f, "{time}")?;
}
if let Some(offset) = &self.offset {
write!(f, "{offset}")?;
}
Ok(())
}
}
impl From<Date> for Datetime {
fn from(date: Date) -> Datetime {
Datetime::local_date(date)
}
}
impl From<Time> for Datetime {
fn from(time: Time) -> Datetime {
Datetime::local_time(time)
}
}
impl FromStr for Datetime {
type Err = Error;
fn from_str(s: &str) -> Result<Datetime, Error> {
match scan(s.as_bytes()) {
Ok(Some((dt, used))) if used == s.len() => Ok(dt),
Ok(Some(_)) => Err(Error::fixed("trailing data after date-time")),
Ok(None) => Err(Error::fixed("expected a date-time")),
Err(message) => Err(Error::fixed(message)),
}
}
}
fn all_digits(b: &[u8]) -> bool {
b.iter().all(u8::is_ascii_digit)
}
fn num(b: &[u8]) -> u32 {
b.iter().fold(0u32, |acc, c| acc * 10 + u32::from(c - b'0'))
}
fn looks_like_time(b: &[u8]) -> bool {
b.len() >= 3 && b[2] == b':' && all_digits(&b[..2])
}
fn scan_time(b: &[u8]) -> Result<(Time, usize), &'static str> {
if b.len() < 5 || b[2] != b':' || !all_digits(&b[..2]) || !all_digits(&b[3..5]) {
return Err("expected a time of the form HH:MM");
}
let (hour, minute) = (num(&b[..2]) as u8, num(&b[3..5]) as u8);
let mut i = 5;
let mut second = 0;
let mut nanosecond = 0u32;
let has_seconds = b.get(i) == Some(&b':');
if has_seconds {
if b.len() < i + 3 || !all_digits(&b[i + 1..i + 3]) {
return Err("expected two digits of seconds");
}
second = num(&b[i + 1..i + 3]) as u8;
i += 3;
}
if has_seconds && b.get(i) == Some(&b'.') {
let start = i + 1;
let mut end = start;
while end < b.len() && b[end].is_ascii_digit() {
end += 1;
}
if end == start {
return Err("expected at least one digit after the decimal point");
}
for (idx, c) in b[start..end].iter().take(9).enumerate() {
nanosecond += u32::from(c - b'0') * 10u32.pow(8 - idx as u32);
}
i = end;
}
let time = Time::new(hour, minute, second, nanosecond).ok_or("time is out of range")?;
Ok((time, i))
}
pub(crate) fn scan(b: &[u8]) -> Result<Option<(Datetime, usize)>, &'static str> {
if b.len() >= 5 && b[4] == b'-' && all_digits(&b[..4]) {
if b.len() < 10 || b[7] != b'-' || !all_digits(&b[5..7]) || !all_digits(&b[8..10]) {
return Err("expected a date of the form YYYY-MM-DD");
}
let date = Date::new(
num(&b[..4]) as u16,
num(&b[5..7]) as u8,
num(&b[8..10]) as u8,
)
.ok_or("date is out of range")?;
let mut i = 10;
match b.get(i) {
Some(b'T' | b't') => i += 1,
Some(b' ') if looks_like_time(&b[i + 1..]) => i += 1,
_ => return Ok(Some((Datetime::local_date(date), i))),
}
let (time, used) = scan_time(&b[i..])?;
i += used;
let offset = match b.get(i) {
Some(b'Z' | b'z') => {
i += 1;
Some(Offset::Z)
}
Some(c @ (b'+' | b'-')) => {
let negative = *c == b'-';
if b.len() < i + 6
|| b[i + 3] != b':'
|| !all_digits(&b[i + 1..i + 3])
|| !all_digits(&b[i + 4..i + 6])
{
return Err("expected a time offset of the form +HH:MM");
}
let (hours, minutes) = (num(&b[i + 1..i + 3]), num(&b[i + 4..i + 6]));
if hours > 23 || minutes > 59 {
return Err("time offset is out of range");
}
i += 6;
let total = (hours * 60 + minutes) as i16;
Some(Offset::Custom {
minutes: if negative { -total } else { total },
})
}
_ => None,
};
Ok(Some((
Datetime {
date: Some(date),
time: Some(time),
offset,
},
i,
)))
} else if b.len() >= 3 && b[2] == b':' && all_digits(&b[..2]) {
let (time, used) = scan_time(b)?;
Ok(Some((Datetime::local_time(time), used)))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_date_stops_before_a_non_time() {
let (dt, used) = scan(b"1979-05-27 , 1").unwrap().unwrap();
assert_eq!(used, 10);
assert_eq!(dt.kind(), DatetimeKind::LocalDate);
}
#[test]
fn not_a_datetime() {
assert!(scan(b"1234").unwrap().is_none());
assert!(scan(b"1.5e3").unwrap().is_none());
assert!(scan(b"-17").unwrap().is_none());
}
#[test]
fn ranges_are_checked() {
assert!(Date::new(2024, 2, 29).is_some());
assert!(Date::new(2023, 2, 29).is_none());
assert!(Date::new(2023, 13, 1).is_none());
assert!(Date::new(2023, 1, 0).is_none());
assert!(Time::new(23, 59, 60, 0).is_some());
assert!(Time::new(23, 59, 61, 0).is_none());
assert!(Time::new(24, 0, 0, 0).is_none());
assert!(Time::new(0, 0, 0, 1_000_000_000).is_none());
}
#[test]
fn parses_without_an_allocator() {
for s in [
"1979-05-27T07:32:00Z",
"1979-05-27",
"07:32",
"2010-02-03 14:15",
] {
assert!(s.parse::<Datetime>().is_ok(), "{s}");
}
for s in ["1979-05-32", "07:32.5", "not a date"] {
assert!(s.parse::<Datetime>().is_err(), "{s}");
}
}
}