use core::fmt;
use time::{OffsetDateTime, format_description::FormatItem, macros::format_description};
const MIN_UNIX_NANOS: i128 = -377_705_116_800 * 1_000_000_000;
const MAX_UNIX_NANOS: i128 = 253_402_300_799 * 1_000_000_000 + 999_999_999;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("timestamp of {nanos}ns since the unix epoch is out of range")]
pub struct TimestampOutOfRange {
pub nanos: i128,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("timespec of {secs}s + {nsecs}ns is out of range")]
pub struct TimespecOutOfRange {
pub secs: i128,
pub nsecs: i128,
}
pub trait OffsetDateTimeExt {
fn from_unix_nanos(nanos: i128) -> Result<OffsetDateTime, TimestampOutOfRange>;
fn from_unix_nanos_i64(nanos: i64) -> OffsetDateTime;
fn from_unix_nanos_u64(nanos: u64) -> OffsetDateTime;
fn from_timespec(secs: i128, nsecs: i128) -> Result<OffsetDateTime, TimespecOutOfRange>;
fn try_unix_nanos_i64(self) -> Result<i64, TimestampOutOfRange>;
fn try_unix_nanos_u64(self) -> Result<u64, TimestampOutOfRange>;
fn saturating_duration_since(self, earlier: OffsetDateTime) -> std::time::Duration;
fn display(self) -> OffsetDateTimeDisplay;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OffsetDateTimeStyle {
#[default]
YmdHms,
YmdHm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OffsetDateTimeDisplay {
datetime: OffsetDateTime,
style: OffsetDateTimeStyle,
}
impl OffsetDateTimeDisplay {
#[must_use]
pub const fn ymd_hms(mut self) -> Self {
self.style = OffsetDateTimeStyle::YmdHms;
self
}
#[must_use]
pub const fn ymd_hm(mut self) -> Self {
self.style = OffsetDateTimeStyle::YmdHm;
self
}
}
impl fmt::Display for OffsetDateTimeDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let t = self.datetime;
let year = t.year();
if year < 0 {
write!(f, "-{:04}", -year)?;
} else {
write!(f, "{year:04}")?;
}
write!(
f,
"-{:02}-{:02} {:02}:{:02}",
t.month() as u8,
t.day(),
t.hour(),
t.minute()
)?;
match self.style {
OffsetDateTimeStyle::YmdHms => write!(f, ":{:02}", t.second()),
OffsetDateTimeStyle::YmdHm => Ok(()),
}
}
}
const _: () = assert!(
(i64::MIN as i128) >= MIN_UNIX_NANOS
&& (i64::MAX as i128) <= MAX_UNIX_NANOS
&& (u64::MAX as i128) <= MAX_UNIX_NANOS,
"the full i64/u64 nanosecond range must be representable as an OffsetDateTime"
);
impl OffsetDateTimeExt for OffsetDateTime {
#[allow(clippy::disallowed_methods)]
fn from_unix_nanos(nanos: i128) -> Result<OffsetDateTime, TimestampOutOfRange> {
if !(MIN_UNIX_NANOS..=MAX_UNIX_NANOS).contains(&nanos) {
return Err(TimestampOutOfRange { nanos });
}
OffsetDateTime::from_unix_timestamp_nanos(nanos).map_err(|_| TimestampOutOfRange { nanos })
}
fn from_unix_nanos_i64(nanos: i64) -> OffsetDateTime {
Self::from_unix_nanos(i128::from(nanos))
.expect("the full i64 nanosecond range is representable; asserted at compile time")
}
fn from_unix_nanos_u64(nanos: u64) -> OffsetDateTime {
Self::from_unix_nanos(i128::from(nanos))
.expect("the full u64 nanosecond range is representable; asserted at compile time")
}
fn from_timespec(secs: i128, nsecs: i128) -> Result<OffsetDateTime, TimespecOutOfRange> {
let out_of_range = || TimespecOutOfRange { secs, nsecs };
let nanos = secs
.checked_mul(1_000_000_000)
.and_then(|n| n.checked_add(nsecs))
.ok_or_else(out_of_range)?;
Self::from_unix_nanos(nanos).map_err(|_| out_of_range())
}
fn try_unix_nanos_i64(self) -> Result<i64, TimestampOutOfRange> {
let nanos = self.unix_timestamp_nanos();
i64::try_from(nanos).map_err(|_| TimestampOutOfRange { nanos })
}
fn try_unix_nanos_u64(self) -> Result<u64, TimestampOutOfRange> {
let nanos = self.unix_timestamp_nanos();
u64::try_from(nanos).map_err(|_| TimestampOutOfRange { nanos })
}
fn saturating_duration_since(self, earlier: OffsetDateTime) -> std::time::Duration {
std::time::Duration::try_from(self - earlier).unwrap_or_default()
}
fn display(self) -> OffsetDateTimeDisplay {
OffsetDateTimeDisplay {
datetime: self,
style: OffsetDateTimeStyle::default(),
}
}
}
pub static YMD_HMS: &[FormatItem<'_>] =
format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]");
pub static YMD_HM: &[FormatItem<'_>] =
format_description!("[year]-[month]-[day] [hour repr:24]:[minute]");
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use rstest::rstest;
use time::macros::datetime;
fn expected_nanos(secs: i128, nsecs: i128) -> Option<i128> {
secs.checked_mul(1_000_000_000)?.checked_add(nsecs)
}
#[rstest]
#[case::zero(0, 0, 0)]
#[case::whole_seconds(5, 0, 5_000_000_000)]
#[case::sub_second(1, 500_000_000, 1_500_000_000)]
#[case::carries_up(1, 2_500_000_000, 3_500_000_000)]
#[case::borrows_down(1, -500_000_000, 500_000_000)]
#[case::before_epoch(-1, 0, -1_000_000_000)]
fn from_timespec_sums_components(
#[case] secs: i128,
#[case] nsecs: i128,
#[case] expected: i128,
) {
let t = OffsetDateTime::from_timespec(secs, nsecs).expect("in range");
assert_eq!(t.unix_timestamp_nanos(), expected);
}
#[rstest]
#[case::max_instant(253_402_300_799, 999_999_999, true)]
#[case::one_nano_past_max(253_402_300_799, 1_000_000_000, false)]
#[case::one_second_past_max(253_402_300_800, 0, false)]
#[case::min_instant(-377_705_116_800, 0, true)]
#[case::one_nano_before_min(-377_705_116_800, -1, false)]
#[case::one_second_before_min(-377_705_116_801, 0, false)]
#[case::truncation_point_2_63(9_223_372_036_854_775_808, 0, false)]
#[case::truncation_point_2_64(18_446_744_073_709_551_616, 0, false)]
#[case::i128_overflow_high(i128::MAX, 0, false)]
#[case::i128_overflow_low(i128::MIN, 0, false)]
#[case::i128_overflow_nsecs(0, i128::MAX, false)]
fn from_timespec_range(#[case] secs: i128, #[case] nsecs: i128, #[case] representable: bool) {
assert_eq!(
OffsetDateTime::from_timespec(secs, nsecs).is_ok(),
representable
);
}
#[rstest]
#[case::min(i64::MIN)]
#[case::negative(-1)]
#[case::epoch(0)]
#[case::typical(1_639_162_832_500_000_000)]
#[case::max(i64::MAX)]
fn from_unix_nanos_i64_is_total(#[case] nanos: i64) {
assert_eq!(
OffsetDateTime::from_unix_nanos_i64(nanos).unix_timestamp_nanos(),
i128::from(nanos)
);
}
#[rstest]
#[case::zero(0)]
#[case::typical(1_639_162_832_500_000_000)]
#[case::max(u64::MAX)]
fn from_unix_nanos_u64_is_total(#[case] nanos: u64) {
assert_eq!(
OffsetDateTime::from_unix_nanos_u64(nanos).unix_timestamp_nanos(),
i128::from(nanos)
);
}
#[rstest]
#[case::an_hour(3_600, 0, 3_600)]
#[case::same_instant(0, 0, 0)]
#[case::future_clamps_to_zero(0, 3_600, 0)]
fn saturating_duration_since_clamps(
#[case] now_secs: i64,
#[case] earlier_secs: i64,
#[case] expected_secs: u64,
) {
let now = OffsetDateTime::from_unix_nanos_i64(now_secs * 1_000_000_000);
let earlier = OffsetDateTime::from_unix_nanos_i64(earlier_secs * 1_000_000_000);
assert_eq!(
now.saturating_duration_since(earlier).as_secs(),
expected_secs
);
}
#[rstest]
#[case::epoch(0)]
#[case::typical(1_639_162_832_500_000_000)]
#[case::max_i64(i64::MAX)]
fn try_unix_nanos_i64_round_trips(#[case] nanos: i64) {
let t = OffsetDateTime::from_unix_nanos_i64(nanos);
assert_eq!(t.try_unix_nanos_i64(), Ok(nanos));
}
#[rstest]
#[case::epoch(0)]
#[case::typical(1_639_162_832_500_000_000)]
#[case::max_u64(u64::MAX)]
fn try_unix_nanos_u64_round_trips(#[case] nanos: u64) {
let t = OffsetDateTime::from_unix_nanos_u64(nanos);
assert_eq!(t.try_unix_nanos_u64(), Ok(nanos));
}
#[test]
fn try_unix_nanos_u64_rejects_pre_epoch() {
let before_epoch = OffsetDateTime::from_unix_nanos_i64(-1);
assert!(before_epoch.try_unix_nanos_u64().is_err());
assert_eq!(before_epoch.try_unix_nanos_i64(), Ok(-1));
}
#[test]
fn datetime_formats_render_as_documented() {
let t = OffsetDateTime::from_unix_nanos_i64(1_705_934_107_000_000_000);
assert_eq!(t.display().ymd_hms().to_string(), "2024-01-22 14:35:07");
assert_eq!(t.display().ymd_hm().to_string(), "2024-01-22 14:35");
}
#[rstest]
#[case::epoch(OffsetDateTime::UNIX_EPOCH)]
#[case::single_digit_components(datetime!(2024-02-06 04:05:06 UTC))]
#[case::year_one(datetime!(0001-01-01 00:00:00 UTC))]
#[case::negative_year(datetime!(-0044-03-15 00:00:00 UTC))]
#[case::max(datetime!(9999-12-31 23:59:59 UTC))]
fn display_matches_the_format_descriptors(#[case] t: OffsetDateTime) {
assert_eq!(
t.display().ymd_hms().to_string(),
t.format(YMD_HMS).unwrap()
);
assert_eq!(t.display().ymd_hm().to_string(), t.format(YMD_HM).unwrap());
}
#[allow(clippy::disallowed_methods)]
#[test]
fn from_timespec_guards_a_hole_upstream_still_has() {
let two_pow_64: i128 = 1 << 64;
assert!(
OffsetDateTime::from_unix_timestamp_nanos(two_pow_64 * 1_000_000_000).is_ok(),
"precondition: upstream still wraps here"
);
assert!(OffsetDateTime::from_timespec(two_pow_64, 0).is_err());
}
proptest! {
#[test]
fn from_timespec_is_total(secs in any::<i128>(), nsecs in any::<i128>()) {
let result = OffsetDateTime::from_timespec(secs, nsecs);
match expected_nanos(secs, nsecs) {
None => prop_assert!(result.is_err()),
Some(nanos) => {
let representable = (MIN_UNIX_NANOS..=MAX_UNIX_NANOS).contains(&nanos);
prop_assert_eq!(result.is_ok(), representable);
if let Ok(t) = result {
prop_assert_eq!(t.unix_timestamp_nanos(), nanos);
}
}
}
}
#[allow(clippy::disallowed_methods)]
#[test]
fn from_timespec_agrees_with_upstream_in_the_sound_range(
secs in -400_000_000_000i128..=400_000_000_000,
nsecs in -2_000_000_000i128..=2_000_000_000,
) {
let combined = expected_nanos(secs, nsecs).expect("cannot overflow in this range");
prop_assert_eq!(
OffsetDateTime::from_timespec(secs, nsecs).ok(),
OffsetDateTime::from_unix_timestamp_nanos(combined).ok()
);
}
}
}