Skip to main content

async_nats/
datetime.rs

1// Copyright 2020-2026 The NATS Authors
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4// You may obtain a copy of the License at
5//
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14//! Datetime abstraction over the `time` and `chrono` backends.
15//!
16//! `async-nats` exposes datetimes on its JetStream and Service APIs through the
17//! [`DateTime`] alias. The concrete type is selected by the `chrono` feature:
18//!
19//! - By default, [`DateTime`] is `time::OffsetDateTime`.
20//! - With the `chrono` feature enabled, [`DateTime`] is
21//!   `chrono::DateTime<chrono::Utc>`.
22//!
23//! Note: the `chrono` feature selects the backend for the whole build. Because
24//! Cargo unifies features across the dependency graph, enabling `chrono`
25//! anywhere in the graph switches the public type to chrono for every consumer
26//! of `async-nats` in that build. If you depend on the default `time` backend,
27//! be aware a transitive dependency that enables `async-nats/chrono` will
28//! change [`DateTime`] to the chrono type.
29//!
30//! The chrono backend also stores each instant as an `i64` nanosecond count
31//! (range roughly the years 1678 to 2262), narrower than the `time` backend;
32//! under chrono, [`from_nanos`] returns an error for values outside that range.
33
34/// The datetime type used across the JetStream and Service APIs.
35///
36/// Defaults to `time::OffsetDateTime`; becomes `chrono::DateTime<chrono::Utc>`
37/// when the `chrono` feature is enabled. Enabling `chrono` anywhere in the
38/// dependency graph changes this type for the whole build; see the [module
39/// docs](self) for the feature-unification caveat.
40#[cfg(not(feature = "chrono"))]
41pub type DateTime = time::OffsetDateTime;
42
43/// The datetime type used across the JetStream and Service APIs.
44///
45/// Defaults to `time::OffsetDateTime`; becomes `chrono::DateTime<chrono::Utc>`
46/// when the `chrono` feature is enabled. Enabling `chrono` anywhere in the
47/// dependency graph changes this type for the whole build; see the [module
48/// docs](self) for the feature-unification caveat.
49#[cfg(feature = "chrono")]
50pub type DateTime = chrono::DateTime<chrono::Utc>;
51
52/// Parse an RFC 3339 timestamp into a [`DateTime`] (normalized to UTC).
53#[cfg(not(feature = "chrono"))]
54pub fn parse_rfc3339(s: &str) -> Result<DateTime, crate::Error> {
55    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
56        // Normalize to UTC so both backends agree on the emitted offset (`Z`).
57        .map(|dt| dt.to_offset(time::UtcOffset::UTC))
58        .map_err(|e| e.into())
59}
60
61/// Parse an RFC 3339 timestamp into a [`DateTime`] (normalized to UTC).
62#[cfg(feature = "chrono")]
63pub fn parse_rfc3339(s: &str) -> Result<DateTime, crate::Error> {
64    chrono::DateTime::parse_from_rfc3339(s)
65        .map(|dt| dt.with_timezone(&chrono::Utc))
66        .map_err(|e| e.into())
67}
68
69/// Convert a Unix timestamp in nanoseconds to a [`DateTime`].
70#[cfg(not(feature = "chrono"))]
71pub fn from_nanos(nanos: i128) -> Result<DateTime, crate::Error> {
72    time::OffsetDateTime::from_unix_timestamp_nanos(nanos).map_err(|e| e.into())
73}
74
75/// Convert a Unix timestamp in nanoseconds to a [`DateTime`].
76///
77/// Note: the `chrono` backend represents the nanosecond timestamp as an `i64`,
78/// so values outside roughly the years 1678 to 2262 are rejected. The `time`
79/// backend accepts the full `i128` range.
80#[cfg(feature = "chrono")]
81pub fn from_nanos(nanos: i128) -> Result<DateTime, crate::Error> {
82    let nanos_i64: i64 = nanos.try_into().map_err(|_| {
83        std::io::Error::new(
84            std::io::ErrorKind::InvalidInput,
85            "nanoseconds value out of range for the chrono backend (supports ~1678 to 2262)",
86        )
87    })?;
88    // Use Euclidean division so that negative (pre-1970) timestamps split into a
89    // non-negative sub-second component, matching the `time` backend's behavior.
90    let secs = nanos_i64.div_euclid(1_000_000_000);
91    let nsecs = nanos_i64.rem_euclid(1_000_000_000) as u32;
92    chrono::DateTime::from_timestamp(secs, nsecs).ok_or_else(|| {
93        Box::new(std::io::Error::new(
94            std::io::ErrorKind::InvalidInput,
95            "invalid timestamp",
96        )) as crate::Error
97    })
98}
99
100/// Return the current time as a UTC [`DateTime`].
101#[cfg(not(feature = "chrono"))]
102pub fn now() -> DateTime {
103    time::OffsetDateTime::now_utc()
104}
105
106/// Return the current time as a UTC [`DateTime`].
107#[cfg(feature = "chrono")]
108pub fn now() -> DateTime {
109    chrono::Utc::now()
110}
111
112// Internal serde adapter backing the `#[serde(with = "rfc3339")]` field
113// attributes. Kept crate-private because its public shape differs per backend
114// (a re-export under `time`, a hand-written module under `chrono`).
115#[cfg(not(feature = "chrono"))]
116pub(crate) use time::serde::rfc3339;
117
118// Some serialize/deserialize fns here are unused in feature combinations that
119// enable `chrono` without a field that consumes the adapter (e.g. `chrono` alone,
120// or `chrono` + only `service`, which never uses the `option` variant). The time
121// arm above is a re-export and is dead-code-exempt; this hand-written module is not.
122#[cfg(feature = "chrono")]
123#[allow(dead_code)]
124pub(crate) mod rfc3339 {
125    use serde::{Deserialize, Deserializer, Serialize, Serializer};
126    pub(crate) fn serialize<S>(
127        dt: &chrono::DateTime<chrono::Utc>,
128        serializer: S,
129    ) -> Result<S::Ok, S::Error>
130    where
131        S: Serializer,
132    {
133        Serialize::serialize(dt, serializer)
134    }
135    pub(crate) fn deserialize<'de, D>(
136        deserializer: D,
137    ) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
138    where
139        D: Deserializer<'de>,
140    {
141        Deserialize::deserialize(deserializer)
142    }
143
144    pub(crate) mod option {
145        use serde::{Deserialize, Deserializer, Serialize, Serializer};
146        pub(crate) fn serialize<S>(
147            dt: &Option<chrono::DateTime<chrono::Utc>>,
148            serializer: S,
149        ) -> Result<S::Ok, S::Error>
150        where
151            S: Serializer,
152        {
153            Serialize::serialize(dt, serializer)
154        }
155        pub(crate) fn deserialize<'de, D>(
156            deserializer: D,
157        ) -> Result<Option<chrono::DateTime<chrono::Utc>>, D::Error>
158        where
159            D: Deserializer<'de>,
160        {
161            Deserialize::deserialize(deserializer)
162        }
163    }
164}
165
166/// Add a [`std::time::Duration`] to a [`DateTime`].
167///
168/// Returns an error if the duration is too large to be represented by the
169/// duration type of the active backend.
170#[cfg(not(feature = "chrono"))]
171pub fn add_std_duration(
172    dt: DateTime,
173    duration: std::time::Duration,
174) -> Result<DateTime, crate::Error> {
175    let time_duration = time::Duration::try_from(duration)?;
176    dt.checked_add(time_duration).ok_or_else(|| {
177        Box::new(std::io::Error::new(
178            std::io::ErrorKind::InvalidInput,
179            "datetime overflow",
180        )) as crate::Error
181    })
182}
183
184/// Add a [`std::time::Duration`] to a [`DateTime`].
185///
186/// Returns an error if the duration is too large to be represented by the
187/// duration type of the active backend.
188#[cfg(feature = "chrono")]
189pub fn add_std_duration(
190    dt: DateTime,
191    duration: std::time::Duration,
192) -> Result<DateTime, crate::Error> {
193    let chrono_duration = chrono::Duration::from_std(duration)?;
194    dt.checked_add_signed(chrono_duration).ok_or_else(|| {
195        Box::new(std::io::Error::new(
196            std::io::ErrorKind::InvalidInput,
197            "datetime overflow",
198        )) as crate::Error
199    })
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use serde::{Deserialize, Serialize};
206
207    #[derive(Serialize, Deserialize)]
208    struct Wrap(#[serde(with = "rfc3339")] DateTime);
209
210    #[derive(Serialize, Deserialize, Default)]
211    struct WrapOpt(
212        #[serde(
213            default,
214            with = "rfc3339::option",
215            skip_serializing_if = "Option::is_none"
216        )]
217        Option<DateTime>,
218    );
219
220    // A fixed instant with a full 9-significant-digit fraction:
221    // 2023-01-02T03:04:05.123456789Z
222    const FIXED_NANOS: i128 = 1_672_628_645_123_456_789;
223    const FIXED_RFC3339: &str = "2023-01-02T03:04:05.123456789Z";
224
225    // With all 9 fraction digits significant, `time` and `chrono` emit identical
226    // bytes; this pins that case for whichever backend is built. (They diverge on
227    // trailing zeros — see `rfc3339_trailing_zero_fractions_are_instant_equal`.)
228    #[test]
229    fn rfc3339_full_precision_is_byte_identical() {
230        let dt = from_nanos(FIXED_NANOS).unwrap();
231        let json = serde_json::to_string(&Wrap(dt)).unwrap();
232        assert_eq!(json, format!("\"{FIXED_RFC3339}\""));
233    }
234
235    #[test]
236    fn rfc3339_roundtrips() {
237        let dt = from_nanos(FIXED_NANOS).unwrap();
238        let json = serde_json::to_string(&Wrap(dt)).unwrap();
239        let back: Wrap = serde_json::from_str(&json).unwrap();
240        assert_eq!(serde_json::to_string(&back).unwrap(), json);
241    }
242
243    #[test]
244    fn parse_rfc3339_matches_from_nanos() {
245        let parsed = parse_rfc3339(FIXED_RFC3339).unwrap();
246        let from_ts = from_nanos(FIXED_NANOS).unwrap();
247        assert_eq!(parsed, from_ts);
248    }
249
250    // The NATS server emits timestamps via Go's RFC3339Nano, which trims trailing
251    // zeros from the fractional part (and omits it entirely on a whole second).
252    // Both backends must accept every such shape and decode to the same instant.
253    #[test]
254    fn parse_rfc3339_server_format_variants() {
255        let cases: &[(&str, i128)] = &[
256            // no fractional seconds
257            ("2023-01-02T03:04:05Z", 1_672_628_645_000_000_000),
258            // millisecond precision
259            ("2023-01-02T03:04:05.123Z", 1_672_628_645_123_000_000),
260            // microsecond precision
261            ("2023-01-02T03:04:05.123456Z", 1_672_628_645_123_456_000),
262            // full nanosecond precision
263            ("2023-01-02T03:04:05.123456789Z", 1_672_628_645_123_456_789),
264        ];
265        for (s, nanos) in cases {
266            let parsed = parse_rfc3339(s).unwrap_or_else(|e| panic!("parse {s:?}: {e}"));
267            assert_eq!(
268                parsed,
269                from_nanos(*nanos).unwrap(),
270                "instant mismatch for {s:?}"
271            );
272            // serde deserialize path must accept the same wire shapes.
273            let de: Wrap = serde_json::from_str(&format!("\"{s}\""))
274                .unwrap_or_else(|e| panic!("serde deserialize {s:?}: {e}"));
275            assert_eq!(
276                de.0,
277                from_nanos(*nanos).unwrap(),
278                "serde mismatch for {s:?}"
279            );
280        }
281    }
282
283    // The backends serialize trailing-zero fractions differently (e.g. `time`
284    // emits `.12Z`, `chrono` emits `.120Z`), but both must re-serialize to a form
285    // the other and the server accept, and both must round-trip to the same instant.
286    #[test]
287    fn rfc3339_trailing_zero_fractions_are_instant_equal() {
288        for nanos in [
289            1_672_628_645_120_000_000, // .12
290            1_672_628_645_100_000_000, // .1
291            1_672_628_645_000_000_000, // whole second
292        ] {
293            let dt = from_nanos(nanos).unwrap();
294            let json = serde_json::to_string(&Wrap(dt)).unwrap();
295            let back: Wrap = serde_json::from_str(&json).unwrap();
296            assert_eq!(
297                back.0,
298                from_nanos(nanos).unwrap(),
299                "instant changed for {nanos}"
300            );
301            // And the emitted form must itself parse back to the same instant.
302            let s = json.trim_matches('"');
303            assert_eq!(parse_rfc3339(s).unwrap(), from_nanos(nanos).unwrap());
304        }
305    }
306
307    // Negative (pre-1970) sub-second timestamps must decode identically on both
308    // backends. Regression guard for the chrono `div_euclid`/`rem_euclid` split.
309    #[test]
310    fn from_nanos_handles_negative_sub_second() {
311        let cases: &[(i128, &str)] = &[
312            (-1, "1969-12-31T23:59:59.999999999Z"),
313            (-1_500_000_000, "1969-12-31T23:59:58.500000000Z"),
314            (-1_000_000_000, "1969-12-31T23:59:59Z"),
315        ];
316        for (nanos, expected) in cases {
317            let dt = from_nanos(*nanos).unwrap_or_else(|e| panic!("from_nanos({nanos}): {e}"));
318            assert_eq!(dt, parse_rfc3339(expected).unwrap(), "mismatch for {nanos}");
319        }
320    }
321
322    // The `rfc3339::option` adapter: exercise Some, explicit null, and an absent field.
323    #[test]
324    fn rfc3339_option_serde() {
325        let some = WrapOpt(Some(from_nanos(FIXED_NANOS).unwrap()));
326        let json = serde_json::to_string(&some).unwrap();
327        assert_eq!(json, format!("\"{FIXED_RFC3339}\""));
328        let back: WrapOpt = serde_json::from_str(&json).unwrap();
329        assert_eq!(back.0, Some(from_nanos(FIXED_NANOS).unwrap()));
330
331        let null: WrapOpt = serde_json::from_str("null").unwrap();
332        assert_eq!(null.0, None);
333
334        // Absent field with #[serde(default)] decodes to None.
335        #[derive(Deserialize)]
336        struct Outer {
337            #[serde(default, with = "rfc3339::option")]
338            ts: Option<DateTime>,
339        }
340        let outer: Outer = serde_json::from_str("{}").unwrap();
341        assert_eq!(outer.ts, None);
342    }
343
344    // A non-UTC offset input must be normalized to the same instant as its UTC form.
345    #[test]
346    fn parse_rfc3339_normalizes_offset() {
347        // 03:04:05+02:00 == 01:04:05Z
348        let with_offset = parse_rfc3339("2023-01-02T03:04:05+02:00").unwrap();
349        let utc = parse_rfc3339("2023-01-02T01:04:05Z").unwrap();
350        assert_eq!(with_offset, utc);
351    }
352
353    // Compile-time proof of the backend selection: `time` by default, `chrono`
354    // when the feature is enabled.
355    #[cfg(not(feature = "chrono"))]
356    #[test]
357    fn datetime_is_time_by_default() {
358        fn assert_offset_date_time(_: time::OffsetDateTime) {}
359        assert_offset_date_time(now());
360    }
361
362    #[cfg(feature = "chrono")]
363    #[test]
364    fn datetime_is_chrono_when_enabled() {
365        fn assert_chrono(_: chrono::DateTime<chrono::Utc>) {}
366        assert_chrono(now());
367    }
368}