use std::cmp::Ordering;
use std::fmt;
use std::time::SystemTime;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[doc(alias = "time")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timestamp {
total_secs: u64,
year: u32,
month: u32,
day: u32,
hours: u32,
minutes: u32,
seconds: u32,
}
impl PartialOrd for Timestamp {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Timestamp {
fn cmp(&self, other: &Self) -> Ordering {
self.total_secs.cmp(&other.total_secs)
}
}
impl Timestamp {
#[cfg(test)]
fn new(year: u32, month: u32, day: u32, hours: u32, minutes: u32, seconds: u32) -> Self {
let days = days_from_civil(u64::from(year), u64::from(month), u64::from(day));
let total_secs =
days * 86_400 + u64::from(hours) * 3600 + u64::from(minutes) * 60 + u64::from(seconds);
let ts = Self::from_unix_secs(total_secs);
debug_assert_eq!(
(ts.year, ts.month, ts.day, ts.hours, ts.minutes, ts.seconds),
(year, month, day, hours, minutes, seconds),
"Timestamp::new called with fields that do not round-trip through epoch seconds"
);
ts
}
#[must_use]
#[inline]
pub fn now() -> Self {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
Self::from_unix_secs(now.as_secs())
}
#[must_use]
#[inline]
pub fn from_unix_secs(secs: u64) -> Self {
let days = secs / 86_400;
let day_secs = secs % 86_400;
let (year, month, day) = civil_from_days(days);
#[allow(clippy::cast_possible_truncation)]
Self {
total_secs: secs,
year,
month,
day,
hours: (day_secs / 3600) as u32,
minutes: ((day_secs % 3600) / 60) as u32,
seconds: (day_secs % 60) as u32,
}
}
#[must_use]
#[inline]
pub fn unix_epoch_secs(&self) -> u64 {
self.total_secs
}
#[must_use]
#[inline]
pub fn is_before(&self, other: &Timestamp) -> bool {
self.total_secs < other.total_secs
}
#[must_use]
#[inline]
pub fn is_expired(&self, now: &Timestamp) -> bool {
self.total_secs <= now.total_secs
}
#[must_use]
#[inline]
pub fn year(&self) -> u32 {
self.year
}
#[must_use]
#[inline]
pub fn month(&self) -> u32 {
self.month
}
#[must_use]
#[inline]
pub fn day(&self) -> u32 {
self.day
}
#[must_use]
#[inline]
pub fn hour(&self) -> u32 {
self.hours
}
#[must_use]
#[inline]
pub fn minute(&self) -> u32 {
self.minutes
}
#[must_use]
#[inline]
pub fn second(&self) -> u32 {
self.seconds
}
#[deprecated(note = "renamed to `hour()` for consistency with `year()`/`month()`/`day()`")]
#[must_use]
#[inline]
pub fn hours(&self) -> u32 {
self.hours
}
#[deprecated(note = "renamed to `minute()` for consistency")]
#[must_use]
#[inline]
pub fn minutes(&self) -> u32 {
self.minutes
}
#[deprecated(note = "renamed to `second()` for consistency")]
#[must_use]
#[inline]
pub fn seconds(&self) -> u32 {
self.seconds
}
#[must_use]
pub fn parse_iso8601(s: &str) -> Option<Self> {
if !s.is_ascii() {
return None;
}
if s.len() > 64 {
return None;
}
let (core, offset_secs): (&str, i64) = if let Some(c) = s.strip_suffix('Z') {
(c, 0)
} else if s.len() >= 6 {
let (head, tz) = s.split_at(s.len() - 6);
let tz = tz.as_bytes();
let sign: i64 = match tz[0] {
b'+' => 1,
b'-' => -1,
_ => return None,
};
if tz[3] != b':' {
return None;
}
let oh: i64 = core::str::from_utf8(&tz[1..3]).ok()?.parse().ok()?;
let om: i64 = core::str::from_utf8(&tz[4..6]).ok()?.parse().ok()?;
if oh > 23 || om > 59 {
return None;
}
(head, sign * (oh * 3600 + om * 60))
} else {
return None;
};
if core.len() < 19 {
return None;
}
let (datetime, frac) = core.split_at(19);
if !frac.is_empty() {
let digits = frac.strip_prefix('.')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
}
let b = datetime.as_bytes();
if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
return None;
}
if !b[..19]
.iter()
.enumerate()
.all(|(i, c)| matches!(i, 4 | 7 | 10 | 13 | 16) || c.is_ascii_digit())
{
return None;
}
let year: u64 = datetime[0..4].parse().ok()?;
let month: u64 = datetime[5..7].parse().ok()?;
let day: u64 = datetime[8..10].parse().ok()?;
let hour: u64 = datetime[11..13].parse().ok()?;
let minute: u64 = datetime[14..16].parse().ok()?;
let second: u64 = datetime[17..19].parse().ok()?;
if year < 1970
|| !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
|| second > 59
{
return None;
}
let max_day = match month {
2 => {
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
if leap { 29 } else { 28 }
}
4 | 6 | 9 | 11 => 30,
_ => 31,
};
if day > max_day {
return None;
}
let days = days_from_civil(year, month, day);
let local_secs = days * 86_400 + hour * 3600 + minute * 60 + second;
let utc_secs = i64::try_from(local_secs).ok()? - offset_secs;
if utc_secs < 0 {
return None;
}
Some(Self::from_unix_secs(utc_secs.unsigned_abs()))
}
#[must_use]
pub fn to_iso8601(&self) -> String {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
self.year, self.month, self.day, self.hours, self.minutes, self.seconds,
)
}
}
impl Serialize for Timestamp {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u64(self.total_secs)
}
}
impl<'de> Deserialize<'de> for Timestamp {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let secs = u64::deserialize(deserializer)?;
Ok(Self::from_unix_secs(secs))
}
}
impl fmt::Display for Timestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
self.year, self.month, self.day, self.hours, self.minutes, self.seconds,
)
}
}
const fn civil_from_days(days: u64) -> (u32, u32, u32) {
#[allow(clippy::cast_possible_wrap)]
let z = days as i64 + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + 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 y = if m <= 2 { y + 1 } else { y };
debug_assert!(
y >= 0,
"civil_from_days: negative year from post-epoch input"
);
debug_assert!(y <= u32::MAX as i64, "civil_from_days: year overflows u32");
debug_assert!(m >= 1 && m <= 12, "civil_from_days: month out of range");
debug_assert!(d >= 1 && d <= 31, "civil_from_days: day out of range");
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
(y as u32, m as u32, d as u32)
}
const fn days_from_civil(year: u64, month: u64, day: u64) -> u64 {
let y = if month <= 2 { year - 1 } else { year };
let era = y / 400;
let yoe = y - era * 400;
let m_adj = if month > 2 { month - 3 } else { month + 9 };
let doy = (153 * m_adj + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_from_days_unix_epoch() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn civil_from_days_day_one() {
assert_eq!(civil_from_days(1), (1970, 1, 2));
}
#[test]
fn civil_from_days_end_of_1970() {
assert_eq!(civil_from_days(364), (1970, 12, 31));
}
#[test]
fn civil_from_days_start_of_1971() {
assert_eq!(civil_from_days(365), (1971, 1, 1));
}
#[test]
fn civil_from_days_leap_day_2000() {
assert_eq!(civil_from_days(11016), (2000, 2, 29));
}
#[test]
fn civil_from_days_y2k() {
assert_eq!(civil_from_days(10957), (2000, 1, 1));
}
#[test]
fn civil_from_days_2024_leap_day() {
assert_eq!(civil_from_days(19782), (2024, 2, 29));
}
#[test]
fn civil_from_days_2025_01_01() {
assert_eq!(civil_from_days(20089), (2025, 1, 1));
}
#[test]
fn civil_from_days_2026_02_21() {
assert_eq!(civil_from_days(20505), (2026, 2, 21));
}
#[test]
fn civil_from_days_non_leap_century_1900_feb() {
assert_eq!(civil_from_days(47541), (2100, 3, 1));
}
#[test]
fn civil_from_days_millennium_boundary() {
assert_eq!(civil_from_days(10956), (1999, 12, 31));
}
#[test]
fn civil_from_days_round_trip_consistency() {
let mut prev = civil_from_days(0);
for day in 1..=25_000_u64 {
let (y, m, d) = civil_from_days(day);
assert!((1..=12).contains(&m), "month out of range at day {day}");
assert!((1..=31).contains(&d), "day out of range at day {day}");
assert!(
(y, m, d) >= prev,
"dates should be monotonically non-decreasing: \
day {day} produced ({y}, {m}, {d}), prev was {prev:?}",
);
prev = (y, m, d);
}
}
#[test]
fn from_unix_secs_epoch() {
let ts = Timestamp::from_unix_secs(0);
assert_eq!(ts.year(), 1970);
assert_eq!(ts.month(), 1);
assert_eq!(ts.day(), 1);
assert_eq!(ts.hour(), 0);
assert_eq!(ts.minute(), 0);
assert_eq!(ts.second(), 0);
assert_eq!(ts.unix_epoch_secs(), 0);
}
#[test]
fn from_unix_secs_known_date() {
let ts = Timestamp::from_unix_secs(1_735_689_600);
assert_eq!(ts.year(), 2025);
assert_eq!(ts.month(), 1);
assert_eq!(ts.day(), 1);
assert_eq!(ts.hour(), 0);
assert_eq!(ts.minute(), 0);
assert_eq!(ts.second(), 0);
}
#[test]
fn from_unix_secs_with_time_of_day() {
let ts = Timestamp::from_unix_secs(1_771_684_245);
assert_eq!(ts.year(), 2026);
assert_eq!(ts.month(), 2);
assert_eq!(ts.day(), 21);
assert_eq!(ts.hour(), 14);
assert_eq!(ts.minute(), 30);
assert_eq!(ts.second(), 45);
}
#[test]
fn from_unix_secs_round_trips_with_unix_epoch_secs() {
let values = [0, 1, 86_400, 1_000_000, 1_735_689_600, 4_102_444_800];
for secs in values {
let ts = Timestamp::from_unix_secs(secs);
assert_eq!(ts.unix_epoch_secs(), secs, "round-trip failed for {secs}");
}
}
#[test]
fn now_returns_plausible_date() {
let ts = Timestamp::now();
assert!(ts.year() >= 2020, "year {}", ts.year());
assert!(ts.year() <= 2100, "year {}", ts.year());
assert!((1..=12).contains(&ts.month()), "month {}", ts.month());
assert!((1..=31).contains(&ts.day()), "day {}", ts.day());
assert!(ts.hour() < 24, "hour {}", ts.hour());
assert!(ts.minute() < 60, "minute {}", ts.minute());
assert!(ts.second() < 60, "second {}", ts.second());
assert!(ts.unix_epoch_secs() > 1_577_836_800);
}
#[test]
fn ordering_earlier_is_less() {
let earlier = Timestamp::from_unix_secs(1_000_000);
let later = Timestamp::from_unix_secs(2_000_000);
assert!(earlier < later);
assert!(later > earlier);
assert_ne!(earlier, later);
}
#[test]
fn ordering_equal() {
let a = Timestamp::from_unix_secs(1_735_689_600);
let b = Timestamp::from_unix_secs(1_735_689_600);
assert_eq!(a, b);
assert!(a <= b);
assert!(a >= b);
}
#[test]
fn ordering_adjacent_seconds() {
let a = Timestamp::from_unix_secs(1_735_689_600);
let b = Timestamp::from_unix_secs(1_735_689_601);
assert!(a < b);
}
#[test]
fn is_before_correctness() {
let earlier = Timestamp::from_unix_secs(100);
let later = Timestamp::from_unix_secs(200);
assert!(earlier.is_before(&later));
assert!(!later.is_before(&earlier));
assert!(!earlier.is_before(&earlier));
}
#[test]
fn is_expired_token_in_the_past() {
let expiry = Timestamp::from_unix_secs(1_000_000);
let now = Timestamp::from_unix_secs(2_000_000);
assert!(expiry.is_expired(&now));
}
#[test]
fn is_expired_token_in_the_future() {
let expiry = Timestamp::from_unix_secs(3_000_000);
let now = Timestamp::from_unix_secs(2_000_000);
assert!(!expiry.is_expired(&now));
}
#[test]
fn is_expired_exact_boundary() {
let expiry = Timestamp::from_unix_secs(1_000_000);
let now = Timestamp::from_unix_secs(1_000_000);
assert!(expiry.is_expired(&now));
}
#[test]
fn display_format_is_fixed_width() {
let ts = Timestamp::now();
let formatted = format!("{ts}");
assert_eq!(
formatted.len(),
19,
"Expected 19-char timestamp, got '{formatted}' (len {})",
formatted.len(),
);
assert_eq!(&formatted[4..5], "-", "Expected '-' at position 4");
assert_eq!(&formatted[7..8], "-", "Expected '-' at position 7");
assert_eq!(&formatted[10..11], " ", "Expected ' ' at position 10");
assert_eq!(&formatted[13..14], ":", "Expected ':' at position 13");
assert_eq!(&formatted[16..17], ":", "Expected ':' at position 16");
}
#[test]
fn display_epoch_timestamp() {
let ts = Timestamp::new(1970, 1, 1, 0, 0, 0);
assert_eq!(format!("{ts}"), "1970-01-01 00:00:00");
}
#[test]
fn display_zero_pads_single_digit_fields() {
let ts = Timestamp::new(2026, 3, 5, 8, 4, 9);
assert_eq!(format!("{ts}"), "2026-03-05 08:04:09");
}
#[test]
fn display_double_digit_fields() {
let ts = Timestamp::new(2026, 12, 31, 23, 59, 59);
assert_eq!(format!("{ts}"), "2026-12-31 23:59:59");
}
#[test]
fn display_from_unix_secs() {
let ts = Timestamp::from_unix_secs(0);
assert_eq!(format!("{ts}"), "1970-01-01 00:00:00");
}
#[test]
fn to_iso8601_epoch() {
let ts = Timestamp::from_unix_secs(0);
assert_eq!(ts.to_iso8601(), "1970-01-01T00:00:00Z");
}
#[test]
fn to_iso8601_known_date() {
let ts = Timestamp::from_unix_secs(1_735_689_600);
assert_eq!(ts.to_iso8601(), "2025-01-01T00:00:00Z");
}
#[test]
fn to_iso8601_with_time() {
let ts = Timestamp::from_unix_secs(1_771_684_245);
assert_eq!(ts.to_iso8601(), "2026-02-21T14:30:45Z");
}
#[test]
fn to_iso8601_sorts_lexicographically() {
let a = Timestamp::from_unix_secs(1_000_000);
let b = Timestamp::from_unix_secs(2_000_000);
assert!(a.to_iso8601() < b.to_iso8601());
}
#[test]
fn parse_iso8601_epoch() {
let ts = Timestamp::parse_iso8601("1970-01-01T00:00:00Z").unwrap();
assert_eq!(ts.unix_epoch_secs(), 0);
}
#[test]
fn parse_iso8601_known_date() {
let ts = Timestamp::parse_iso8601("2025-01-01T00:00:00Z").unwrap();
assert_eq!(ts.unix_epoch_secs(), 1_735_689_600);
assert_eq!(ts.year(), 2025);
assert_eq!(ts.month(), 1);
assert_eq!(ts.day(), 1);
}
#[test]
fn parse_iso8601_with_time() {
let ts = Timestamp::parse_iso8601("2026-02-21T14:30:45Z").unwrap();
assert_eq!(ts.unix_epoch_secs(), 1_771_684_245);
assert_eq!(ts.year(), 2026);
assert_eq!(ts.month(), 2);
assert_eq!(ts.day(), 21);
assert_eq!(ts.hour(), 14);
assert_eq!(ts.minute(), 30);
assert_eq!(ts.second(), 45);
}
#[test]
fn parse_iso8601_round_trips_with_to_iso8601() {
let values = [0_u64, 1, 86_400, 1_000_000, 1_735_689_600, 1_771_684_245];
for secs in values {
let original = Timestamp::from_unix_secs(secs);
let iso = original.to_iso8601();
let parsed = Timestamp::parse_iso8601(&iso).unwrap();
assert_eq!(
parsed.unix_epoch_secs(),
original.unix_epoch_secs(),
"round-trip failed for {secs} (iso={iso})",
);
}
}
#[test]
fn parse_iso8601_leap_day() {
let ts = Timestamp::parse_iso8601("2024-02-29T00:00:00Z").unwrap();
assert_eq!(ts.year(), 2024);
assert_eq!(ts.month(), 2);
assert_eq!(ts.day(), 29);
}
#[test]
fn parse_iso8601_end_of_day() {
let ts = Timestamp::parse_iso8601("2026-12-31T23:59:59Z").unwrap();
assert_eq!(ts.hour(), 23);
assert_eq!(ts.minute(), 59);
assert_eq!(ts.second(), 59);
}
#[test]
fn parse_iso8601_rejects_empty() {
assert!(Timestamp::parse_iso8601("").is_none());
}
#[test]
fn parse_iso8601_rejects_wrong_length() {
assert!(Timestamp::parse_iso8601("2025-01-01T00:00:00").is_none());
assert!(Timestamp::parse_iso8601("2025-01-01T00:00:00ZZ").is_none());
}
#[test]
fn parse_iso8601_normalizes_numeric_offsets() {
let z = Timestamp::parse_iso8601("2023-11-14T22:13:20Z").unwrap();
assert_eq!(
Timestamp::parse_iso8601("2023-11-14T22:13:20+00:00").unwrap(),
z
);
assert_eq!(
Timestamp::parse_iso8601("2023-11-14T23:13:20+01:00").unwrap(),
z
);
assert_eq!(
Timestamp::parse_iso8601("2023-11-14T16:43:20-05:30").unwrap(),
z
);
assert_eq!(
Timestamp::parse_iso8601("2023-11-14T23:13:20.5+01:00").unwrap(),
z
);
}
#[test]
fn parse_iso8601_rejects_malformed_offsets() {
assert!(Timestamp::parse_iso8601("2025-01-01T00:00:00+0100").is_none()); assert!(Timestamp::parse_iso8601("2025-01-01T00:00:00+24:00").is_none()); assert!(Timestamp::parse_iso8601("2025-01-01T00:00:00+01:60").is_none()); assert!(Timestamp::parse_iso8601("1970-01-01T00:00:00+01:00").is_none());
}
#[test]
fn parse_iso8601_accepts_fractional_seconds() {
let base = Timestamp::parse_iso8601("2026-02-21T14:30:45Z").unwrap();
for s in [
"2026-02-21T14:30:45.1Z",
"2026-02-21T14:30:45.123Z",
"2026-02-21T14:30:45.123456789Z",
] {
let ts = Timestamp::parse_iso8601(s).unwrap();
assert_eq!(ts.unix_epoch_secs(), base.unix_epoch_secs(), "for {s}");
}
}
#[test]
fn parse_iso8601_rejects_malformed_fractional_seconds() {
assert!(Timestamp::parse_iso8601("2026-02-21T14:30:45.Z").is_none());
assert!(Timestamp::parse_iso8601("2026-02-21T14:30:45.12aZ").is_none());
assert!(Timestamp::parse_iso8601("2026-02-21T14:30:45,5Z").is_none());
}
#[test]
fn parse_iso8601_rejects_bad_delimiters() {
assert!(Timestamp::parse_iso8601("2025-01-01 00:00:00Z").is_none());
assert!(Timestamp::parse_iso8601("2025/01/01T00:00:00Z").is_none());
}
#[test]
fn parse_iso8601_rejects_invalid_month() {
assert!(Timestamp::parse_iso8601("2025-00-01T00:00:00Z").is_none());
assert!(Timestamp::parse_iso8601("2025-13-01T00:00:00Z").is_none());
}
#[test]
fn parse_iso8601_rejects_invalid_day() {
assert!(Timestamp::parse_iso8601("2025-01-00T00:00:00Z").is_none());
assert!(Timestamp::parse_iso8601("2025-01-32T00:00:00Z").is_none());
}
#[test]
fn parse_iso8601_rejects_invalid_time() {
assert!(Timestamp::parse_iso8601("2025-01-01T24:00:00Z").is_none());
assert!(Timestamp::parse_iso8601("2025-01-01T00:60:00Z").is_none());
assert!(Timestamp::parse_iso8601("2025-01-01T00:00:60Z").is_none());
}
#[test]
fn parse_iso8601_rejects_non_numeric() {
assert!(Timestamp::parse_iso8601("abcd-ef-ghTij:kl:mnZ").is_none());
}
#[test]
fn parse_iso8601_rejects_pre_epoch_years() {
assert!(Timestamp::parse_iso8601("1969-12-31T23:59:59Z").is_none());
assert!(Timestamp::parse_iso8601("0001-01-01T00:00:00Z").is_none());
}
#[test]
fn parse_iso8601_accepts_epoch_boundary() {
let ts = Timestamp::parse_iso8601("1970-01-01T00:00:00Z").unwrap();
assert_eq!(ts.unix_epoch_secs(), 0);
}
#[test]
fn new_total_secs_consistent_with_ordering() {
let earlier = Timestamp::new(1970, 1, 1, 0, 0, 0);
let later = Timestamp::new(2026, 6, 19, 12, 0, 0);
assert!(earlier < later);
assert_ne!(earlier, later);
assert_eq!(Timestamp::from_unix_secs(later.unix_epoch_secs()), later);
}
#[test]
fn accessor_hour_minute_second() {
let ts = Timestamp::from_unix_secs(1_771_684_245);
assert_eq!(ts.hour(), 14);
assert_eq!(ts.minute(), 30);
assert_eq!(ts.second(), 45);
}
#[test]
#[allow(deprecated)]
fn deprecated_hours_minutes_seconds_still_work() {
let ts = Timestamp::from_unix_secs(1_771_684_245);
assert_eq!(ts.hours(), 14);
assert_eq!(ts.minutes(), 30);
assert_eq!(ts.seconds(), 45);
}
}