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}