Skip to main content

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}