Skip to main content

klauthed_core/time/
timestamp.rs

1//! The canonical UTC instant type, [`Timestamp`].
2
3use std::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use time::format_description::well_known::Rfc3339;
7use time::{Duration, OffsetDateTime, PrimitiveDateTime, UtcOffset};
8
9/// A point in time (UTC), the canonical instant type.
10///
11/// Serializes as a millisecond-precision RFC 3339 string with a `Z` UTC
12/// designator (e.g. `2023-11-14T22:13:20.000Z`).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct Timestamp(OffsetDateTime);
15
16impl Timestamp {
17    /// The current instant from the system clock.
18    ///
19    /// Prefer a [`Clock`](super::Clock) in code that should be testable; this is for the edges
20    /// (e.g. constructing the [`SystemClock`](super::SystemClock) itself).
21    pub fn now() -> Self {
22        Self(OffsetDateTime::now_utc())
23    }
24
25    /// Wrap a [`time::OffsetDateTime`], normalising it to UTC.
26    pub fn from_offset_datetime(dt: OffsetDateTime) -> Self {
27        Self(dt.to_offset(UtcOffset::UTC))
28    }
29
30    /// The underlying [`time::OffsetDateTime`] (always UTC).
31    pub const fn as_offset_datetime(&self) -> &OffsetDateTime {
32        &self.0
33    }
34
35    /// Consume into the underlying [`time::OffsetDateTime`] (always UTC).
36    pub const fn into_offset_datetime(self) -> OffsetDateTime {
37        self.0
38    }
39
40    /// Construct from milliseconds since the Unix epoch, or `None` if `millis`
41    /// falls outside the representable range (roughly years ±9999).
42    ///
43    /// Prefer this over [`from_unix_millis`](Self::from_unix_millis) when
44    /// `millis` is untrusted or computed and an out-of-range value should be
45    /// treated as an error rather than silently clamped.
46    pub fn from_unix_millis_opt(millis: i64) -> Option<Self> {
47        OffsetDateTime::from_unix_timestamp_nanos(millis as i128 * 1_000_000).ok().map(Self)
48    }
49
50    /// Construct from milliseconds since the Unix epoch.
51    ///
52    /// **Saturating:** a `millis` value outside the representable range is
53    /// clamped to the earliest or latest representable instant, *preserving
54    /// order* — a far-future overflow stays in the far future and a far-past
55    /// underflow stays in the far past; neither collapses to "now". Use
56    /// [`from_unix_millis_opt`](Self::from_unix_millis_opt) to detect
57    /// out-of-range input instead of saturating.
58    pub fn from_unix_millis(millis: i64) -> Self {
59        Self::from_unix_millis_opt(millis).unwrap_or(Self::saturated(millis >= 0))
60    }
61
62    /// Construct from seconds since the Unix epoch, or `None` if `secs` falls
63    /// outside the representable range.
64    pub fn from_unix_seconds_opt(secs: i64) -> Option<Self> {
65        OffsetDateTime::from_unix_timestamp(secs).ok().map(Self)
66    }
67
68    /// Construct from seconds since the Unix epoch, saturating on out-of-range
69    /// input (see [`from_unix_millis`](Self::from_unix_millis)).
70    pub fn from_unix_seconds(secs: i64) -> Self {
71        Self::from_unix_seconds_opt(secs).unwrap_or(Self::saturated(secs >= 0))
72    }
73
74    /// The latest or earliest representable instant, used as the saturation
75    /// target for out-of-range conversions.
76    fn saturated(non_negative: bool) -> Self {
77        if non_negative {
78            Self(PrimitiveDateTime::MAX.assume_utc())
79        } else {
80            Self(PrimitiveDateTime::MIN.assume_utc())
81        }
82    }
83
84    /// Milliseconds since the Unix epoch.
85    pub fn unix_millis(&self) -> i64 {
86        (self.0.unix_timestamp_nanos() / 1_000_000) as i64
87    }
88
89    /// Whole seconds since the Unix epoch.
90    pub fn unix_seconds(&self) -> i64 {
91        self.0.unix_timestamp()
92    }
93
94    /// RFC 3339 / ISO 8601 representation (millisecond precision, `Z` suffix).
95    #[allow(
96        clippy::expect_used,
97        reason = "formatting an in-range UTC value with a static format description is infallible"
98    )]
99    pub fn to_rfc3339(&self) -> String {
100        // Fixed format matches the historical wire contract: UTC `Z` designator
101        // with exactly three subsecond digits.
102        let fmt = time::macros::format_description!(
103            "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
104        );
105        self.0.format(fmt).expect("formatting a UTC timestamp with a fixed description cannot fail")
106    }
107
108    /// Parse an RFC 3339 / ISO 8601 timestamp (e.g. from [`to_rfc3339`]), or
109    /// `None` if it is malformed. The inverse of [`to_rfc3339`].
110    ///
111    /// [`to_rfc3339`]: Timestamp::to_rfc3339
112    #[must_use]
113    pub fn parse_rfc3339(s: &str) -> Option<Self> {
114        OffsetDateTime::parse(s, &Rfc3339).map(Self::from_offset_datetime).ok()
115    }
116
117    /// The signed duration elapsed since `earlier` (negative if `earlier` is later).
118    pub fn duration_since(&self, earlier: Timestamp) -> Duration {
119        self.0 - earlier.0
120    }
121
122    /// This instant shifted by `delta`, or `None` on over/underflow.
123    pub fn checked_add(&self, delta: Duration) -> Option<Timestamp> {
124        self.0.checked_add(delta).map(Timestamp)
125    }
126}
127
128impl fmt::Display for Timestamp {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.write_str(&self.to_rfc3339())
131    }
132}
133
134impl From<OffsetDateTime> for Timestamp {
135    fn from(dt: OffsetDateTime) -> Self {
136        Self::from_offset_datetime(dt)
137    }
138}
139
140impl From<Timestamp> for OffsetDateTime {
141    fn from(ts: Timestamp) -> Self {
142        ts.0
143    }
144}
145
146impl Serialize for Timestamp {
147    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
148        // Full-precision RFC 3339 (`Z`, subsecond digits as needed) so
149        // serialization is lossless and round-trips exactly. `to_rfc3339` is the
150        // millisecond-precision *human* format and is intentionally separate.
151        let s = self.0.format(&Rfc3339).map_err(serde::ser::Error::custom)?;
152        serializer.serialize_str(&s)
153    }
154}
155
156impl<'de> Deserialize<'de> for Timestamp {
157    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
158        let s = String::deserialize(deserializer)?;
159        OffsetDateTime::parse(&s, &Rfc3339)
160            .map(Self::from_offset_datetime)
161            .map_err(serde::de::Error::custom)
162    }
163}