buffa_types/duration_chrono.rs
1//! `chrono` interop for [`google::protobuf::Duration`](crate::google::protobuf::Duration).
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::Duration;
7
8/// Errors that can occur when converting a protobuf [`Duration`] to a
9/// [`chrono::TimeDelta`].
10///
11/// Distinct from [`crate::duration_ext::DurationError`] because `TimeDelta`'s
12/// representable range (`±i64::MAX` milliseconds) is narrower than proto
13/// `Duration`'s, so this conversion has an `Overflow` failure mode that
14/// `std::time::Duration` does not.
15///
16/// This enum is `#[non_exhaustive]` (unlike the older `DurationError` /
17/// `TimestampError`, which predate that convention): `match` arms over it
18/// must include a wildcard arm.
19#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
21#[non_exhaustive]
22pub enum DurationChronoError {
23 /// The `nanos` field is outside `[-999_999_999, 999_999_999]` or its sign
24 /// is inconsistent with `seconds`.
25 #[error("nanos field has invalid value or sign mismatch with seconds")]
26 InvalidNanos,
27 /// The duration exceeds `chrono::TimeDelta`'s representable range
28 /// (`±i64::MAX` milliseconds).
29 #[error("duration is out of range for chrono::TimeDelta")]
30 Overflow,
31}
32
33#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
34impl From<chrono::TimeDelta> for Duration {
35 /// Convert a [`chrono::TimeDelta`] to a protobuf [`Duration`].
36 ///
37 /// Both sides represent a signed duration as `seconds` + `subsec_nanos`
38 /// with sign-consistent components, so this is a direct field copy.
39 ///
40 /// # Warning: proto JSON spec range
41 ///
42 /// `chrono::TimeDelta` ranges to ±`i64::MAX` milliseconds (~9.2e15
43 /// seconds), while the proto spec restricts `Duration` to
44 /// ±315,576,000,000 seconds (~10,000 years). A `TimeDelta` beyond that
45 /// converts without error here — binary encoding round-trips it — but
46 /// the resulting `Duration` will fail JSON serialization (`json`
47 /// feature), which enforces the spec range.
48 ///
49 /// # Examples
50 ///
51 /// ```
52 /// use buffa_types::Duration;
53 /// use chrono::TimeDelta;
54 ///
55 /// let proto: Duration = TimeDelta::milliseconds(1_500).into();
56 /// assert_eq!(proto.seconds, 1);
57 /// assert_eq!(proto.nanos, 500_000_000);
58 /// ```
59 fn from(d: chrono::TimeDelta) -> Self {
60 Self {
61 seconds: d.num_seconds(),
62 // `TimeDelta::subsec_nanos` is signed and shares the duration's
63 // overall sign, matching the proto Duration convention.
64 nanos: d.subsec_nanos(),
65 ..Default::default()
66 }
67 }
68}
69
70#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
71impl TryFrom<Duration> for chrono::TimeDelta {
72 type Error = DurationChronoError;
73
74 /// Convert a protobuf [`Duration`] to a [`chrono::TimeDelta`].
75 ///
76 /// # Examples
77 ///
78 /// ```
79 /// use buffa_types::{Duration, DurationChronoError};
80 /// use chrono::TimeDelta;
81 ///
82 /// let proto = Duration {
83 /// seconds: 2,
84 /// nanos: 250_000_000,
85 /// ..Default::default()
86 /// };
87 /// let td: TimeDelta = proto.try_into().unwrap();
88 /// assert_eq!(td, TimeDelta::milliseconds(2_250));
89 ///
90 /// let too_big = Duration {
91 /// seconds: i64::MAX,
92 /// nanos: 0,
93 /// ..Default::default()
94 /// };
95 /// assert_eq!(
96 /// TimeDelta::try_from(too_big),
97 /// Err(DurationChronoError::Overflow)
98 /// );
99 /// ```
100 ///
101 /// # Errors
102 ///
103 /// Returns [`DurationChronoError::InvalidNanos`] if `nanos` is outside
104 /// `[-999_999_999, 999_999_999]` or if its sign is inconsistent with
105 /// `seconds`. Such values never come from the [`From<chrono::TimeDelta>`]
106 /// impl, but `seconds` and `nanos` are independent wire fields, so a
107 /// decoded `Duration` can carry any combination — the proto spec declares
108 /// sign-mismatched ones invalid, and this conversion rejects them rather
109 /// than silently reinterpreting them arithmetically.
110 ///
111 /// Returns [`DurationChronoError::Overflow`] if the total duration
112 /// exceeds `chrono::TimeDelta`'s representable range (`±i64::MAX`
113 /// milliseconds).
114 fn try_from(d: Duration) -> Result<Self, Self::Error> {
115 if d.nanos < -999_999_999 || d.nanos > 999_999_999 {
116 return Err(DurationChronoError::InvalidNanos);
117 }
118 // Decoding doesn't validate the spec's sign-consistency rule, so a
119 // malformed message can carry e.g. {seconds: 5, nanos: -1}. Reject
120 // per spec instead of guessing at an arithmetic interpretation.
121 let sign_mismatch = (d.seconds > 0 && d.nanos < 0) || (d.seconds < 0 && d.nanos > 0);
122 if sign_mismatch {
123 return Err(DurationChronoError::InvalidNanos);
124 }
125
126 // `chrono::TimeDelta` is internally `i64` milliseconds; large second
127 // values that fit in proto Duration can overflow it. Build from the
128 // two components with checked arithmetic.
129 //
130 // `try_seconds` rejects |seconds| > i64::MAX / 1000 ≈ 9.22e15. After
131 // that, `checked_add` is still needed because a `secs_part` close to
132 // the i64-millisecond boundary plus a `nanos_part` of up to ±999 ms
133 // can still push the sum over i64::MAX. `nanoseconds(_)` itself can
134 // never overflow because |nanos| < 1e9 always fits in `TimeDelta`.
135 let secs_part = Self::try_seconds(d.seconds).ok_or(DurationChronoError::Overflow)?;
136 let nanos_part = Self::nanoseconds(i64::from(d.nanos));
137 secs_part
138 .checked_add(&nanos_part)
139 .ok_or(DurationChronoError::Overflow)
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn positive_roundtrip() {
149 let td = chrono::TimeDelta::new(300, 500_000_000).unwrap();
150 let proto: Duration = td.into();
151 assert_eq!(proto.seconds, 300);
152 assert_eq!(proto.nanos, 500_000_000);
153 let back: chrono::TimeDelta = proto.try_into().unwrap();
154 assert_eq!(back, td);
155 }
156
157 #[test]
158 fn zero_roundtrip() {
159 let td = chrono::TimeDelta::zero();
160 let proto: Duration = td.into();
161 assert_eq!(proto.seconds, 0);
162 assert_eq!(proto.nanos, 0);
163 let back: chrono::TimeDelta = proto.try_into().unwrap();
164 assert_eq!(back, td);
165 }
166
167 #[test]
168 fn negative_roundtrip() {
169 // -1.5 seconds. chrono returns num_seconds = -1, subsec_nanos = -500_000_000,
170 // matching the proto convention.
171 let td = chrono::TimeDelta::milliseconds(-1_500);
172 let proto: Duration = td.into();
173 assert_eq!(proto.seconds, -1);
174 assert_eq!(proto.nanos, -500_000_000);
175 let back: chrono::TimeDelta = proto.try_into().unwrap();
176 assert_eq!(back, td);
177 }
178
179 #[test]
180 fn sub_second_negative_roundtrip() {
181 let td = chrono::TimeDelta::nanoseconds(-500_000_000);
182 let proto: Duration = td.into();
183 assert_eq!(proto.seconds, 0);
184 assert_eq!(proto.nanos, -500_000_000);
185 let back: chrono::TimeDelta = proto.try_into().unwrap();
186 assert_eq!(back, td);
187 }
188
189 #[test]
190 fn invalid_nanos_rejected() {
191 let bad = Duration {
192 seconds: 1,
193 nanos: 1_000_000_000,
194 ..Default::default()
195 };
196 let result: Result<chrono::TimeDelta, _> = bad.try_into();
197 assert_eq!(result, Err(DurationChronoError::InvalidNanos));
198 }
199
200 #[test]
201 fn nanos_i32_min_is_invalid() {
202 let bad = Duration {
203 seconds: 0,
204 nanos: i32::MIN,
205 ..Default::default()
206 };
207 let result: Result<chrono::TimeDelta, _> = bad.try_into();
208 assert_eq!(result, Err(DurationChronoError::InvalidNanos));
209 }
210
211 #[test]
212 fn sign_mismatch_rejected() {
213 let bad = Duration {
214 seconds: 5,
215 nanos: -1,
216 ..Default::default()
217 };
218 let result: Result<chrono::TimeDelta, _> = bad.try_into();
219 assert_eq!(result, Err(DurationChronoError::InvalidNanos));
220
221 let bad2 = Duration {
222 seconds: -5,
223 nanos: 1,
224 ..Default::default()
225 };
226 let result2: Result<chrono::TimeDelta, _> = bad2.try_into();
227 assert_eq!(result2, Err(DurationChronoError::InvalidNanos));
228 }
229
230 #[test]
231 fn timedelta_extremes_roundtrip() {
232 // `TimeDelta` spans ±i64::MAX milliseconds. Pin that both extremes
233 // survive the proto roundtrip exactly (constructed via `milliseconds`,
234 // which is total over i64, rather than the MIN/MAX consts that only
235 // exist in newer chrono versions).
236 let max = chrono::TimeDelta::milliseconds(i64::MAX);
237 let proto: Duration = max.into();
238 assert_eq!(proto.seconds, max.num_seconds());
239 assert_eq!(proto.nanos, max.subsec_nanos());
240 let back: chrono::TimeDelta = proto.try_into().unwrap();
241 assert_eq!(back, max);
242
243 let min = chrono::TimeDelta::milliseconds(-i64::MAX);
244 let proto_min: Duration = min.into();
245 let back_min: chrono::TimeDelta = proto_min.try_into().unwrap();
246 assert_eq!(back_min, min);
247 }
248
249 #[test]
250 fn nanos_addition_overflow_is_overflow() {
251 // try_seconds accepts |seconds| up to i64::MAX / 1000. At that boundary
252 // the resulting TimeDelta is within ~999 ms of i64::MAX milliseconds;
253 // a positive nanos value tips checked_add over the edge.
254 let boundary_secs = i64::MAX / 1_000;
255 let near_max = Duration {
256 seconds: boundary_secs,
257 nanos: 999_999_999,
258 ..Default::default()
259 };
260 let result: Result<chrono::TimeDelta, _> = near_max.try_into();
261 assert_eq!(result, Err(DurationChronoError::Overflow));
262
263 // Mirror for the negative boundary.
264 let boundary_neg = -(i64::MAX / 1_000);
265 let near_min = Duration {
266 seconds: boundary_neg,
267 nanos: -999_999_999,
268 ..Default::default()
269 };
270 let result_neg: Result<chrono::TimeDelta, _> = near_min.try_into();
271 assert_eq!(result_neg, Err(DurationChronoError::Overflow));
272 }
273
274 #[test]
275 fn out_of_range_seconds_is_overflow() {
276 // `chrono::TimeDelta` caps at `i64::MAX` milliseconds, so `i64::MAX`
277 // seconds overflows by ~1000×.
278 let huge = Duration {
279 seconds: i64::MAX,
280 nanos: 0,
281 ..Default::default()
282 };
283 let result: Result<chrono::TimeDelta, _> = huge.try_into();
284 assert_eq!(result, Err(DurationChronoError::Overflow));
285
286 let tiny = Duration {
287 seconds: i64::MIN,
288 nanos: 0,
289 ..Default::default()
290 };
291 let result2: Result<chrono::TimeDelta, _> = tiny.try_into();
292 assert_eq!(result2, Err(DurationChronoError::Overflow));
293 }
294}