Skip to main content

cbor_core/ext/
jiff.rs

1use jiff::{
2    Timestamp, Zoned,
3    fmt::temporal::Pieces,
4    tz::{Offset, TimeZone},
5};
6
7use crate::{Error, Result, Value};
8
9fn timestamp_from_str(s: &str) -> Result<Timestamp> {
10    s.parse::<Timestamp>().or(Err(Error::InvalidFormat))
11}
12
13fn zoned_from_str(s: &str) -> Result<Zoned> {
14    let ts = timestamp_from_str(s)?;
15    let pieces = Pieces::parse(s).or(Err(Error::InvalidFormat))?;
16    let offset = pieces.to_numeric_offset().unwrap_or(Offset::UTC);
17    Ok(ts.to_zoned(TimeZone::fixed(offset)))
18}
19
20fn timestamp_from_float(f: f64) -> Result<Timestamp> {
21    if f.is_finite() && f >= 0.0 {
22        let secs = f.trunc() as i64;
23        let nanos = ((f - f.trunc()) * 1_000_000_000.0).round() as i32;
24        Timestamp::new(secs, nanos).or(Err(Error::InvalidValue))
25    } else {
26        Err(Error::InvalidValue)
27    }
28}
29
30fn timestamp_from_u64(secs: u64) -> Result<Timestamp> {
31    let secs: i64 = secs.try_into().or(Err(Error::Overflow))?;
32    Timestamp::from_second(secs).or(Err(Error::Overflow))
33}
34
35// ---------------------------------------------------------------------------
36// jiff::Timestamp → crate::DateTime
37// ---------------------------------------------------------------------------
38
39impl TryFrom<Timestamp> for crate::DateTime {
40    type Error = Error;
41
42    /// Converts a `jiff::Timestamp` to a CBOR date/time string (tag 0).
43    ///
44    /// Whole-second timestamps omit the fractional part and sub-second
45    /// timestamps include only the necessary digits.
46    fn try_from(value: Timestamp) -> Result<Self> {
47        crate::DateTime::try_from(value.to_string())
48    }
49}
50
51impl TryFrom<&Timestamp> for crate::DateTime {
52    type Error = Error;
53
54    fn try_from(value: &Timestamp) -> Result<Self> {
55        crate::DateTime::try_from(value.to_string())
56    }
57}
58
59// ---------------------------------------------------------------------------
60// jiff::Zoned → crate::DateTime
61// ---------------------------------------------------------------------------
62
63impl TryFrom<Zoned> for crate::DateTime {
64    type Error = Error;
65
66    /// Converts a `jiff::Zoned` to a CBOR date/time string (tag 0).
67    ///
68    /// The RFC 3339 output preserves the offset; any IANA time zone
69    /// annotation is dropped. Whole-second timestamps omit the
70    /// fractional part and sub-second timestamps include only the
71    /// necessary digits.
72    fn try_from(value: Zoned) -> Result<Self> {
73        crate::DateTime::try_from(&value)
74    }
75}
76
77impl TryFrom<&Zoned> for crate::DateTime {
78    type Error = Error;
79
80    fn try_from(value: &Zoned) -> Result<Self> {
81        let s = value.timestamp().display_with_offset(value.offset()).to_string();
82        crate::DateTime::try_from(s)
83    }
84}
85
86// ---------------------------------------------------------------------------
87// jiff::Timestamp → crate::EpochTime
88// ---------------------------------------------------------------------------
89
90impl TryFrom<Timestamp> for crate::EpochTime {
91    type Error = Error;
92
93    /// Converts a `jiff::Timestamp` to a CBOR epoch time (tag 1).
94    ///
95    /// Whole-second timestamps are stored as integers; sub-second
96    /// timestamps are stored as floats.
97    fn try_from(value: Timestamp) -> Result<Self> {
98        timestamp_to_epoch(&value)
99    }
100}
101
102impl TryFrom<&Timestamp> for crate::EpochTime {
103    type Error = Error;
104
105    fn try_from(value: &Timestamp) -> Result<Self> {
106        timestamp_to_epoch(value)
107    }
108}
109
110fn timestamp_to_epoch(value: &Timestamp) -> Result<crate::EpochTime> {
111    let secs = value.as_second();
112    let nanos = value.subsec_nanosecond();
113
114    if nanos == 0 {
115        crate::EpochTime::try_from(secs)
116    } else {
117        let f = secs as f64 + nanos as f64 / 1_000_000_000.0;
118        crate::EpochTime::try_from(f)
119    }
120}
121
122// ---------------------------------------------------------------------------
123// jiff::Zoned → crate::EpochTime
124// ---------------------------------------------------------------------------
125
126impl TryFrom<Zoned> for crate::EpochTime {
127    type Error = Error;
128
129    /// Converts a `jiff::Zoned` to a CBOR epoch time (tag 1).
130    ///
131    /// Whole-second timestamps are stored as integers; sub-second
132    /// timestamps are stored as floats.
133    fn try_from(value: Zoned) -> Result<Self> {
134        timestamp_to_epoch(&value.timestamp())
135    }
136}
137
138impl TryFrom<&Zoned> for crate::EpochTime {
139    type Error = Error;
140
141    fn try_from(value: &Zoned) -> Result<Self> {
142        timestamp_to_epoch(&value.timestamp())
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Value → jiff::Timestamp
148// ---------------------------------------------------------------------------
149
150impl TryFrom<Value> for Timestamp {
151    type Error = Error;
152
153    /// Extracts a `jiff::Timestamp` from a CBOR time value.
154    ///
155    /// Accepts date/time strings (tag 0) and epoch integers/floats (tag 1).
156    /// Any offset in an RFC 3339 string is normalized to UTC.
157    fn try_from(value: Value) -> Result<Self> {
158        value_to_timestamp(&value)
159    }
160}
161
162impl TryFrom<&Value> for Timestamp {
163    type Error = Error;
164
165    fn try_from(value: &Value) -> Result<Self> {
166        value_to_timestamp(value)
167    }
168}
169
170fn value_to_timestamp(value: &Value) -> Result<Timestamp> {
171    if let Ok(s) = value.as_str() {
172        timestamp_from_str(s)
173    } else if let Ok(f) = value.to_f64() {
174        timestamp_from_float(f)
175    } else {
176        match value.to_u64() {
177            Ok(secs) => timestamp_from_u64(secs),
178            Err(Error::NegativeUnsigned) => Err(Error::InvalidValue),
179            Err(other_error) => Err(other_error),
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Value → jiff::Zoned
186// ---------------------------------------------------------------------------
187
188impl TryFrom<Value> for Zoned {
189    type Error = Error;
190
191    /// Extracts a `jiff::Zoned` from a CBOR time value.
192    ///
193    /// Date/time strings (tag 0) preserve the offset as a fixed-offset
194    /// time zone. Epoch integers/floats are returned with a UTC time zone.
195    fn try_from(value: Value) -> Result<Self> {
196        value_to_zoned(&value)
197    }
198}
199
200impl TryFrom<&Value> for Zoned {
201    type Error = Error;
202
203    fn try_from(value: &Value) -> Result<Self> {
204        value_to_zoned(value)
205    }
206}
207
208fn value_to_zoned(value: &Value) -> Result<Zoned> {
209    if let Ok(s) = value.as_str() {
210        zoned_from_str(s)
211    } else if let Ok(f) = value.to_f64() {
212        timestamp_from_float(f).map(|ts| ts.to_zoned(TimeZone::UTC))
213    } else {
214        match value.to_u64() {
215            Ok(secs) => timestamp_from_u64(secs).map(|ts| ts.to_zoned(TimeZone::UTC)),
216            Err(Error::NegativeUnsigned) => Err(Error::InvalidValue),
217            Err(other_error) => Err(other_error),
218        }
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Tests
224// ---------------------------------------------------------------------------
225
226#[cfg(test)]
227mod tests {
228    use std::time::{Duration, UNIX_EPOCH};
229
230    use jiff::{Timestamp, Zoned, tz::TimeZone};
231
232    use crate::{DataType, Error, Float, Value};
233
234    // ---- jiff::Timestamp → crate::DateTime (tag 0) ----
235
236    #[test]
237    fn jiff_to_date_time_utc() {
238        let ts: Timestamp = "2000-01-01T00:00:00Z".parse().unwrap();
239        let v = Value::date_time(ts);
240        assert_eq!(v.as_str(), Ok("2000-01-01T00:00:00Z"));
241    }
242
243    #[test]
244    fn jiff_to_date_time_subsec() {
245        let ts = Timestamp::new(946_684_800, 123_456_789).unwrap();
246        let v = Value::date_time(ts);
247        assert_eq!(v.as_str(), Ok("2000-01-01T00:00:00.123456789Z"));
248    }
249
250    #[test]
251    fn jiff_to_date_time_millis_only() {
252        let ts = Timestamp::new(1_718_438_400, 500_000_000).unwrap();
253        let v = Value::date_time(ts);
254        assert_eq!(v.as_str(), Ok("2024-06-15T08:00:00.5Z"));
255    }
256
257    // ---- jiff::Zoned → crate::DateTime (tag 0) ----
258
259    #[test]
260    fn jiff_zoned_to_date_time_preserves_offset() {
261        let z: Zoned = "2000-01-01T01:00:00+01:00[+01:00]".parse().unwrap();
262        let v = Value::date_time(z);
263        assert_eq!(v.as_str(), Ok("2000-01-01T01:00:00+01:00"));
264    }
265
266    // ---- jiff::Timestamp → crate::EpochTime (tag 1) ----
267
268    #[test]
269    fn jiff_to_epoch_time_whole_second() {
270        let ts = Timestamp::from_second(1_000_000).unwrap();
271        let v = Value::epoch_time(ts);
272        assert_eq!(v.into_untagged(), Value::Unsigned(1_000_000));
273    }
274
275    #[test]
276    fn jiff_to_epoch_time_subsec() {
277        let ts = Timestamp::new(1_000_000, 500_000_000).unwrap();
278        let v = Value::epoch_time(ts);
279        assert_eq!(v.into_untagged(), Value::Float(Float::from(1000000.5)));
280    }
281
282    #[test]
283    fn jiff_to_epoch_time_negative() {
284        let ts = Timestamp::from_second(-1).unwrap();
285        assert_eq!(crate::EpochTime::try_from(ts), Err(Error::InvalidValue));
286    }
287
288    // ---- Value → jiff::Timestamp ----
289
290    #[test]
291    fn value_date_time_string_to_jiff_timestamp() {
292        let v = Value::date_time("2000-01-01T00:00:00Z");
293        let ts = Timestamp::try_from(v).unwrap();
294        assert_eq!(ts.to_string(), "2000-01-01T00:00:00Z");
295    }
296
297    #[test]
298    fn value_date_time_string_with_offset_to_jiff_timestamp() {
299        let v = Value::date_time("2000-01-01T01:00:00+01:00");
300        let ts = Timestamp::try_from(v).unwrap();
301        assert_eq!(ts.to_string(), "2000-01-01T00:00:00Z");
302    }
303
304    #[test]
305    fn value_epoch_int_to_jiff_timestamp() {
306        let v = Value::epoch_time(946684800_u64);
307        let ts = Timestamp::try_from(v).unwrap();
308        assert_eq!(ts.to_string(), "2000-01-01T00:00:00Z");
309    }
310
311    #[test]
312    fn value_epoch_float_to_jiff_timestamp() {
313        let v = Value::epoch_time(946684800.5_f64);
314        let ts = Timestamp::try_from(v).unwrap();
315        assert_eq!(ts.as_second(), 946684800);
316        assert_eq!(ts.subsec_nanosecond(), 500_000_000);
317    }
318
319    // ---- Value → jiff::Zoned ----
320
321    #[test]
322    fn value_date_time_string_to_jiff_zoned_preserves_offset() {
323        let v = Value::date_time("2000-01-01T01:00:00+01:00");
324        let z = Zoned::try_from(&v).unwrap();
325        assert_eq!(z.offset().seconds(), 3600);
326        assert_eq!(z.timestamp().to_string(), "2000-01-01T00:00:00Z");
327    }
328
329    #[test]
330    fn value_epoch_to_jiff_zoned_is_utc() {
331        let v = Value::epoch_time(0_u64);
332        let z = Zoned::try_from(&v).unwrap();
333        assert_eq!(z.offset().seconds(), 0);
334        assert_eq!(z.time_zone(), &TimeZone::UTC);
335    }
336
337    // ---- Error cases ----
338
339    #[test]
340    fn value_non_time_to_jiff_errors() {
341        assert_eq!(
342            Timestamp::try_from(Value::from("not a date")),
343            Err(Error::InvalidFormat)
344        );
345        assert_eq!(
346            Timestamp::try_from(Value::null()),
347            Err(Error::IncompatibleType(DataType::Null))
348        );
349    }
350
351    #[test]
352    fn value_negative_epoch_to_jiff_errors() {
353        let v = Value::from(-1);
354        assert_eq!(Timestamp::try_from(v), Err(Error::InvalidValue));
355    }
356
357    // ---- SystemTime ↔ Value ↔ jiff roundtrips ----
358
359    #[test]
360    fn jiff_roundtrip_unix_epoch() {
361        let st = UNIX_EPOCH;
362        let v = Value::date_time(st);
363        let ts = Timestamp::try_from(&v).unwrap();
364        assert_eq!(ts.as_second(), 0);
365        assert_eq!(ts.subsec_nanosecond(), 0);
366        let st2 = v.to_system_time().unwrap();
367        assert_eq!(st, st2);
368    }
369
370    #[test]
371    fn jiff_roundtrip_y2k() {
372        let st = UNIX_EPOCH + Duration::from_secs(946684800);
373        let v = Value::date_time(st);
374        let ts = Timestamp::try_from(&v).unwrap();
375        assert_eq!(ts.to_string(), "2000-01-01T00:00:00Z");
376        let st2 = v.to_system_time().unwrap();
377        assert_eq!(st, st2);
378    }
379
380    #[test]
381    fn jiff_roundtrip_y2k38() {
382        let st = UNIX_EPOCH + Duration::from_secs(2147483647);
383        let v = Value::date_time(st);
384        let ts = Timestamp::try_from(&v).unwrap();
385        assert_eq!(ts.to_string(), "2038-01-19T03:14:07Z");
386        let st2 = v.to_system_time().unwrap();
387        assert_eq!(st, st2);
388    }
389
390    #[test]
391    fn jiff_roundtrip_subsec_via_epoch() {
392        let st = UNIX_EPOCH + Duration::new(1_000_000, 123_000_000);
393        let v = Value::epoch_time(st);
394        let ts = Timestamp::try_from(&v).unwrap();
395        assert_eq!(ts.as_second(), 1_000_000);
396        assert_eq!(ts.subsec_nanosecond(), 123_000_000);
397    }
398
399    #[test]
400    fn jiff_roundtrip_timestamp_to_value_to_timestamp() {
401        let original = Timestamp::new(1_720_094_400, 0).unwrap();
402        let cbor_dt = crate::DateTime::try_from(original).unwrap();
403        let v = Value::date_time(cbor_dt);
404        let back = Timestamp::try_from(&v).unwrap();
405        assert_eq!(original, back);
406    }
407}