Skip to main content

buffa_types/
duration_jiff.rs

1//! `jiff` interop for [`google::protobuf::Duration`](crate::google::protobuf::Duration).
2//!
3//! Enabled with the `jiff` Cargo feature. The proto `Duration` maps to
4//! [`jiff::SignedDuration`] — jiff's fixed (non-calendar) signed duration,
5//! whose sign-consistent `seconds` + sub-second `nanos` representation matches
6//! proto's exactly. `no_std`-compatible.
7//!
8//! [`jiff::Span`](jiff::Span) is deliberately *not* a conversion target: a
9//! `Span` carries calendar units (years/months/days) whose length is only
10//! defined relative to a reference date, whereas a proto `Duration` is an
11//! absolute elapsed time. `SignedDuration` is the faithful analog.
12
13use crate::google::protobuf::Duration;
14
15/// Errors that can occur when converting a protobuf [`Duration`] to a
16/// [`jiff::SignedDuration`].
17///
18/// Unlike the `chrono` conversion's
19/// [`DurationChronoError`](crate::DurationChronoError), this has no `Overflow`
20/// mode: a validated `nanos` (`|nanos| < 1_000_000_000`) never carries into the
21/// seconds field, and both types store `seconds` as `i64`, so every well-formed
22/// proto `Duration` maps into [`jiff::SignedDuration`] in range. The only
23/// failure is a malformed `nanos` field.
24///
25/// This enum is `#[non_exhaustive]`: `match` arms over it must include a
26/// wildcard arm.
27#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))]
28#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
29#[non_exhaustive]
30pub enum DurationJiffError {
31    /// The `nanos` field is outside `[-999_999_999, 999_999_999]` or its sign
32    /// is inconsistent with `seconds`.
33    #[error("nanos field has invalid value or sign mismatch with seconds")]
34    InvalidNanos,
35}
36
37#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))]
38impl From<jiff::SignedDuration> for Duration {
39    /// Convert a [`jiff::SignedDuration`] to a protobuf [`Duration`].
40    ///
41    /// Infallible: both types represent a signed duration as a sign-consistent
42    /// `seconds` + sub-second `nanos` pair, so this is a direct field copy.
43    ///
44    /// # Warning: proto JSON spec range
45    ///
46    /// `jiff::SignedDuration` ranges to ±`i64::MAX` seconds (~2.9e11 years),
47    /// while the proto spec restricts `Duration` to ±315,576,000,000 seconds
48    /// (~10,000 years). A `SignedDuration` beyond that converts without error
49    /// here — binary encoding round-trips it — but the resulting `Duration`
50    /// will fail JSON serialization (`json` feature), which enforces the spec
51    /// range.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use buffa_types::Duration;
57    ///
58    /// let sd = jiff::SignedDuration::new(1, 500_000_000);
59    /// let proto: Duration = sd.into();
60    /// assert_eq!(proto.seconds, 1);
61    /// assert_eq!(proto.nanos, 500_000_000);
62    /// ```
63    fn from(d: jiff::SignedDuration) -> Self {
64        Self {
65            seconds: d.as_secs(),
66            nanos: d.subsec_nanos(),
67            ..Default::default()
68        }
69    }
70}
71
72#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))]
73impl TryFrom<Duration> for jiff::SignedDuration {
74    type Error = DurationJiffError;
75
76    /// Convert a protobuf [`Duration`] to a [`jiff::SignedDuration`].
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use buffa_types::Duration;
82    ///
83    /// let proto = Duration {
84    ///     seconds: 2,
85    ///     nanos: 250_000_000,
86    ///     ..Default::default()
87    /// };
88    /// let sd: jiff::SignedDuration = proto.try_into().unwrap();
89    /// assert_eq!(sd, jiff::SignedDuration::new(2, 250_000_000));
90    /// ```
91    ///
92    /// # Errors
93    ///
94    /// Returns [`DurationJiffError::InvalidNanos`] if `nanos` is outside
95    /// `[-999_999_999, 999_999_999]` or if its sign is inconsistent with
96    /// `seconds`. Such values never come from the [`From<jiff::SignedDuration>`]
97    /// impl, but `seconds` and `nanos` are independent wire fields, so a decoded
98    /// `Duration` can carry any combination — the proto spec declares
99    /// sign-mismatched ones invalid, and this conversion rejects them rather
100    /// than letting `SignedDuration::new` silently re-normalize them.
101    fn try_from(d: Duration) -> Result<Self, Self::Error> {
102        if !(-999_999_999..=999_999_999).contains(&d.nanos) {
103            return Err(DurationJiffError::InvalidNanos);
104        }
105        let sign_mismatch = (d.seconds > 0 && d.nanos < 0) || (d.seconds < 0 && d.nanos > 0);
106        if sign_mismatch {
107            return Err(DurationJiffError::InvalidNanos);
108        }
109
110        // `|nanos| < 1_000_000_000`, so `new` performs no second-carry and
111        // cannot overflow i64.
112        Ok(jiff::SignedDuration::new(d.seconds, d.nanos))
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn positive_roundtrip() {
122        let sd = jiff::SignedDuration::new(300, 500_000_000);
123        let proto: Duration = sd.into();
124        assert_eq!(proto.seconds, 300);
125        assert_eq!(proto.nanos, 500_000_000);
126        let back: jiff::SignedDuration = proto.try_into().unwrap();
127        assert_eq!(back, sd);
128    }
129
130    #[test]
131    fn zero_roundtrip() {
132        let sd = jiff::SignedDuration::ZERO;
133        let proto: Duration = sd.into();
134        assert_eq!(proto.seconds, 0);
135        assert_eq!(proto.nanos, 0);
136        let back: jiff::SignedDuration = proto.try_into().unwrap();
137        assert_eq!(back, sd);
138    }
139
140    #[test]
141    fn negative_roundtrip() {
142        // -1.5 seconds: jiff keeps both components negative, matching proto.
143        let sd = jiff::SignedDuration::new(-1, -500_000_000);
144        let proto: Duration = sd.into();
145        assert_eq!(proto.seconds, -1);
146        assert_eq!(proto.nanos, -500_000_000);
147        let back: jiff::SignedDuration = proto.try_into().unwrap();
148        assert_eq!(back, sd);
149    }
150
151    #[test]
152    fn sub_second_negative_roundtrip() {
153        let sd = jiff::SignedDuration::new(0, -500_000_000);
154        let proto: Duration = sd.into();
155        assert_eq!(proto.seconds, 0);
156        assert_eq!(proto.nanos, -500_000_000);
157        let back: jiff::SignedDuration = proto.try_into().unwrap();
158        assert_eq!(back, sd);
159    }
160
161    #[test]
162    fn invalid_nanos_rejected() {
163        let bad = Duration {
164            seconds: 1,
165            nanos: 1_000_000_000,
166            ..Default::default()
167        };
168        let r: Result<jiff::SignedDuration, _> = bad.try_into();
169        assert_eq!(r, Err(DurationJiffError::InvalidNanos));
170    }
171
172    #[test]
173    fn nanos_i32_min_is_invalid() {
174        let bad = Duration {
175            seconds: 0,
176            nanos: i32::MIN,
177            ..Default::default()
178        };
179        let r: Result<jiff::SignedDuration, _> = bad.try_into();
180        assert_eq!(r, Err(DurationJiffError::InvalidNanos));
181    }
182
183    #[test]
184    fn sign_mismatch_rejected() {
185        let bad = Duration {
186            seconds: 5,
187            nanos: -1,
188            ..Default::default()
189        };
190        let r: Result<jiff::SignedDuration, _> = bad.try_into();
191        assert_eq!(r, Err(DurationJiffError::InvalidNanos));
192
193        let bad2 = Duration {
194            seconds: -5,
195            nanos: 1,
196            ..Default::default()
197        };
198        let r2: Result<jiff::SignedDuration, _> = bad2.try_into();
199        assert_eq!(r2, Err(DurationJiffError::InvalidNanos));
200    }
201
202    #[test]
203    fn signed_duration_extremes_roundtrip() {
204        // `SignedDuration` spans ±i64::MAX seconds — wider than proto Duration's
205        // spec range, but proto's `seconds` is also i64, so the binary form
206        // round-trips both extremes exactly (no Overflow mode).
207        for sd in [jiff::SignedDuration::MAX, jiff::SignedDuration::MIN] {
208            let proto: Duration = sd.into();
209            assert_eq!(proto.seconds, sd.as_secs());
210            assert_eq!(proto.nanos, sd.subsec_nanos());
211            let back: jiff::SignedDuration = proto.try_into().unwrap();
212            assert_eq!(back, sd);
213        }
214    }
215}