buffa_types/timestamp_jiff.rs
1//! `jiff` interop for [`google::protobuf::Timestamp`](crate::google::protobuf::Timestamp).
2//!
3//! Enabled with the `jiff` Cargo feature. `no_std`-compatible — `jiff` is
4//! pulled in with `default-features = false` and its `alloc` feature.
5
6use crate::google::protobuf::Timestamp;
7use crate::timestamp_ext::{TimestampError, NANOS_MAX};
8
9#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))]
10impl From<jiff::Timestamp> for Timestamp {
11 /// Convert a [`jiff::Timestamp`] to a protobuf [`Timestamp`].
12 ///
13 /// Infallible: every [`jiff::Timestamp`] fits the *binary* proto
14 /// `Timestamp` range (proto allows any `i64` second; jiff spans
15 /// ≈ years -9999 through 9999, a strict subset).
16 ///
17 /// # Warning: proto JSON spec range
18 ///
19 /// `jiff::Timestamp` reaches back to ≈ year -9999, but the proto JSON spec
20 /// restricts `Timestamp` to years 0001–9999. A pre-year-1 instant converts
21 /// without error here and round-trips through binary encoding, but the
22 /// resulting `Timestamp` will fail JSON serialization (`json` feature),
23 /// which enforces the spec range.
24 ///
25 /// # Sign normalization
26 ///
27 /// [`jiff::Timestamp`] reports its sub-second component with the *same
28 /// sign* as the overall instant — a pre-epoch instant has a negative
29 /// [`subsec_nanosecond`](jiff::Timestamp::subsec_nanosecond) — whereas
30 /// proto `Timestamp.nanos` is always in `[0, 999_999_999]`. The conversion
31 /// re-normalizes by borrowing a second for negative sub-second components,
32 /// so `-1.5s` becomes `{ seconds: -2, nanos: 500_000_000 }`.
33 ///
34 /// # Examples
35 ///
36 /// ```
37 /// use buffa_types::Timestamp;
38 ///
39 /// let jt = jiff::Timestamp::new(1_700_000_000, 123_456_789).unwrap();
40 /// let ts: Timestamp = jt.into();
41 /// assert_eq!(ts.seconds, 1_700_000_000);
42 /// assert_eq!(ts.nanos, 123_456_789);
43 /// ```
44 fn from(ts: jiff::Timestamp) -> Self {
45 let seconds = ts.as_second();
46 let nanos = ts.subsec_nanosecond();
47 if nanos < 0 {
48 // `seconds` is >= jiff's MIN second (-377_705_023_201), so the
49 // borrow `seconds - 1` cannot underflow i64.
50 Self {
51 seconds: seconds - 1,
52 nanos: nanos + 1_000_000_000,
53 ..Default::default()
54 }
55 } else {
56 Self {
57 seconds,
58 nanos,
59 ..Default::default()
60 }
61 }
62 }
63}
64
65#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))]
66impl TryFrom<Timestamp> for jiff::Timestamp {
67 type Error = TimestampError;
68
69 /// Convert a protobuf [`Timestamp`] to a [`jiff::Timestamp`].
70 ///
71 /// # Examples
72 ///
73 /// ```
74 /// use buffa_types::Timestamp;
75 ///
76 /// let ts = Timestamp {
77 /// seconds: 1_700_000_000,
78 /// nanos: 0,
79 /// ..Default::default()
80 /// };
81 /// let jt: jiff::Timestamp = ts.try_into().unwrap();
82 /// assert_eq!(jt.as_second(), 1_700_000_000);
83 /// ```
84 ///
85 /// # Errors
86 ///
87 /// Returns [`TimestampError::InvalidNanos`] if `nanos` is outside
88 /// `[0, 999_999_999]`, or [`TimestampError::Overflow`] if the instant is
89 /// outside [`jiff::Timestamp`]'s representable range (≈ years -9999 through
90 /// 9999 — proto permits a far wider second range).
91 fn try_from(ts: Timestamp) -> Result<Self, Self::Error> {
92 if ts.nanos < 0 || ts.nanos > NANOS_MAX {
93 return Err(TimestampError::InvalidNanos);
94 }
95 // Nanos validated above, so the only remaining failure is an
96 // out-of-range second.
97 jiff::Timestamp::new(ts.seconds, ts.nanos).map_err(|_| TimestampError::Overflow)
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn post_epoch_roundtrip() {
107 let jt = jiff::Timestamp::new(1_700_000_000, 123_456_789).unwrap();
108 let ts: Timestamp = jt.into();
109 assert_eq!(ts.seconds, 1_700_000_000);
110 assert_eq!(ts.nanos, 123_456_789);
111 let back: jiff::Timestamp = ts.try_into().unwrap();
112 assert_eq!(back, jt);
113 }
114
115 #[test]
116 fn epoch_roundtrip() {
117 let jt = jiff::Timestamp::new(0, 0).unwrap();
118 let ts: Timestamp = jt.into();
119 assert_eq!(ts.seconds, 0);
120 assert_eq!(ts.nanos, 0);
121 let back: jiff::Timestamp = ts.try_into().unwrap();
122 assert_eq!(back, jt);
123 }
124
125 #[test]
126 fn pre_epoch_borrows_second() {
127 // -1.5 seconds. jiff stores this sign-consistently as
128 // (as_second = -1, subsec_nanosecond = -500_000_000); the proto form
129 // must borrow a second to keep nanos non-negative.
130 let jt = jiff::Timestamp::new(-2, 500_000_000).unwrap();
131 assert_eq!(jt.as_second(), -1);
132 assert_eq!(jt.subsec_nanosecond(), -500_000_000);
133 let ts: Timestamp = jt.into();
134 assert_eq!(ts.seconds, -2);
135 assert_eq!(ts.nanos, 500_000_000);
136 let back: jiff::Timestamp = ts.try_into().unwrap();
137 assert_eq!(back, jt);
138 }
139
140 #[test]
141 fn exact_pre_epoch_second_roundtrip() {
142 // Whole second before the epoch: no borrow needed.
143 let jt = jiff::Timestamp::new(-2, 0).unwrap();
144 let ts: Timestamp = jt.into();
145 assert_eq!(ts.seconds, -2);
146 assert_eq!(ts.nanos, 0);
147 let back: jiff::Timestamp = ts.try_into().unwrap();
148 assert_eq!(back, jt);
149 }
150
151 #[test]
152 fn nanos_upper_boundary_roundtrip() {
153 let ts = Timestamp {
154 seconds: 5,
155 nanos: 999_999_999,
156 ..Default::default()
157 };
158 let jt: jiff::Timestamp = ts.clone().try_into().expect("upper boundary converts");
159 let back: Timestamp = jt.into();
160 assert_eq!(back, ts);
161 }
162
163 #[test]
164 fn invalid_nanos_rejected() {
165 let neg = Timestamp {
166 seconds: 0,
167 nanos: -1,
168 ..Default::default()
169 };
170 let r: Result<jiff::Timestamp, _> = neg.try_into();
171 assert_eq!(r, Err(TimestampError::InvalidNanos));
172
173 let too_big = Timestamp {
174 seconds: 0,
175 nanos: 1_000_000_000,
176 ..Default::default()
177 };
178 let r2: Result<jiff::Timestamp, _> = too_big.try_into();
179 assert_eq!(r2, Err(TimestampError::InvalidNanos));
180 }
181
182 #[test]
183 fn out_of_range_seconds_is_overflow() {
184 // proto Timestamp spans the full i64 second range; jiff caps at
185 // ≈ year 9999, so i64::MAX seconds overflows.
186 let huge = Timestamp {
187 seconds: i64::MAX,
188 nanos: 0,
189 ..Default::default()
190 };
191 let r: Result<jiff::Timestamp, _> = huge.try_into();
192 assert_eq!(r, Err(TimestampError::Overflow));
193
194 let tiny = Timestamp {
195 seconds: i64::MIN,
196 nanos: 0,
197 ..Default::default()
198 };
199 let r2: Result<jiff::Timestamp, _> = tiny.try_into();
200 assert_eq!(r2, Err(TimestampError::Overflow));
201 }
202
203 #[test]
204 fn jiff_extremes_roundtrip() {
205 // Both ends of jiff's representable range survive the proto roundtrip.
206 for jt in [jiff::Timestamp::MIN, jiff::Timestamp::MAX] {
207 let ts: Timestamp = jt.into();
208 assert!(
209 (0..=NANOS_MAX).contains(&ts.nanos),
210 "nanos must stay within proto invariant: got {}",
211 ts.nanos
212 );
213 let back: jiff::Timestamp = ts.try_into().expect("jiff extreme must convert back");
214 assert_eq!(back, jt);
215 }
216 }
217
218 #[test]
219 fn borrow_at_near_min_roundtrip() {
220 // The subtlest borrow case: one nanosecond short of jiff's MIN second.
221 // jiff reports it as (MIN + 1, -999_999_999); the borrow produces proto
222 // { seconds: MIN, nanos: 1 }, and the conversion back must accept a
223 // positive nanos at MIN (jiff permits it — only negative nanos at MIN
224 // are out of range).
225 let min_second = jiff::Timestamp::MIN.as_second();
226 let jt = jiff::Timestamp::new(min_second + 1, -999_999_999).unwrap();
227 let ts: Timestamp = jt.into();
228 assert_eq!(ts.seconds, min_second);
229 assert_eq!(ts.nanos, 1);
230 let back: jiff::Timestamp = ts.try_into().expect("near-MIN borrow must convert back");
231 assert_eq!(back, jt);
232 }
233}