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}