use std::fmt::Display;
use std::iter::Peekable;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DateTime {
date: Date,
time: Time,
}
impl DateTime {
pub(crate) fn parse(raw: &str) -> Option<Self> {
let raw = raw.trim();
let (date_str, time_str) = raw
.split_once(['T', ' '])
.map(|(date, time)| (date, Some(time)))
.unwrap_or((raw, None));
Some(DateTime {
date: Date::parse(date_str)?,
time: time_str.and_then(Time::parse).unwrap_or(Time::EMPTY),
})
}
pub fn date(&self) -> Date {
self.date
}
pub fn time(&self) -> Time {
self.time
}
}
impl Display for DateTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}T{}", self.date, self.time)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
year: i16,
month: u8,
day: u8,
}
impl Date {
fn new_clamped(year: i16, month: u8, day: u8) -> Self {
Self {
year: year.clamp(-9999, 9999),
month: month.clamp(1, 12),
day: day.clamp(1, 31),
}
}
fn parse(raw: &str) -> Option<Self> {
fn take_date_num(
chars: &mut Peekable<impl Iterator<Item = char>>,
max_count: usize,
) -> Option<u32> {
while let Some(c) = chars.peek() {
match c {
c if c.is_ascii_digit() => break,
_ => chars.next(),
};
}
take_num(chars, max_count)
}
let mut chars = raw.chars().peekable();
let year = take_date_num(&mut chars, 4)? as i16;
let mut month = 1;
let mut day = 1;
if let Some(m) = take_date_num(&mut chars, 2) {
month = m as u8;
if let Some(d) = take_date_num(&mut chars, 2) {
day = d as u8;
}
}
Some(Date::new_clamped(year, month, day))
}
pub fn year(&self) -> i16 {
self.year
}
pub fn month(&self) -> u8 {
self.month
}
pub fn day(&self) -> u8 {
self.day
}
}
impl Display for Date {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:0>4}-{:0>2}-{:0>2}", self.year, self.month, self.day)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Time {
hour: u8,
minute: u8,
second: u8,
offset: Option<i16>,
}
impl Time {
const EMPTY: Time = Time {
hour: 0,
minute: 0,
second: 0,
offset: None,
};
fn new_clamped(hour: u8, minute: u8, second: u8, offset: Option<i16>) -> Self {
Self {
hour: hour.min(23),
minute: minute.min(59),
second: second.min(59),
offset: offset.map(|minutes| minutes.clamp(-12 * 60, 14 * 60)),
}
}
fn parse(raw: &str) -> Option<Self> {
fn take_time_num(
chars: &mut Peekable<impl Iterator<Item = char>>,
max_count: usize,
) -> Option<u32> {
while let Some(c) = chars.peek() {
match c {
'Z' | '-' | '+' => return None,
c if c.is_ascii_digit() => break,
_ => chars.next(),
};
}
take_num(chars, max_count)
}
let mut chars = raw.chars().peekable();
let hour = take_time_num(&mut chars, 2)? as u8;
let mut minute = 0;
let mut second = 0;
let mut offset = None;
if let Some(m) = take_time_num(&mut chars, 2) {
minute = m as u8;
if let Some(s) = take_time_num(&mut chars, 2) {
second = s as u8;
}
}
while let Some(c) = chars.next() {
if c == 'Z' {
offset = Some(0);
break;
} else if matches!(c, '-' | '+') {
let sign = if c == '+' { 1 } else { -1 };
let hours = take_time_num(&mut chars, 2).unwrap_or(0) as i16;
let minutes = take_time_num(&mut chars, 2).unwrap_or(0) as i16;
offset = Some(sign * (hours * 60 + minutes));
break;
}
}
Some(Time::new_clamped(hour, minute, second, offset))
}
pub fn hour(&self) -> u8 {
self.hour
}
pub fn minute(&self) -> u8 {
self.minute
}
pub fn second(&self) -> u8 {
self.second
}
pub fn offset(&self) -> Option<i16> {
self.offset
}
pub fn offset_hour(&self) -> Option<i16> {
self.offset.map(|offset| offset / 60)
}
pub fn offset_minute(&self) -> Option<i16> {
self.offset.map(|offset| offset % 60)
}
pub fn is_local(&self) -> bool {
self.offset.is_none()
}
pub fn is_offset(&self) -> bool {
self.offset.is_some()
}
pub fn is_utc(&self) -> bool {
self.offset.is_some_and(|offset| offset == 0)
}
}
impl Display for Time {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:0>2}:{:0>2}:{:0>2}",
self.hour, self.minute, self.second,
)?;
match self.offset {
Some(0) => write!(f, "Z"),
Some(offset) => {
let sign = if offset < 0 { '-' } else { '+' };
let offset = offset.abs();
write!(f, "{sign}{:0>2}:{:0>2}", offset / 60, offset % 60)
}
_ => Ok(()),
}
}
}
fn take_num(chars: &mut Peekable<impl Iterator<Item = char>>, max_count: usize) -> Option<u32> {
let mut num = 0;
let mut found = false;
for _ in 0..max_count {
if let Some(&c) = chars.peek()
&& let Some(digit) = c.to_digit(10)
{
num = num * 10 + digit;
found = true;
chars.next();
continue;
}
break;
}
found.then_some(num)
}
#[cfg(feature = "write")]
mod write {
use crate::ebook::metadata::datetime::{Date, DateTime, Time};
impl DateTime {
pub fn new(date: Date, time: Time) -> Self {
Self { date, time }
}
pub fn now() -> Self {
Self::try_now().expect("rbook: std::time::SystemTime is not supported on this platform")
}
pub fn try_now() -> Option<Self> {
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
None
}
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
{
let epoch_time = match std::time::UNIX_EPOCH.elapsed() {
Ok(after_epoch) => after_epoch.as_secs() as i64,
Err(before_epoch) => -(before_epoch.duration().as_secs() as i64),
};
Some(Self::from_unix(epoch_time))
}
}
pub fn from_unix(secs: i64) -> Self {
unix_timestamp_to_utc_calendar(secs)
}
}
impl Date {
pub fn new(year: i16, month: u8, day: u8) -> Self {
Self::new_clamped(year, month, day)
}
pub fn at(self, time: Time) -> DateTime {
DateTime { date: self, time }
}
}
impl Time {
pub fn new(hour: u8, minute: u8, second: u8, utc_offset: Option<i16>) -> Self {
Time::new_clamped(hour, minute, second, utc_offset)
}
pub fn utc(hour: u8, minute: u8, second: u8) -> Self {
Self::new(hour, minute, second, Some(0))
}
}
fn unix_timestamp_to_utc_calendar(secs: i64) -> DateTime {
let days_since_epoch = secs.div_euclid(86400) as i32;
let secs_of_day = secs.rem_euclid(86400) as u32;
let second = (secs_of_day % 60) as u8;
let minute = ((secs_of_day / 60) % 60) as u8;
let hour = ((secs_of_day / 3600) % 24) as u8;
let z = days_since_epoch + 719468; let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i32 + 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;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = y + (m <= 2) as i32;
let month = m as u8;
let day = d as u8;
DateTime::new(
Date::new(year as i16, month, day),
Time::utc(hour, minute, second),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_datetime() {
#[rustfmt::skip]
let expected = [
((2025, 1, 1, 0, 0, 0, None), "2025"),
((2023, 10, 27, 15, 30, 5, Some(0)), "2023-10-27T15:30:05Z"),
((2023, 10, 27, 15, 30, 0, Some(120)), "2023-10-27 15:30+02:00"),
((2025, 12, 1, 0, 0, 0, None), "2025-13-01"),
((2025, 1, 1, 0, 0, 0, None), "2025-00-00"),
((2025, 1, 31, 0, 0, 0, None), "2025-01-32"),
((2025, 1, 1, 23, 59, 59, Some(0)), "2025-01-01 25:61:99Z"),
((2020, 1, 1, 20, 0, 0, None), "2020-01-01T20"),
((2020, 5, 20, 0, 0, 0, None), "20200520"),
((1999, 12, 31, 23, 59, 0, None), "1999/12/31 23:59"),
((2022, 1, 1, 12, 0, 0, Some(-480)), "2022.01.01 12:00:00-0800[T/Z]"),
((2021, 1, 1, 0, 0, 0, None), " 2021-01-01 "),
((2021, 6, 1, 0, 0, 0, None), "2021-06-unknown"),
((5, 1, 1, 0, 0, 0, None), "0005-01-01"),
((5, 12, 1, 0, 0, 0, None), "5-50T"),
];
for ((y, m, d, hh, mm, ss, off), raw) in expected {
let datetime =
DateTime::parse(raw).unwrap_or_else(|| panic!("Failed to parse: {}", raw));
let expected_date = Date {
year: y,
month: m,
day: d,
};
let expected_time = Time {
hour: hh,
minute: mm,
second: ss,
offset: off,
};
assert_eq!(datetime.date, expected_date, "Date mismatch for: {raw}");
assert_eq!(datetime.time, expected_time, "Time mismatch for: {raw}");
}
}
#[test]
fn test_parse_datetime_fail() {
#[rustfmt::skip]
let expected = [
"T12:00:00",
];
for raw in expected {
assert_eq!(None, DateTime::parse(raw), "{raw}");
}
}
#[test]
fn test_parse_date() {
#[rustfmt::skip]
let expected = [
((0, 1, 1), "0"),
((1902, 2, 11), "- 1902 2 11 "),
((2025, 1, 1), "2025"),
((2025, 8, 1), "20258"),
((2025, 12, 1), "202518"),
((2025, 12, 2), "202518/2"),
((2003, 10, 23), "2003.10.23"),
((2025, 8, 1), "2025/8"),
((2021, 12, 1), "2021-16-a"),
((2070, 5, 1), "2070-b-5"),
((2030, 12, 1), "203012"),
((2025, 12, 1), "2025-12"),
((2025, 12, 31), "2025-12-31"),
((2001, 6, 7), " 2001 / 06 / 07 "),
((2026, 4, 5), "2026/4/5"),
((2029, 8, 16), "2029/8/16"),
((589, 5, 5), "589/5/05"),
((5, 5, 5), "5-5-5"),
];
for ((year, month, day), raw) in expected {
assert_eq!(
Some(Date { year, month, day }),
Date::parse(raw),
"Date mismatch for: {raw}",
);
}
}
#[test]
fn test_parse_date_fail() {
#[rustfmt::skip]
let expected = [
"",
"abc",
"---",
"/",
"n/a",
];
for raw in expected {
assert_eq!(None, Date::parse(raw), "{raw}");
}
}
#[test]
fn test_parse_time() {
#[rustfmt::skip]
let expected = [
((13, 0, 0, Some(615)), "13+10:15"),
];
for ((hour, minute, second, offset), raw) in expected {
assert_eq!(
Some(Time {
hour,
minute,
second,
offset,
}),
Time::parse(raw),
"Time mismatch for: {raw}",
);
}
}
#[test]
fn test_parse_time_fail() {
#[rustfmt::skip]
let expected = [
"Z",
"",
"xyz",
"---Z",
"+3",
"-9",
"n/a",
"+00",
"-10:03",
];
for raw in expected {
assert_eq!(None, Time::parse(raw));
}
}
#[test]
#[cfg(feature = "write")]
fn test_unix_to_datetime() {
#[rustfmt::skip]
let cases = [
(0, 1970, 1, 1, 0, 0, 0), (1709164800, 2024, 2, 29, 0, 0, 0), (1709251200, 2024, 3, 1, 0, 0, 0), (1709251199, 2024, 2, 29, 23, 59, 59), (951825600, 2000, 2, 29, 12, 0, 0), (4107542399, 2100, 2, 28, 23, 59, 59), (4107542400, 2100, 3, 1, 0, 0, 0), (2147483647, 2038, 1, 19, 3, 14, 7), (16725225600, 2500, 1, 1, 0, 0, 0), (253402300799, 9999, 12, 31, 23, 59, 59),
(-1, 1969, 12, 31, 23, 59, 59), (-86400, 1969, 12, 31, 0, 0, 0), (-58060800, 1968, 2, 29, 0, 0, 0), (-2147483648, 1901, 12, 13, 20, 45, 52),
(-2208988800, 1900, 1, 1, 0, 0, 0),
(-2203977600, 1900, 2, 28, 0, 0, 0),
(-2203891200, 1900, 3, 1, 0, 0, 0),
(-4952457600, 1813, 1, 23, 21, 20, 0),
(-62135596800, 1, 1, 1, 0, 0, 0), ];
for (stamp, y, m, d, hh, mm, ss) in cases {
let datetime = DateTime::from_unix(stamp);
let expected_date = Date {
year: y,
month: m,
day: d,
};
let expected_time = Time {
hour: hh,
minute: mm,
second: ss,
offset: Some(0),
};
assert_eq!(datetime.date, expected_date, "Date mismatch for: {stamp}");
assert_eq!(datetime.time, expected_time, "Time mismatch for: {stamp}");
}
}
}