Skip to main content

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}