Skip to main content

buffa_types/
timestamp_chrono.rs

1//! `chrono` interop for [`google::protobuf::Timestamp`](crate::google::protobuf::Timestamp).
2//!
3//! Enabled with the `chrono` Cargo feature. `no_std`-compatible — `chrono` is
4//! pulled in with `default-features = false`.
5
6use crate::google::protobuf::Timestamp;
7use crate::timestamp_ext::{TimestampError, NANOS_MAX};
8
9#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
10impl<Tz: chrono::TimeZone> From<chrono::DateTime<Tz>> for Timestamp {
11    /// Convert a [`chrono::DateTime`] in any time zone to a protobuf
12    /// [`Timestamp`].
13    ///
14    /// Infallible. The instant is preserved — proto `Timestamp` is always
15    /// UTC, so the offset is folded in (a `DateTime<FixedOffset>` of
16    /// `2023-11-15T03:13:20+05:00` and a `DateTime<Utc>` of
17    /// `2023-11-14T22:13:20Z` produce the same `Timestamp`).
18    ///
19    /// # Examples
20    ///
21    /// ```
22    /// use buffa_types::Timestamp;
23    /// use chrono::{DateTime, Utc};
24    ///
25    /// let dt = DateTime::<Utc>::from_timestamp(1_700_000_000, 123_456_789).unwrap();
26    /// let ts: Timestamp = dt.into();
27    /// assert_eq!(ts.seconds, 1_700_000_000);
28    /// assert_eq!(ts.nanos, 123_456_789);
29    /// ```
30    ///
31    /// # Warning: proto JSON spec range
32    ///
33    /// `chrono::DateTime` supports years up to ~262143, while the proto
34    /// JSON spec restricts `Timestamp` to years 0001–9999. A `DateTime`
35    /// outside that range converts without error here, but the resulting
36    /// `Timestamp` will fail JSON serialization (`json` feature).
37    ///
38    /// # Leap seconds
39    ///
40    /// A leap-second `DateTime` (constructed via
41    /// `NaiveTime::from_hms_nano_opt(_, _, 59, 1_000_000_000)`) reports
42    /// `timestamp_subsec_nanos()` of `1_000_000_000`, which exceeds the proto
43    /// `Timestamp.nanos` upper bound of `999_999_999`. The conversion clamps
44    /// the nanos field to `999_999_999` — the leap second collapses to the
45    /// final representable nanosecond of the same POSIX second
46    /// (`23:59:59.999999999`).
47    fn from(dt: chrono::DateTime<Tz>) -> Self {
48        Self {
49            seconds: dt.timestamp(),
50            // `timestamp_subsec_nanos` returns `[0, 999_999_999]` outside a
51            // leap second, and `1_000_000_000` inside one. Clamping keeps the
52            // proto invariant `nanos ∈ [0, 999_999_999]` intact.
53            nanos: dt.timestamp_subsec_nanos().min(NANOS_MAX as u32) as i32,
54            ..Default::default()
55        }
56    }
57}
58
59#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
60impl TryFrom<Timestamp> for chrono::DateTime<chrono::Utc> {
61    type Error = TimestampError;
62
63    /// Convert a protobuf [`Timestamp`] to a [`chrono::DateTime<Utc>`](chrono::DateTime).
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use buffa_types::Timestamp;
69    /// use chrono::{DateTime, Utc};
70    ///
71    /// let ts = Timestamp {
72    ///     seconds: 1_700_000_000,
73    ///     nanos: 0,
74    ///     ..Default::default()
75    /// };
76    /// let dt: DateTime<Utc> = ts.try_into().unwrap();
77    /// assert_eq!(dt.timestamp(), 1_700_000_000);
78    /// ```
79    ///
80    /// # Errors
81    ///
82    /// Returns [`TimestampError::InvalidNanos`] if `nanos` is outside
83    /// `[0, 999_999_999]`, or [`TimestampError::Overflow`] if the value is
84    /// outside the range `chrono::DateTime<Utc>` can represent.
85    fn try_from(ts: Timestamp) -> Result<Self, Self::Error> {
86        if ts.nanos < 0 || ts.nanos > NANOS_MAX {
87            return Err(TimestampError::InvalidNanos);
88        }
89        // MSRV: `i32::cast_unsigned` requires 1.87. The range check above
90        // guarantees `nanos` is non-negative, so the `as` cast is value-preserving.
91        #[allow(clippy::cast_sign_loss)]
92        Self::from_timestamp(ts.seconds, ts.nanos as u32).ok_or(TimestampError::Overflow)
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use chrono::{DateTime, TimeZone, Utc};
100
101    #[test]
102    fn datetime_post_epoch_roundtrip() {
103        let dt = Utc.with_ymd_and_hms(2023, 11, 14, 22, 13, 20).unwrap()
104            + chrono::TimeDelta::nanoseconds(123_456_789);
105        let ts: Timestamp = dt.into();
106        assert_eq!(ts.seconds, 1_700_000_000);
107        assert_eq!(ts.nanos, 123_456_789);
108        let back: DateTime<Utc> = ts.try_into().unwrap();
109        assert_eq!(back, dt);
110    }
111
112    #[test]
113    fn datetime_epoch_roundtrip() {
114        let dt = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
115        let ts: Timestamp = dt.into();
116        assert_eq!(ts.seconds, 0);
117        assert_eq!(ts.nanos, 0);
118        let back: DateTime<Utc> = ts.try_into().unwrap();
119        assert_eq!(back, dt);
120    }
121
122    #[test]
123    fn datetime_pre_epoch_roundtrip() {
124        // 1.5 seconds before epoch. chrono normalises to (secs=-2, nanos=500_000_000)
125        // internally, which matches the proto `Timestamp` convention exactly
126        // (nanos always non-negative).
127        let dt = DateTime::<Utc>::from_timestamp(-2, 500_000_000).unwrap();
128        let ts: Timestamp = dt.into();
129        assert_eq!(ts.seconds, -2);
130        assert_eq!(ts.nanos, 500_000_000);
131        let back: DateTime<Utc> = ts.try_into().unwrap();
132        assert_eq!(back, dt);
133    }
134
135    #[test]
136    fn datetime_fixed_offset_preserves_instant() {
137        use chrono::FixedOffset;
138        // 2023-11-15T03:13:20+05:00 is the same instant as 2023-11-14T22:13:20Z.
139        let tz = FixedOffset::east_opt(5 * 3600).unwrap();
140        let dt = tz.with_ymd_and_hms(2023, 11, 15, 3, 13, 20).unwrap();
141        let ts: Timestamp = dt.into();
142        assert_eq!(ts.seconds, 1_700_000_000);
143        assert_eq!(ts.nanos, 0);
144
145        let utc_equivalent = Utc.with_ymd_and_hms(2023, 11, 14, 22, 13, 20).unwrap();
146        assert_eq!(ts, Timestamp::from(utc_equivalent));
147    }
148
149    #[test]
150    fn nanos_upper_boundary_accepted() {
151        let ts = Timestamp {
152            seconds: 0,
153            nanos: 999_999_999,
154            ..Default::default()
155        };
156        let dt: DateTime<Utc> = ts.try_into().expect("upper boundary must convert");
157        assert_eq!(dt.timestamp_subsec_nanos(), 999_999_999);
158    }
159
160    #[test]
161    fn invalid_nanos_rejected() {
162        let ts = Timestamp {
163            seconds: 0,
164            nanos: -1,
165            ..Default::default()
166        };
167        let result: Result<DateTime<Utc>, _> = ts.try_into();
168        assert_eq!(result, Err(TimestampError::InvalidNanos));
169
170        let ts2 = Timestamp {
171            seconds: 0,
172            nanos: 1_000_000_000,
173            ..Default::default()
174        };
175        let result2: Result<DateTime<Utc>, _> = ts2.try_into();
176        assert_eq!(result2, Err(TimestampError::InvalidNanos));
177    }
178
179    #[test]
180    fn out_of_range_seconds_is_overflow() {
181        // `chrono::DateTime<Utc>` tops out around year 262143; i64::MAX seconds
182        // is far beyond that.
183        let ts = Timestamp {
184            seconds: i64::MAX,
185            nanos: 0,
186            ..Default::default()
187        };
188        let result: Result<DateTime<Utc>, _> = ts.try_into();
189        assert_eq!(result, Err(TimestampError::Overflow));
190    }
191
192    #[test]
193    fn i64_min_seconds_is_overflow_not_panic() {
194        let ts = Timestamp {
195            seconds: i64::MIN,
196            nanos: 0,
197            ..Default::default()
198        };
199        let result: Result<DateTime<Utc>, _> = ts.try_into();
200        assert_eq!(result, Err(TimestampError::Overflow));
201    }
202
203    #[test]
204    fn leap_second_datetime_clamps_nanos() {
205        // A leap-second NaiveDateTime returns `timestamp_subsec_nanos() == 1_000_000_000`,
206        // which is outside the proto `nanos ∈ [0, 999_999_999]` invariant. The
207        // `From` impl clamps to `999_999_999` so the resulting `Timestamp` stays
208        // valid (and round-trips via `TryFrom`).
209        use chrono::NaiveDate;
210        let leap = NaiveDate::from_ymd_opt(2016, 12, 31)
211            .unwrap()
212            .and_hms_nano_opt(23, 59, 59, 1_000_000_000)
213            .expect("leap-second construction");
214        let dt = DateTime::<Utc>::from_naive_utc_and_offset(leap, Utc);
215        assert_eq!(dt.timestamp_subsec_nanos(), 1_000_000_000);
216
217        let ts: Timestamp = dt.into();
218        assert!(
219            (0..=999_999_999).contains(&ts.nanos),
220            "nanos must stay within proto invariant: got {}",
221            ts.nanos
222        );
223        assert_eq!(ts.nanos, 999_999_999);
224        // Reverse direction must succeed.
225        let _: DateTime<Utc> = ts.try_into().expect("clamped Timestamp must convert");
226    }
227}