Skip to main content

buffa_types/
timestamp_ext.rs

1//! Ergonomic helpers for [`google::protobuf::Timestamp`](crate::google::protobuf::Timestamp).
2
3use crate::google::protobuf::Timestamp;
4
5/// Errors that can occur when converting a [`Timestamp`] to a Rust time type.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
7pub enum TimestampError {
8    /// The nanoseconds field is outside the valid range `[0, 999_999_999]`.
9    #[error("nanos field must be in [0, 999_999_999]")]
10    InvalidNanos,
11    /// The timestamp is too far in the past or future for the target type.
12    #[error("timestamp is out of range for SystemTime")]
13    Overflow,
14}
15
16impl Timestamp {
17    /// Create a [`Timestamp`] from a Unix epoch offset.
18    ///
19    /// `seconds` is the number of seconds since (or before, if negative) the
20    /// Unix epoch.  `nanos` must be in `[0, 999_999_999]`.
21    ///
22    /// # Panics
23    ///
24    /// Panics in debug mode if `nanos` is outside `[0, 999_999_999]`.
25    /// In release mode the value is stored as-is, producing an invalid
26    /// timestamp.  Use [`Timestamp::from_unix_checked`] for a checked
27    /// variant that returns `None` on invalid input.
28    pub fn from_unix(seconds: i64, nanos: i32) -> Self {
29        debug_assert!(
30            (0..=999_999_999).contains(&nanos),
31            "nanos ({nanos}) must be in [0, 999_999_999]"
32        );
33        Timestamp {
34            seconds,
35            nanos,
36            ..Default::default()
37        }
38    }
39
40    /// Create a [`Timestamp`] from a whole number of Unix seconds (nanoseconds = 0).
41    ///
42    /// This is a convenience shorthand for `Timestamp::from_unix(seconds, 0)`.
43    pub fn from_unix_secs(seconds: i64) -> Self {
44        Timestamp {
45            seconds,
46            nanos: 0,
47            ..Default::default()
48        }
49    }
50
51    /// Create a [`Timestamp`] from a Unix epoch offset, returning `None` if
52    /// `nanos` is outside `[0, 999_999_999]`.
53    pub fn from_unix_checked(seconds: i64, nanos: i32) -> Option<Self> {
54        if (0..=999_999_999).contains(&nanos) {
55            Some(Timestamp {
56                seconds,
57                nanos,
58                ..Default::default()
59            })
60        } else {
61            None
62        }
63    }
64
65    /// Return the current wall-clock time as a [`Timestamp`].
66    ///
67    /// Requires the `std` feature.
68    #[cfg(feature = "std")]
69    pub fn now() -> Self {
70        std::time::SystemTime::now().into()
71    }
72}
73
74#[cfg(feature = "std")]
75impl TryFrom<Timestamp> for std::time::SystemTime {
76    type Error = TimestampError;
77
78    /// Convert a protobuf [`Timestamp`] to a [`std::time::SystemTime`].
79    ///
80    /// # Errors
81    ///
82    /// Returns [`TimestampError::InvalidNanos`] if `nanos` is outside
83    /// `[0, 999_999_999]`, or [`TimestampError::Overflow`] if the result
84    /// does not fit in a [`std::time::SystemTime`].
85    fn try_from(ts: Timestamp) -> Result<Self, Self::Error> {
86        if ts.nanos < 0 || ts.nanos > 999_999_999 {
87            return Err(TimestampError::InvalidNanos);
88        }
89
90        if ts.seconds >= 0 {
91            let offset = std::time::Duration::new(ts.seconds as u64, ts.nanos as u32);
92            std::time::UNIX_EPOCH
93                .checked_add(offset)
94                .ok_or(TimestampError::Overflow)
95        } else {
96            // ts.seconds is negative: move backward from epoch, then forward by nanos.
97            //
98            // For example, ts.seconds = -2, ts.nanos = 500_000_000 represents
99            // -1.5 seconds from epoch (i.e. 1.5 s before epoch):
100            //   result = UNIX_EPOCH - 2s + 0.5s = UNIX_EPOCH - 1.5s
101            //
102            // unsigned_abs() avoids the overflow that `(-ts.seconds) as u64` would
103            // cause when ts.seconds == i64::MIN (which cannot be negated in i64).
104            let neg_secs = ts.seconds.unsigned_abs();
105            let base = std::time::UNIX_EPOCH
106                .checked_sub(std::time::Duration::from_secs(neg_secs))
107                .ok_or(TimestampError::Overflow)?;
108            if ts.nanos == 0 {
109                Ok(base)
110            } else {
111                base.checked_add(std::time::Duration::from_nanos(ts.nanos as u64))
112                    .ok_or(TimestampError::Overflow)
113            }
114        }
115    }
116}
117
118#[cfg(feature = "std")]
119impl From<std::time::SystemTime> for Timestamp {
120    /// Convert a [`std::time::SystemTime`] to a protobuf [`Timestamp`].
121    ///
122    /// Pre-epoch times (where `t < UNIX_EPOCH`) are represented with a
123    /// negative `seconds` field and a non-negative `nanos` field, following
124    /// the protobuf convention that `nanos` is always in `[0, 999_999_999]`.
125    ///
126    /// # Saturation
127    ///
128    /// Times more than ~292 billion years from the epoch (beyond `i64::MAX`
129    /// seconds) are saturated to `i64::MAX` seconds rather than wrapping,
130    /// which would produce a semantically incorrect negative timestamp.
131    fn from(t: std::time::SystemTime) -> Self {
132        match t.duration_since(std::time::UNIX_EPOCH) {
133            Ok(d) => Timestamp {
134                // Saturate at i64::MAX to avoid wrapping for times far in the future.
135                seconds: d.as_secs().min(i64::MAX as u64) as i64,
136                nanos: d.subsec_nanos() as i32,
137                ..Default::default()
138            },
139            Err(e) => {
140                // `e.duration()` is how far `t` is *before* the epoch.
141                // We need: seconds = floor(t - epoch), nanos = (t - epoch) - seconds.
142                //
143                // Example: t is 1.5s before epoch → duration = 1.5s
144                //   floor = -2 (the largest integer ≤ -1.5)
145                //   nanos = -1.5 - (-2) = 0.5s = 500_000_000 ns
146                //
147                // In terms of the subtraction duration `dur = e.duration()`:
148                //   If dur.subsec_nanos() == 0:
149                //     seconds = -(dur.as_secs() as i64), nanos = 0
150                //   Else:
151                //     seconds = -(dur.as_secs() as i64 + 1)
152                //     nanos = 1_000_000_000 - dur.subsec_nanos()
153                //
154                // Saturate at i64::MAX to avoid wrapping for extreme pre-epoch times.
155                let dur = e.duration();
156                if dur.subsec_nanos() == 0 {
157                    let secs = dur.as_secs().min(i64::MAX as u64) as i64;
158                    Timestamp {
159                        seconds: -secs,
160                        nanos: 0,
161                        ..Default::default()
162                    }
163                } else {
164                    // saturating_add avoids overflow when dur.as_secs() == u64::MAX,
165                    // then clamp to i64::MAX before converting.
166                    let neg_secs = dur.as_secs().saturating_add(1).min(i64::MAX as u64) as i64;
167                    Timestamp {
168                        seconds: -neg_secs,
169                        nanos: (1_000_000_000u32 - dur.subsec_nanos()) as i32,
170                        ..Default::default()
171                    }
172                }
173            }
174        }
175    }
176}
177
178// ── RFC 3339 formatting ──────────────────────────────────────────────────────
179
180/// Convert unix-epoch days to a proleptic Gregorian (year, month, day).
181/// Uses Howard Hinnant's civil calendar algorithm.
182#[cfg(feature = "json")]
183fn days_to_date(days: i64) -> (i64, u8, u8) {
184    let z = days + 719468;
185    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
186    let doe = z - era * 146097;
187    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
188    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
189    let mp = (5 * doy + 2) / 153;
190    let d = doy - (153 * mp + 2) / 5 + 1;
191    let m = if mp < 10 { mp + 3 } else { mp - 9 };
192    (
193        yoe + era * 400 + if m <= 2 { 1 } else { 0 },
194        m as u8,
195        d as u8,
196    )
197}
198
199/// Convert a proleptic Gregorian date to unix-epoch days.
200///
201/// Returns `None` if the date components are out of range or do not form a
202/// valid calendar date (e.g. February 30 or June 31).  Validity is checked by
203/// a round-trip through [`days_to_date`]: if the computed day number maps back
204/// to a different date the input was not a real calendar date.
205#[cfg(feature = "json")]
206fn date_to_days(y: i64, m: u8, d: u8) -> Option<i64> {
207    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
208        return None;
209    }
210    let (ya, ma) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
211    let era = (if ya >= 0 { ya } else { ya - 399 }) / 400;
212    let yoe = ya - era * 400;
213    let doy = (153 * ma as i64 + 2) / 5 + d as i64 - 1;
214    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
215    let days = era * 146097 + doe - 719468;
216    // Verify the computed day number round-trips back to the same date.
217    // This rejects dates like "Feb 30" that the formula maps to a different day.
218    if days_to_date(days) != (y, m, d) {
219        return None;
220    }
221    Some(days)
222}
223
224/// Format a unix timestamp as an RFC 3339 string (UTC, Z suffix).
225/// Nanosecond precision is auto-detected (0, 3, 6, or 9 fractional digits).
226#[cfg(feature = "json")]
227fn timestamp_to_rfc3339(secs: i64, nanos: i32) -> alloc::string::String {
228    use alloc::format;
229    use alloc::string::String;
230    let (tod, day) = {
231        let r = secs % 86400;
232        if r >= 0 {
233            (r, secs / 86400)
234        } else {
235            (r + 86400, secs / 86400 - 1)
236        }
237    };
238    let (y, mo, d) = days_to_date(day);
239    let h = tod / 3600;
240    let mi = (tod % 3600) / 60;
241    let s = tod % 60;
242    let frac = if nanos == 0 {
243        String::new()
244    } else if nanos % 1_000_000 == 0 {
245        format!(".{:03}", nanos / 1_000_000)
246    } else if nanos % 1_000 == 0 {
247        format!(".{:06}", nanos / 1_000)
248    } else {
249        format!(".{:09}", nanos)
250    };
251    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}{frac}Z")
252}
253
254/// Parse an RFC 3339 string to (unix_seconds, nanos). Accepts uppercase `Z`
255/// suffix and UTC offsets (`+HH:MM` / `-HH:MM`). Lowercase `t` and `z`
256/// are rejected per the proto3 JSON spec.
257#[cfg(feature = "json")]
258fn parse_rfc3339(s: &str) -> Option<(i64, i32)> {
259    // RFC 3339 timestamps are pure ASCII. Reject non-ASCII early to avoid
260    // panics from byte-offset string slicing on multi-byte UTF-8 input.
261    if !s.is_ascii() {
262        return None;
263    }
264    // Proto3 JSON spec requires uppercase 'Z' suffix (not lowercase).
265    let (dt, tz_offset) = if let Some(rest) = s.strip_suffix('Z') {
266        (rest, 0i64)
267    } else {
268        let len = s.len();
269        if len < 6 {
270            return None;
271        }
272        let sign: i64 = match s.as_bytes()[len - 6] {
273            b'+' => -1,
274            b'-' => 1,
275            _ => return None,
276        };
277        // Offset must be `(+|-)HH:MM` with colon separator and valid ranges.
278        if s.as_bytes()[len - 3] != b':' {
279            return None;
280        }
281        let oh: i64 = s[len - 5..len - 3].parse().ok()?;
282        let om: i64 = s[len - 2..].parse().ok()?;
283        if !(0..=23).contains(&oh) || !(0..=59).contains(&om) {
284            return None;
285        }
286        (&s[..len - 6], sign * (oh * 3600 + om * 60))
287    };
288
289    // Proto3 JSON spec requires uppercase 'T' separator (not lowercase).
290    let t = dt.find('T')?;
291    let (date, time) = (&dt[..t], &dt[t + 1..]);
292    if date.len() != 10 || time.len() < 8 {
293        return None;
294    }
295
296    // Validate structural separators (hyphens in date, colons in time).
297    let date_b = date.as_bytes();
298    let time_b = time.as_bytes();
299    if date_b[4] != b'-' || date_b[7] != b'-' || time_b[2] != b':' || time_b[5] != b':' {
300        return None;
301    }
302
303    let year: i64 = date[0..4].parse().ok()?;
304    let month: u8 = date[5..7].parse().ok()?;
305    let day: u8 = date[8..10].parse().ok()?;
306    let hour: i64 = time[0..2].parse().ok()?;
307    let min: i64 = time[3..5].parse().ok()?;
308    let sec: i64 = time[6..8].parse().ok()?;
309    // RFC 3339: hour 0-23, minute 0-59, second 0-60 (leap seconds).
310    // Proto3 JSON spec inherits RFC 3339 but Timestamp uses Unix epoch
311    // seconds which has no leap-second representation, so reject 60.
312    if !(0..=23).contains(&hour) || !(0..=59).contains(&min) || !(0..=59).contains(&sec) {
313        return None;
314    }
315
316    let nanos = if time.len() > 8 {
317        if time.as_bytes()[8] != b'.' {
318            return None;
319        }
320        let frac = &time[9..];
321        // All chars must be digits (i32::parse accepts '-' and '+', which
322        // would let e.g. "T23:59:59.-3Z" produce negative nanos).
323        if frac.is_empty() || frac.len() > 9 || !frac.bytes().all(|b| b.is_ascii_digit()) {
324            return None;
325        }
326        let n: i32 = frac.parse().ok()?;
327        n * 10_i32.pow(9 - frac.len() as u32)
328    } else {
329        0
330    };
331
332    // Proto spec: year must be in [1, 9999]. Check BEFORE applying the
333    // offset (cheap reject for most bad input)...
334    if !(1..=9999).contains(&year) {
335        return None;
336    }
337    let days = date_to_days(year, month, day)?;
338    let unix = days * 86400 + hour * 3600 + min * 60 + sec + tz_offset;
339    // ...and AFTER, because the offset can push a boundary timestamp past the
340    // valid range (e.g. "9999-12-31T23:59:59-23:59" has year 9999 but the
341    // UTC-equivalent is year 10000).
342    if !(MIN_TIMESTAMP_SECS..=MAX_TIMESTAMP_SECS).contains(&unix) {
343        return None;
344    }
345    Some((unix, nanos))
346}
347
348// ── serde impls ──────────────────────────────────────────────────────────────
349
350// Protobuf spec: Timestamp is restricted to years 0001–9999.
351#[cfg(feature = "json")]
352const MIN_TIMESTAMP_SECS: i64 = -62_135_596_800; // 0001-01-01T00:00:00Z
353#[cfg(feature = "json")]
354const MAX_TIMESTAMP_SECS: i64 = 253_402_300_799; // 9999-12-31T23:59:59Z
355
356#[cfg(feature = "json")]
357impl serde::Serialize for Timestamp {
358    /// Serializes as an RFC 3339 string (e.g. `"2021-01-01T00:00:00Z"`).
359    ///
360    /// # Errors
361    ///
362    /// Returns a serialization error if `nanos` is outside `[0, 999_999_999]`
363    /// or if `seconds` is outside the proto spec range (years 0001–9999).
364    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
365        use alloc::format;
366        if !(0..=999_999_999).contains(&self.nanos) {
367            return Err(serde::ser::Error::custom(format!(
368                "invalid Timestamp: nanos {} is outside [0, 999_999_999]",
369                self.nanos
370            )));
371        }
372        if !(MIN_TIMESTAMP_SECS..=MAX_TIMESTAMP_SECS).contains(&self.seconds) {
373            return Err(serde::ser::Error::custom(format!(
374                "invalid Timestamp: seconds {} is outside [{}, {}]",
375                self.seconds, MIN_TIMESTAMP_SECS, MAX_TIMESTAMP_SECS
376            )));
377        }
378        s.serialize_str(&timestamp_to_rfc3339(self.seconds, self.nanos))
379    }
380}
381
382#[cfg(feature = "json")]
383impl<'de> serde::Deserialize<'de> for Timestamp {
384    /// Deserializes from an RFC 3339 string.
385    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
386        use alloc::{format, string::String};
387        let s: String = serde::Deserialize::deserialize(d)?;
388        let (secs, nanos) = parse_rfc3339(&s)
389            .ok_or_else(|| serde::de::Error::custom(format!("invalid RFC 3339 timestamp: {s}")))?;
390        Ok(Timestamp {
391            seconds: secs,
392            nanos,
393            ..Default::default()
394        })
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn from_unix_secs_sets_nanos_to_zero() {
404        let ts = Timestamp::from_unix_secs(1_700_000_000);
405        assert_eq!(ts.seconds, 1_700_000_000);
406        assert_eq!(ts.nanos, 0);
407    }
408
409    #[test]
410    fn from_unix_secs_zero() {
411        let ts = Timestamp::from_unix_secs(0);
412        assert_eq!(ts.seconds, 0);
413        assert_eq!(ts.nanos, 0);
414    }
415
416    #[test]
417    fn from_unix_secs_negative() {
418        let ts = Timestamp::from_unix_secs(-1);
419        assert_eq!(ts.seconds, -1);
420        assert_eq!(ts.nanos, 0);
421    }
422
423    #[test]
424    fn from_unix_secs_i64_min() {
425        let ts = Timestamp::from_unix_secs(i64::MIN);
426        assert_eq!(ts.seconds, i64::MIN);
427        assert_eq!(ts.nanos, 0);
428    }
429
430    #[test]
431    fn from_unix_secs_i64_max() {
432        let ts = Timestamp::from_unix_secs(i64::MAX);
433        assert_eq!(ts.seconds, i64::MAX);
434        assert_eq!(ts.nanos, 0);
435    }
436
437    #[test]
438    fn from_unix_basic() {
439        let ts = Timestamp::from_unix(1_000_000_000, 500_000_000);
440        assert_eq!(ts.seconds, 1_000_000_000);
441        assert_eq!(ts.nanos, 500_000_000);
442    }
443
444    #[test]
445    fn from_unix_zero() {
446        let ts = Timestamp::from_unix(0, 0);
447        assert_eq!(ts.seconds, 0);
448        assert_eq!(ts.nanos, 0);
449    }
450
451    #[test]
452    fn from_unix_checked_valid() {
453        assert!(Timestamp::from_unix_checked(0, 0).is_some());
454        assert!(Timestamp::from_unix_checked(-100, 999_999_999).is_some());
455    }
456
457    #[test]
458    fn from_unix_checked_invalid_nanos() {
459        assert!(Timestamp::from_unix_checked(0, -1).is_none());
460        assert!(Timestamp::from_unix_checked(0, 1_000_000_000).is_none());
461    }
462
463    #[cfg(feature = "std")]
464    #[test]
465    fn systemtime_roundtrip_post_epoch() {
466        let ts = Timestamp::from_unix(1_700_000_000, 123_456_789);
467        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
468        let ts2: Timestamp = st.into();
469        assert_eq!(ts, ts2);
470    }
471
472    #[cfg(feature = "std")]
473    #[test]
474    fn systemtime_roundtrip_pre_epoch() {
475        // -1.5 seconds before epoch: seconds = -2, nanos = 500_000_000
476        let ts = Timestamp::from_unix(-2, 500_000_000);
477        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
478        let ts2: Timestamp = st.into();
479        assert_eq!(ts, ts2);
480    }
481
482    #[cfg(feature = "std")]
483    #[test]
484    fn systemtime_roundtrip_exact_pre_epoch() {
485        // Exactly 2 seconds before epoch.
486        let ts = Timestamp::from_unix(-2, 0);
487        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
488        let ts2: Timestamp = st.into();
489        assert_eq!(ts, ts2);
490    }
491
492    #[cfg(feature = "std")]
493    #[test]
494    fn systemtime_roundtrip_epoch() {
495        let ts = Timestamp::from_unix(0, 0);
496        let st: std::time::SystemTime = ts.clone().try_into().unwrap();
497        let ts2: Timestamp = st.into();
498        assert_eq!(ts, ts2);
499    }
500
501    #[cfg(feature = "std")]
502    #[test]
503    fn invalid_nanos_rejected() {
504        let ts = Timestamp {
505            seconds: 0,
506            nanos: -1,
507            ..Default::default()
508        };
509        let result: Result<std::time::SystemTime, _> = ts.try_into();
510        assert_eq!(result, Err(TimestampError::InvalidNanos));
511
512        let ts2 = Timestamp {
513            seconds: 0,
514            nanos: 1_000_000_000,
515            ..Default::default()
516        };
517        let result2: Result<std::time::SystemTime, _> = ts2.try_into();
518        assert_eq!(result2, Err(TimestampError::InvalidNanos));
519    }
520
521    #[cfg(feature = "std")]
522    #[test]
523    fn i64_min_seconds_does_not_panic() {
524        // i64::MIN cannot be negated in i64; unsigned_abs() must be used.
525        let ts = Timestamp {
526            seconds: i64::MIN,
527            nanos: 0,
528            ..Default::default()
529        };
530        // The conversion should either succeed or return Overflow, never panic.
531        let _: Result<std::time::SystemTime, _> = ts.try_into();
532    }
533
534    #[cfg(feature = "std")]
535    #[test]
536    fn now_is_positive() {
537        let ts = Timestamp::now();
538        assert!(ts.seconds > 0, "current time should be after Unix epoch");
539    }
540
541    #[test]
542    fn timestamp_view_round_trip() {
543        use crate::google::protobuf::{Timestamp, TimestampView};
544        use buffa::{Message, MessageView};
545
546        let ts = Timestamp {
547            seconds: 1_700_000_000,
548            nanos: 123_456_789,
549            ..Default::default()
550        };
551        let bytes = ts.encode_to_vec();
552        let view = TimestampView::decode_view(&bytes).expect("decode_view");
553        assert_eq!(view.seconds, ts.seconds);
554        assert_eq!(view.nanos, ts.nanos);
555
556        let owned = view.to_owned_message();
557        assert_eq!(owned, ts);
558    }
559
560    #[cfg(feature = "json")]
561    mod serde_tests {
562        use super::*;
563
564        // ---- RFC 3339 helper unit tests -----------------------------------
565
566        #[test]
567        fn days_to_date_epoch() {
568            assert_eq!(days_to_date(0), (1970, 1, 1));
569        }
570
571        #[test]
572        fn days_to_date_known_date() {
573            // 2021-01-01: days since epoch = 18628
574            assert_eq!(days_to_date(18628), (2021, 1, 1));
575        }
576
577        #[test]
578        fn date_to_days_roundtrip() {
579            let (y, m, d) = days_to_date(18628);
580            assert_eq!(date_to_days(y, m, d), Some(18628));
581        }
582
583        #[test]
584        fn date_to_days_invalid_month() {
585            assert_eq!(date_to_days(2021, 13, 1), None);
586            assert_eq!(date_to_days(2021, 0, 1), None);
587        }
588
589        #[test]
590        fn rfc3339_epoch() {
591            assert_eq!(timestamp_to_rfc3339(0, 0), "1970-01-01T00:00:00Z");
592        }
593
594        #[test]
595        fn rfc3339_half_second() {
596            assert_eq!(
597                timestamp_to_rfc3339(0, 500_000_000),
598                "1970-01-01T00:00:00.500Z"
599            );
600        }
601
602        #[test]
603        fn rfc3339_one_nanosecond() {
604            assert_eq!(timestamp_to_rfc3339(0, 1), "1970-01-01T00:00:00.000000001Z");
605        }
606
607        #[test]
608        fn parse_epoch() {
609            assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some((0, 0)));
610        }
611
612        #[test]
613        fn parse_with_fractional_seconds() {
614            assert_eq!(
615                parse_rfc3339("1970-01-01T00:00:00.5Z"),
616                Some((0, 500_000_000))
617            );
618        }
619
620        #[test]
621        fn parse_with_positive_offset() {
622            // +05:00 means local is 5h ahead, so UTC = local - 5h
623            assert_eq!(parse_rfc3339("1970-01-01T05:00:00+05:00"), Some((0, 0)));
624        }
625
626        #[test]
627        fn parse_invalid() {
628            assert_eq!(parse_rfc3339("not-a-date"), None);
629            assert_eq!(parse_rfc3339("1970-01-01T00:00:00"), None); // missing tz
630        }
631
632        // ---- serde roundtrips ---------------------------------------------
633
634        #[test]
635        fn timestamp_epoch_roundtrip() {
636            let ts = Timestamp::from_unix(0, 0);
637            let json = serde_json::to_string(&ts).unwrap();
638            assert_eq!(json, r#""1970-01-01T00:00:00Z""#);
639            let back: Timestamp = serde_json::from_str(&json).unwrap();
640            assert_eq!(back.seconds, 0);
641            assert_eq!(back.nanos, 0);
642        }
643
644        #[test]
645        fn timestamp_with_nanos_roundtrip() {
646            let ts = Timestamp::from_unix(1_000_000_000, 500_000_000);
647            let json = serde_json::to_string(&ts).unwrap();
648            let back: Timestamp = serde_json::from_str(&json).unwrap();
649            assert_eq!(back.seconds, ts.seconds);
650            assert_eq!(back.nanos, ts.nanos);
651        }
652
653        #[test]
654        fn timestamp_pre_epoch_roundtrip() {
655            // -1.5 seconds before epoch: seconds = -2, nanos = 500_000_000
656            let ts = Timestamp::from_unix(-2, 500_000_000);
657            let json = serde_json::to_string(&ts).unwrap();
658            let back: Timestamp = serde_json::from_str(&json).unwrap();
659            assert_eq!(back.seconds, ts.seconds);
660            assert_eq!(back.nanos, ts.nanos);
661        }
662
663        #[test]
664        fn timestamp_invalid_string_is_error() {
665            let result: Result<Timestamp, _> = serde_json::from_str(r#""not-a-date""#);
666            assert!(result.is_err());
667        }
668
669        #[test]
670        fn timestamp_invalid_nanos_is_serialize_error() {
671            let ts = Timestamp {
672                seconds: 0,
673                nanos: -1,
674                ..Default::default()
675            };
676            let result = serde_json::to_string(&ts);
677            assert!(result.is_err(), "negative nanos must fail serialization");
678        }
679
680        #[test]
681        fn parse_lowercase_separators_rejected() {
682            // Proto3 JSON spec requires uppercase 'T' and 'Z'.
683            assert_eq!(parse_rfc3339("1970-01-01T00:00:00z"), None);
684            assert_eq!(parse_rfc3339("1970-01-01t00:00:00Z"), None);
685            assert_eq!(parse_rfc3339("1970-01-01t00:00:00z"), None);
686        }
687
688        #[test]
689        fn parse_date_to_days_rejects_feb_30() {
690            // "Feb 30" is not a real date; parse_rfc3339 must return None.
691            assert_eq!(parse_rfc3339("2021-02-30T00:00:00Z"), None);
692        }
693
694        #[test]
695        fn parse_time_component_range_rejected() {
696            // Hour, minute, second must be in valid ranges.
697            assert_eq!(parse_rfc3339("2021-01-01T24:00:00Z"), None, "hour 24");
698            assert_eq!(parse_rfc3339("2021-01-01T25:00:00Z"), None, "hour 25");
699            assert_eq!(parse_rfc3339("2021-01-01T00:60:00Z"), None, "min 60");
700            assert_eq!(parse_rfc3339("2021-01-01T00:99:00Z"), None, "min 99");
701            assert_eq!(parse_rfc3339("2021-01-01T00:00:60Z"), None, "sec 60 (leap)");
702            assert_eq!(parse_rfc3339("2021-01-01T00:00:99Z"), None, "sec 99");
703            // Valid boundaries.
704            assert!(parse_rfc3339("2021-01-01T23:59:59Z").is_some());
705            assert!(parse_rfc3339("2021-01-01T00:00:00Z").is_some());
706        }
707
708        #[test]
709        fn parse_offset_range_rejected() {
710            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+24:00"), None, "oh 24");
711            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+99:00"), None, "oh 99");
712            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+00:60"), None, "om 60");
713            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+99:99"), None, "both");
714            // Valid boundaries.
715            assert!(parse_rfc3339("2021-01-01T00:00:00+23:59").is_some());
716            assert!(parse_rfc3339("2021-01-01T00:00:00-23:59").is_some());
717        }
718
719        #[test]
720        fn parse_separator_chars_rejected() {
721            // Hyphens in date, colons in time, colon in offset are required.
722            assert_eq!(parse_rfc3339("2021X01-01T00:00:00Z"), None, "date[4]");
723            assert_eq!(parse_rfc3339("2021-01X01T00:00:00Z"), None, "date[7]");
724            assert_eq!(parse_rfc3339("2021-01-01T00X00:00Z"), None, "time[2]");
725            assert_eq!(parse_rfc3339("2021-01-01T00:00X00Z"), None, "time[5]");
726            assert_eq!(parse_rfc3339("2021-01-01T00:00:00+05X30"), None, "off");
727            // All separators wrong at once.
728            assert_eq!(parse_rfc3339("2021X01X01T00X00X00Z"), None);
729        }
730
731        #[test]
732        fn parse_fractional_seconds_rejects_non_digits() {
733            // Regression (fuzzer-found): i32::parse accepts '-' and '+',
734            // which previously allowed "T23:59:59.-3Z" → nanos = -30_000_000.
735            assert_eq!(parse_rfc3339("1970-01-01T00:00:00.-3Z"), None, "minus");
736            assert_eq!(parse_rfc3339("1970-01-01T00:00:00.+3Z"), None, "plus");
737            assert_eq!(parse_rfc3339("1970-01-01T00:00:00.3aZ"), None, "alpha");
738            assert_eq!(parse_rfc3339("1970-01-01T00:00:00. Z"), None, "space");
739            // Edge: 9999-12-31T23:59:59.-3Z — the fuzzer's original crash input.
740            assert_eq!(parse_rfc3339("9999-12-31T23:59:59.-3Z"), None);
741            // Valid digits still work.
742            assert_eq!(
743                parse_rfc3339("1970-01-01T00:00:00.5Z"),
744                Some((0, 500_000_000))
745            );
746            assert_eq!(
747                parse_rfc3339("1970-01-01T00:00:00.000000001Z"),
748                Some((0, 1))
749            );
750        }
751
752        #[test]
753        fn parse_offset_pushes_past_boundary_rejected() {
754            // Year is 9999 (passes pre-offset check), but -23:59 offset means
755            // UTC is in year 10000 — must be rejected per proto Timestamp range.
756            assert_eq!(parse_rfc3339("9999-12-31T23:59:59-23:59"), None);
757            // Year is 0001 (passes), but +23:59 offset means UTC is in year 0.
758            assert_eq!(parse_rfc3339("0001-01-01T00:00:00+23:59"), None);
759            // Boundary values that just fit are OK.
760            assert_eq!(
761                parse_rfc3339("9999-12-31T23:59:59Z"),
762                Some((MAX_TIMESTAMP_SECS, 0))
763            );
764            assert_eq!(
765                parse_rfc3339("0001-01-01T00:00:00Z"),
766                Some((MIN_TIMESTAMP_SECS, 0))
767            );
768        }
769    }
770}