use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(
Clone,
Copy,
Debug,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
Serialize,
Deserialize,
)]
pub struct DateTime {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
offset_minutes: i16,
}
#[derive(
Clone,
Copy,
Debug,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
Serialize,
Deserialize,
)]
pub struct Duration {
seconds: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParseError {
InvalidFormat,
OutOfRange,
InvalidTimezone,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidFormat => {
write!(f, "invalid ISO 8601 format")
}
Self::OutOfRange => {
write!(f, "date/time field out of range")
}
Self::InvalidTimezone => {
write!(f, "invalid timezone offset")
}
}
}
}
impl std::error::Error for ParseError {}
impl DateTime {
pub fn new(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
offset_minutes: i16,
) -> Option<Self> {
if !(1..=12).contains(&month)
|| day == 0
|| day > days_in_month(year, month)
|| hour > 23
|| minute > 59
|| second > 59
|| !(-1440..=1440).contains(&offset_minutes)
{
return None;
}
Some(Self {
year,
month,
day,
hour,
minute,
second,
offset_minutes,
})
}
pub fn parse(input: &str) -> Result<Self, ParseError> {
let b = input.as_bytes();
if b.len() < 20 {
return Err(ParseError::InvalidFormat);
}
let year =
parse_u32(b, 0, 4).ok_or(ParseError::InvalidFormat)? as i32;
if b[4] != b'-' {
return Err(ParseError::InvalidFormat);
}
let month =
parse_u32(b, 5, 2).ok_or(ParseError::InvalidFormat)? as u8;
if b[7] != b'-' {
return Err(ParseError::InvalidFormat);
}
let day =
parse_u32(b, 8, 2).ok_or(ParseError::InvalidFormat)? as u8;
if b[10] != b'T' {
return Err(ParseError::InvalidFormat);
}
let hour =
parse_u32(b, 11, 2).ok_or(ParseError::InvalidFormat)? as u8;
if b[13] != b':' {
return Err(ParseError::InvalidFormat);
}
let minute =
parse_u32(b, 14, 2).ok_or(ParseError::InvalidFormat)? as u8;
if b[16] != b':' {
return Err(ParseError::InvalidFormat);
}
let second =
parse_u32(b, 17, 2).ok_or(ParseError::InvalidFormat)? as u8;
let offset_minutes = match b[19] {
b'Z' => {
if b.len() != 20 {
return Err(ParseError::InvalidFormat);
}
0i16
}
b'+' | b'-' => {
if b.len() != 25 {
return Err(ParseError::InvalidFormat);
}
let oh = parse_u32(b, 20, 2)
.ok_or(ParseError::InvalidTimezone)?
as i16;
if b[22] != b':' {
return Err(ParseError::InvalidTimezone);
}
let om = parse_u32(b, 23, 2)
.ok_or(ParseError::InvalidTimezone)?
as i16;
if oh > 23 || om > 59 {
return Err(ParseError::InvalidTimezone);
}
let total = oh * 60 + om;
if b[19] == b'-' {
-total
} else {
total
}
}
_ => return Err(ParseError::InvalidTimezone),
};
DateTime::new(
year,
month,
day,
hour,
minute,
second,
offset_minutes,
)
.ok_or(ParseError::OutOfRange)
}
pub fn year(&self) -> i32 {
self.year
}
pub fn month(&self) -> u8 {
self.month
}
pub fn day(&self) -> u8 {
self.day
}
pub fn hour(&self) -> u8 {
self.hour
}
pub fn minute(&self) -> u8 {
self.minute
}
pub fn second(&self) -> u8 {
self.second
}
pub fn offset_minutes(&self) -> i16 {
self.offset_minutes
}
pub fn to_iso8601(&self) -> String {
if self.offset_minutes == 0 {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
)
} else {
let sign = if self.offset_minutes >= 0 { '+' } else { '-' };
let abs = self.offset_minutes.unsigned_abs();
let oh = abs / 60;
let om = abs % 60;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}\
{}{:02}:{:02}",
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
sign,
oh,
om,
)
}
}
pub fn to_unix_timestamp(&self) -> i64 {
let days = days_from_civil(self.year, self.month, self.day);
let secs = days * 86400
+ i64::from(self.hour) * 3600
+ i64::from(self.minute) * 60
+ i64::from(self.second);
secs - i64::from(self.offset_minutes) * 60
}
pub fn now() -> Self {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
Self::from_unix_timestamp(dur.as_secs() as i64)
}
pub fn from_unix_timestamp(ts: i64) -> Self {
let (days, rem) = if ts >= 0 {
(ts / 86400, ts % 86400)
} else {
let d = (ts + 1) / 86400 - 1;
(d, ts - d * 86400)
};
let (y, m, d) = civil_from_days(days);
let hour = (rem / 3600) as u8;
let minute = ((rem % 3600) / 60) as u8;
let second = (rem % 60) as u8;
Self {
year: y,
month: m,
day: d,
hour,
minute,
second,
offset_minutes: 0,
}
}
pub fn add_seconds(&self, secs: i64) -> Self {
Self::from_unix_timestamp(self.to_unix_timestamp() + secs)
}
pub fn add_hours(&self, hours: i64) -> Self {
self.add_seconds(hours * 3600)
}
pub fn add_days(&self, days: i64) -> Self {
self.add_seconds(days * 86400)
}
pub fn duration_since(&self, other: &Self) -> Duration {
Duration {
seconds: self.to_unix_timestamp()
- other.to_unix_timestamp(),
}
}
pub fn relative_to(&self, other: &Self) -> String {
let d = self.duration_since(other);
let abs = d.seconds.unsigned_abs();
let past = d.seconds < 0;
let label = if abs < 60 {
format!("{abs} seconds")
} else if abs < 3600 {
let m = abs / 60;
if m == 1 {
"1 minute".to_string()
} else {
format!("{m} minutes")
}
} else if abs < 86400 {
let h = abs / 3600;
if h == 1 {
"1 hour".to_string()
} else {
format!("{h} hours")
}
} else if abs < 2_592_000 {
let d = abs / 86400;
if d == 1 {
"1 day".to_string()
} else {
format!("{d} days")
}
} else if abs < 31_536_000 {
let mo = abs / 2_592_000;
if mo == 1 {
"1 month".to_string()
} else {
format!("{mo} months")
}
} else {
let y = abs / 31_536_000;
if y == 1 {
"1 year".to_string()
} else {
format!("{y} years")
}
};
if past {
format!("{label} ago")
} else if d.seconds == 0 {
"just now".to_string()
} else {
format!("in {label}")
}
}
}
impl fmt::Display for DateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_iso8601())
}
}
impl TryFrom<&str> for DateTime {
type Error = ParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl From<std::time::SystemTime> for DateTime {
fn from(st: std::time::SystemTime) -> Self {
let dur = st
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
Self::from_unix_timestamp(dur.as_secs() as i64)
}
}
impl Duration {
pub fn from_seconds(seconds: i64) -> Self {
Self { seconds }
}
pub fn whole_seconds(&self) -> i64 {
self.seconds
}
pub fn whole_minutes(&self) -> i64 {
self.seconds / 60
}
pub fn whole_hours(&self) -> i64 {
self.seconds / 3600
}
pub fn whole_days(&self) -> i64 {
self.seconds / 86400
}
pub fn is_negative(&self) -> bool {
self.seconds < 0
}
pub fn abs(&self) -> Self {
Self {
seconds: self.seconds.abs(),
}
}
}
impl fmt::Display for Duration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let abs = self.seconds.unsigned_abs();
let h = abs / 3600;
let m = (abs % 3600) / 60;
let s = abs % 60;
if self.seconds < 0 {
write!(f, "-{h:02}:{m:02}:{s:02}")
} else {
write!(f, "{h:02}:{m:02}:{s:02}")
}
}
}
fn is_leap_year(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn days_in_month(y: i32, m: u8) -> u8 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(y) => 29,
2 => 28,
_ => 0,
}
}
fn civil_from_days(z: i64) -> (i32, u8, u8) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = (yoe as i64) + 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) as u8;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
let y = if m <= 2 { y + 1 } else { y } as i32;
(y, m, d)
}
fn days_from_civil(y: i32, m: u8, d: u8) -> i64 {
let y = i64::from(y);
let m = i64::from(m);
let d = i64::from(d);
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let m_adj = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * m_adj as u64 + 2) / 5 + d as u64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe as i64 - 719468
}
fn parse_u32(b: &[u8], offset: usize, len: usize) -> Option<u32> {
if offset + len > b.len() {
return None;
}
let mut val = 0u32;
for &ch in &b[offset..offset + len] {
if !ch.is_ascii_digit() {
return None;
}
val = val * 10 + u32::from(ch - b'0');
}
Some(val)
}
#[cfg(test)]
mod internal_tests {
#[test]
fn days_in_month_invalid_returns_zero() {
assert_eq!(super::days_in_month(2026, 0), 0);
assert_eq!(super::days_in_month(2026, 13), 0);
}
#[test]
fn parse_u32_out_of_bounds() {
assert!(super::parse_u32(b"12", 0, 5).is_none());
assert!(super::parse_u32(b"12", 3, 1).is_none());
}
}