Skip to main content

buffa_types/
duration_ext.rs

1//! Ergonomic helpers for [`google::protobuf::Duration`](crate::google::protobuf::Duration).
2
3use crate::google::protobuf::Duration;
4
5/// Errors that can occur when converting a protobuf [`Duration`] to a Rust type.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
7pub enum DurationError {
8    /// The duration is negative and cannot be represented as [`std::time::Duration`].
9    #[error("negative protobuf Duration cannot be converted to std::time::Duration")]
10    NegativeDuration,
11    /// The `nanos` field is outside its valid range, or its sign is inconsistent
12    /// with the `seconds` field.
13    ///
14    /// Per the protobuf spec, `nanos` must be in `[-999_999_999, 999_999_999]`
15    /// and must have the same sign (or be zero) as `seconds`.
16    #[error("nanos field has invalid value or sign mismatch with seconds")]
17    InvalidNanos,
18}
19
20#[cfg(feature = "std")]
21impl TryFrom<Duration> for std::time::Duration {
22    type Error = DurationError;
23
24    /// Convert a protobuf [`Duration`] to a [`std::time::Duration`].
25    ///
26    /// # Errors
27    ///
28    /// Returns [`DurationError::InvalidNanos`] if `nanos` is outside
29    /// `[-999_999_999, 999_999_999]` or if its sign is inconsistent with
30    /// `seconds` (e.g. positive nanos with negative seconds).
31    ///
32    /// Returns [`DurationError::NegativeDuration`] if the duration is
33    /// negative but otherwise well-formed (e.g. `seconds < 0`, `nanos ≤ 0`),
34    /// since [`std::time::Duration`] cannot represent negative values.
35    fn try_from(d: Duration) -> Result<Self, Self::Error> {
36        // Protobuf spec: nanos ∈ [-999_999_999, 999_999_999].
37        // Use a range check rather than .abs() to avoid overflow on i32::MIN.
38        if !(-999_999_999..=999_999_999).contains(&d.nanos) {
39            return Err(DurationError::InvalidNanos);
40        }
41        // Protobuf spec: nanos sign must match seconds sign (or nanos is zero).
42        let sign_mismatch = (d.seconds > 0 && d.nanos < 0) || (d.seconds < 0 && d.nanos > 0);
43        if sign_mismatch {
44            return Err(DurationError::InvalidNanos);
45        }
46        // std::time::Duration is unsigned; reject well-formed negative durations.
47        if d.seconds < 0 || d.nanos < 0 {
48            return Err(DurationError::NegativeDuration);
49        }
50        Ok(Self::new(d.seconds as u64, d.nanos as u32))
51    }
52}
53
54#[cfg(feature = "std")]
55impl From<std::time::Duration> for Duration {
56    /// Convert a [`std::time::Duration`] to a protobuf [`Duration`].
57    ///
58    /// # Saturation
59    ///
60    /// Durations whose `as_secs()` exceeds `i64::MAX` (~292 billion years) are
61    /// saturated to `i64::MAX` seconds rather than wrapping, which would produce
62    /// an incorrect negative value.
63    fn from(d: std::time::Duration) -> Self {
64        Self {
65            // Saturate at i64::MAX rather than wrapping for extremely large durations.
66            seconds: d.as_secs().min(i64::MAX as u64) as i64,
67            nanos: d.subsec_nanos() as i32,
68            ..Default::default()
69        }
70    }
71}
72
73// ── RFC 3339-style decimal-seconds formatting ─────────────────────────────────
74
75// ── Decimal seconds formatting ──────────────────────────────────────────────
76//
77// The shared formatting and parsing primitives live in
78// `buffa::json_helpers::wkt`. Both this typed serde impl and `buffa-descriptor`'s
79// reflective JSON codec call into the same code, so the two paths can't drift
80// on edge cases the conformance suite exercises. The functions below are thin
81// adapters that preserve the `Option`-returning private API the test suite
82// targets.
83
84#[cfg(feature = "json")]
85fn duration_to_string(secs: i64, nanos: i32) -> alloc::string::String {
86    // The serde `Serialize` impl validates `(seconds, nanos)` with
87    // `is_valid_duration` before calling this; `expect` documents the invariant.
88    buffa::json_helpers::wkt::fmt_duration(secs, nanos)
89        .expect("Duration validated before formatting")
90}
91
92#[cfg(feature = "json")]
93fn parse_duration_string(s: &str) -> Option<(i64, i32)> {
94    buffa::json_helpers::wkt::parse_duration(s).ok()
95}
96
97#[cfg(feature = "json")]
98fn is_valid_duration(secs: i64, nanos: i32) -> bool {
99    buffa::json_helpers::wkt::validate_duration(secs, nanos).is_ok()
100}
101
102// ── serde impls ──────────────────────────────────────────────────────────────
103
104#[cfg(feature = "json")]
105impl serde::Serialize for Duration {
106    /// Serializes as a decimal seconds string (e.g. `"1.5s"`, `"-0.001s"`).
107    ///
108    /// # Errors
109    ///
110    /// Returns a serialization error if the duration is outside the proto
111    /// spec range of ±315,576,000,000 seconds, or if nanos is invalid.
112    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
113        use alloc::format;
114        if !is_valid_duration(self.seconds, self.nanos) {
115            return Err(serde::ser::Error::custom(format!(
116                "invalid Duration: seconds={}, nanos={} is out of range",
117                self.seconds, self.nanos
118            )));
119        }
120        s.serialize_str(&duration_to_string(self.seconds, self.nanos))
121    }
122}
123
124#[cfg(feature = "json")]
125impl<'de> serde::Deserialize<'de> for Duration {
126    /// Deserializes from a decimal seconds string (e.g. `"1.5s"`).
127    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
128        use alloc::{format, string::String};
129        let s: String = serde::Deserialize::deserialize(d)?;
130        let (seconds, nanos) = parse_duration_string(&s)
131            .ok_or_else(|| serde::de::Error::custom(format!("invalid Duration string: {s}")))?;
132        Ok(Self {
133            seconds,
134            nanos,
135            ..Default::default()
136        })
137    }
138}
139
140impl Duration {
141    /// Create a [`Duration`] from a whole number of seconds.
142    pub fn from_secs(seconds: i64) -> Self {
143        Self {
144            seconds,
145            nanos: 0,
146            ..Default::default()
147        }
148    }
149
150    /// Create a [`Duration`] from seconds and nanoseconds.
151    ///
152    /// # Panics
153    ///
154    /// Panics in debug mode if `nanos` is outside `[-999_999_999, 999_999_999]`
155    /// or if its sign is inconsistent with `seconds`.  When `seconds` is zero,
156    /// `nanos` may be positive, negative, or zero.  In release mode the value
157    /// is stored as-is.  Use [`Duration::from_secs_nanos_checked`] for a variant
158    /// that returns `None` on invalid input.
159    pub fn from_secs_nanos(seconds: i64, nanos: i32) -> Self {
160        // Use a range check rather than .abs() to avoid overflow on i32::MIN.
161        debug_assert!(
162            (-999_999_999..=999_999_999).contains(&nanos),
163            "nanos ({nanos}) must be in [-999_999_999, 999_999_999]"
164        );
165        debug_assert!(
166            !((seconds > 0 && nanos < 0) || (seconds < 0 && nanos > 0)),
167            "nanos sign must be consistent with seconds sign"
168        );
169        Self {
170            seconds,
171            nanos,
172            ..Default::default()
173        }
174    }
175
176    /// Create a [`Duration`] from seconds and nanoseconds, returning `None`
177    /// if `nanos` is out of range or has a sign inconsistent with `seconds`.
178    pub fn from_secs_nanos_checked(seconds: i64, nanos: i32) -> Option<Self> {
179        // Use a range check rather than .abs() to avoid overflow on i32::MIN.
180        if !(-999_999_999..=999_999_999).contains(&nanos) {
181            return None;
182        }
183        if (seconds > 0 && nanos < 0) || (seconds < 0 && nanos > 0) {
184            return None;
185        }
186        Some(Self {
187            seconds,
188            nanos,
189            ..Default::default()
190        })
191    }
192
193    /// Create a [`Duration`] from a number of milliseconds.
194    ///
195    /// The sign of `millis` determines the sign of both `seconds` and the
196    /// sub-second `nanos` field, per the protobuf sign-consistency rule.
197    pub fn from_millis(millis: i64) -> Self {
198        Self {
199            seconds: millis / 1_000,
200            // Remainder is in [-999, 999]; after ×1_000_000 → [-999_000_000, 999_000_000],
201            // which fits in i32 (max ≈ ±2.1 billion). Cast is lossless.
202            nanos: ((millis % 1_000) * 1_000_000) as i32,
203            ..Default::default()
204        }
205    }
206
207    /// Create a [`Duration`] from a number of microseconds.
208    pub fn from_micros(micros: i64) -> Self {
209        Self {
210            seconds: micros / 1_000_000,
211            // Remainder is in [-999_999, 999_999]; after ×1_000 → [-999_999_000, 999_999_000],
212            // which fits in i32. Cast is lossless.
213            nanos: ((micros % 1_000_000) * 1_000) as i32,
214            ..Default::default()
215        }
216    }
217
218    /// Create a [`Duration`] from a number of nanoseconds.
219    pub fn from_nanos(nanos: i64) -> Self {
220        Self {
221            seconds: nanos / 1_000_000_000,
222            // Remainder is in [-999_999_999, 999_999_999], which fits in i32. Cast is lossless.
223            nanos: (nanos % 1_000_000_000) as i32,
224            ..Default::default()
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[cfg(feature = "std")]
234    #[test]
235    fn std_duration_roundtrip() {
236        let d = std::time::Duration::new(300, 500_000_000);
237        let proto: Duration = d.into();
238        assert_eq!(proto.seconds, 300);
239        assert_eq!(proto.nanos, 500_000_000);
240        let back: std::time::Duration = proto.try_into().unwrap();
241        assert_eq!(back, d);
242    }
243
244    #[cfg(feature = "std")]
245    #[test]
246    fn zero_duration_roundtrip() {
247        let d = std::time::Duration::ZERO;
248        let proto: Duration = d.into();
249        let back: std::time::Duration = proto.try_into().unwrap();
250        assert_eq!(back, d);
251    }
252
253    #[cfg(feature = "std")]
254    #[test]
255    fn negative_duration_rejected() {
256        let neg = Duration {
257            seconds: -5,
258            nanos: 0,
259            ..Default::default()
260        };
261        let result: Result<std::time::Duration, _> = neg.try_into();
262        assert_eq!(result, Err(DurationError::NegativeDuration));
263    }
264
265    #[cfg(feature = "std")]
266    #[test]
267    fn invalid_nanos_rejected() {
268        let bad = Duration {
269            seconds: 1,
270            nanos: 1_000_000_000,
271            ..Default::default()
272        };
273        let result: Result<std::time::Duration, _> = bad.try_into();
274        assert_eq!(result, Err(DurationError::InvalidNanos));
275    }
276
277    // ---- from_millis / from_micros / from_nanos ---------------------------
278
279    #[test]
280    fn from_millis_positive() {
281        let d = Duration::from_millis(1_500);
282        assert_eq!(d.seconds, 1);
283        assert_eq!(d.nanos, 500_000_000);
284    }
285
286    #[test]
287    fn from_millis_negative() {
288        let d = Duration::from_millis(-1_500);
289        assert_eq!(d.seconds, -1);
290        assert_eq!(d.nanos, -500_000_000);
291    }
292
293    #[test]
294    fn from_millis_exact_seconds() {
295        let d = Duration::from_millis(2_000);
296        assert_eq!(d.seconds, 2);
297        assert_eq!(d.nanos, 0);
298    }
299
300    #[test]
301    fn from_micros_positive() {
302        let d = Duration::from_micros(1_500_000);
303        assert_eq!(d.seconds, 1);
304        assert_eq!(d.nanos, 500_000_000);
305    }
306
307    #[test]
308    fn from_micros_negative() {
309        let d = Duration::from_micros(-750);
310        assert_eq!(d.seconds, 0);
311        assert_eq!(d.nanos, -750_000);
312    }
313
314    #[test]
315    fn from_nanos_positive() {
316        let d = Duration::from_nanos(1_500_000_000);
317        assert_eq!(d.seconds, 1);
318        assert_eq!(d.nanos, 500_000_000);
319    }
320
321    #[test]
322    fn from_nanos_negative() {
323        let d = Duration::from_nanos(-2_000_000_000);
324        assert_eq!(d.seconds, -2);
325        assert_eq!(d.nanos, 0);
326    }
327
328    #[test]
329    fn from_nanos_sub_second() {
330        let d = Duration::from_nanos(999_999_999);
331        assert_eq!(d.seconds, 0);
332        assert_eq!(d.nanos, 999_999_999);
333    }
334
335    #[test]
336    fn from_millis_i64_min() {
337        // i64::MIN = -9_223_372_036_854_775_808
338        // remainder = i64::MIN % 1_000 = -808 (Rust truncation remainder)
339        // nanos cast: -808 * 1_000_000 = -808_000_000, fits in i32
340        let d = Duration::from_millis(i64::MIN);
341        assert_eq!(d.nanos, -808_000_000_i32);
342    }
343
344    #[test]
345    fn from_millis_i64_max() {
346        // i64::MAX = 9_223_372_036_854_775_807; remainder = 807
347        let d = Duration::from_millis(i64::MAX);
348        assert_eq!(d.nanos, 807_000_000_i32);
349    }
350
351    #[test]
352    fn from_micros_i64_min() {
353        // remainder = i64::MIN % 1_000_000 = -775_808
354        // nanos cast: -775_808 * 1_000 = -775_808_000, fits in i32
355        let d = Duration::from_micros(i64::MIN);
356        assert_eq!(d.nanos, -775_808_000_i32);
357    }
358
359    #[test]
360    fn from_nanos_i64_min() {
361        // remainder = i64::MIN % 1_000_000_000 = -854_775_808, fits in i32
362        let d = Duration::from_nanos(i64::MIN);
363        assert_eq!(d.nanos, -854_775_808_i32);
364    }
365
366    #[test]
367    fn from_nanos_i64_max() {
368        // remainder = i64::MAX % 1_000_000_000 = 854_775_807, fits in i32
369        let d = Duration::from_nanos(i64::MAX);
370        assert_eq!(d.nanos, 854_775_807_i32);
371    }
372
373    // ---- TryFrom edge cases -----------------------------------------------
374
375    #[cfg(feature = "std")]
376    #[test]
377    fn nanos_i32_min_is_invalid() {
378        // i32::MIN cannot be represented as a valid protobuf nanos value
379        // (valid range is [-999_999_999, 999_999_999]).  Using .abs() on
380        // i32::MIN overflows; we use a range check to avoid that.
381        let bad = Duration {
382            seconds: 0,
383            nanos: i32::MIN,
384            ..Default::default()
385        };
386        let result: Result<std::time::Duration, _> = bad.try_into();
387        assert_eq!(result, Err(DurationError::InvalidNanos));
388    }
389
390    #[cfg(feature = "std")]
391    #[test]
392    fn negative_seconds_and_negative_nanos_is_negative_duration() {
393        // A well-formed negative duration (sign-consistent) must return
394        // NegativeDuration, not InvalidNanos.
395        let neg = Duration {
396            seconds: -5,
397            nanos: -500_000_000,
398            ..Default::default()
399        };
400        let result: Result<std::time::Duration, _> = neg.try_into();
401        assert_eq!(result, Err(DurationError::NegativeDuration));
402    }
403
404    // ---- from_secs --------------------------------------------------------
405
406    #[test]
407    fn from_secs_zero() {
408        let d = Duration::from_secs(0);
409        assert_eq!(d.seconds, 0);
410        assert_eq!(d.nanos, 0);
411    }
412
413    #[test]
414    fn from_secs_positive() {
415        let d = Duration::from_secs(300);
416        assert_eq!(d.seconds, 300);
417        assert_eq!(d.nanos, 0);
418    }
419
420    #[test]
421    fn from_secs_negative() {
422        let d = Duration::from_secs(-7);
423        assert_eq!(d.seconds, -7);
424        assert_eq!(d.nanos, 0);
425    }
426
427    // ---- from_secs_nanos_checked ------------------------------------------
428
429    #[test]
430    fn from_secs_nanos_checked_valid_positive() {
431        let d = Duration::from_secs_nanos_checked(1, 999_999_999).unwrap();
432        assert_eq!(d.seconds, 1);
433        assert_eq!(d.nanos, 999_999_999);
434    }
435
436    #[test]
437    fn from_secs_nanos_checked_valid_negative() {
438        let d = Duration::from_secs_nanos_checked(-1, -999_999_999).unwrap();
439        assert_eq!(d.seconds, -1);
440        assert_eq!(d.nanos, -999_999_999);
441    }
442
443    #[test]
444    fn from_secs_nanos_checked_nanos_out_of_range() {
445        assert!(Duration::from_secs_nanos_checked(1, 1_000_000_000).is_none());
446    }
447
448    #[test]
449    fn from_secs_nanos_checked_i32_min_nanos_is_none() {
450        // i32::MIN would overflow .abs(); the range check must handle it.
451        assert!(Duration::from_secs_nanos_checked(0, i32::MIN).is_none());
452    }
453
454    #[test]
455    fn from_secs_nanos_checked_sign_mismatch_is_none() {
456        assert!(Duration::from_secs_nanos_checked(-1, 1).is_none());
457        assert!(Duration::from_secs_nanos_checked(1, -1).is_none());
458    }
459
460    #[test]
461    fn from_secs_nanos_checked_zero_seconds_allows_negative_nanos() {
462        // When seconds == 0, the sign rule does not apply; nanos may be negative.
463        let d = Duration::from_secs_nanos_checked(0, -500_000_000).unwrap();
464        assert_eq!(d.seconds, 0);
465        assert_eq!(d.nanos, -500_000_000);
466    }
467
468    // ---- from_secs_nanos (panic path tested via checked variant above) ----
469
470    #[test]
471    fn from_secs_nanos_valid() {
472        let d = Duration::from_secs_nanos(2, 500_000_000);
473        assert_eq!(d.seconds, 2);
474        assert_eq!(d.nanos, 500_000_000);
475    }
476
477    // ---- saturation -------------------------------------------------------
478
479    #[cfg(feature = "std")]
480    #[test]
481    fn large_std_duration_saturates_to_i64_max_seconds() {
482        // std::time::Duration can represent values far beyond i64::MAX seconds
483        // (its seconds are stored as u64).  The From impl must saturate rather
484        // than wrap, which would produce a negative seconds value.
485        let huge = std::time::Duration::from_secs(u64::MAX);
486        let proto: Duration = huge.into();
487        assert_eq!(proto.seconds, i64::MAX);
488        // Subsecond nanos are zero because u64::MAX is a whole number of seconds.
489        assert_eq!(proto.nanos, 0);
490    }
491
492    // ---- serde ----------------------------------------------------------------
493
494    #[cfg(feature = "json")]
495    mod serde_tests {
496        use super::*;
497
498        #[test]
499        fn duration_zero_roundtrip() {
500            let d = Duration::from_secs(0);
501            let json = serde_json::to_string(&d).unwrap();
502            assert_eq!(json, r#""0s""#);
503            let back: Duration = serde_json::from_str(&json).unwrap();
504            assert_eq!(back.seconds, 0);
505            assert_eq!(back.nanos, 0);
506        }
507
508        #[test]
509        fn duration_positive_whole_seconds_roundtrip() {
510            let d = Duration::from_secs(300);
511            let json = serde_json::to_string(&d).unwrap();
512            assert_eq!(json, r#""300s""#);
513            let back: Duration = serde_json::from_str(&json).unwrap();
514            assert_eq!(back.seconds, 300);
515            assert_eq!(back.nanos, 0);
516        }
517
518        #[test]
519        fn duration_millis_precision_roundtrip() {
520            let d = Duration::from_secs_nanos(1, 500_000_000);
521            let json = serde_json::to_string(&d).unwrap();
522            assert_eq!(json, r#""1.500s""#);
523            let back: Duration = serde_json::from_str(&json).unwrap();
524            assert_eq!(back.seconds, 1);
525            assert_eq!(back.nanos, 500_000_000);
526        }
527
528        #[test]
529        fn duration_micros_precision_roundtrip() {
530            let d = Duration::from_secs_nanos(0, 1_000);
531            let json = serde_json::to_string(&d).unwrap();
532            assert_eq!(json, r#""0.000001s""#);
533            let back: Duration = serde_json::from_str(&json).unwrap();
534            assert_eq!(back.nanos, 1_000);
535        }
536
537        #[test]
538        fn duration_nanos_precision_roundtrip() {
539            let d = Duration::from_secs_nanos(0, 1);
540            let json = serde_json::to_string(&d).unwrap();
541            assert_eq!(json, r#""0.000000001s""#);
542            let back: Duration = serde_json::from_str(&json).unwrap();
543            assert_eq!(back.nanos, 1);
544        }
545
546        #[test]
547        fn duration_negative_roundtrip() {
548            let d = Duration::from_secs_nanos(-1, -500_000_000);
549            let json = serde_json::to_string(&d).unwrap();
550            assert_eq!(json, r#""-1.500s""#);
551            let back: Duration = serde_json::from_str(&json).unwrap();
552            assert_eq!(back.seconds, -1);
553            assert_eq!(back.nanos, -500_000_000);
554        }
555
556        #[test]
557        fn duration_invalid_string_is_error() {
558            let result: Result<Duration, _> = serde_json::from_str(r#""1.5""#); // missing 's'
559            assert!(result.is_err());
560        }
561
562        #[test]
563        fn parse_duration_rejects_double_sign() {
564            // Regression: "--5s" used to strip one '-' then parse "-5"
565            // via i64::parse, yielding +5 via double negation. Now rejected.
566            assert_eq!(parse_duration_string("--5s"), None);
567            assert_eq!(parse_duration_string("-+5s"), None);
568            assert_eq!(parse_duration_string("+5s"), None); // '+' never valid
569                                                            // The fractional variant was already caught by sign mismatch,
570                                                            // but verify it still is.
571            assert_eq!(parse_duration_string("--5.5s"), None);
572            // Sanity: valid negative still works.
573            assert_eq!(parse_duration_string("-5s"), Some((-5, 0)));
574        }
575
576        #[test]
577        fn parse_duration_rejects_non_digit_fractional() {
578            // Regression (fuzzer-found): "5.-3s" previously parsed with
579            // nano_str="-3" → i32::parse accepts it → nanos=-300000000.
580            // Same class as the double-sign bug but in the fractional part.
581            assert_eq!(parse_duration_string("5.-3s"), None, "minus in frac");
582            assert_eq!(parse_duration_string("5.+3s"), None, "plus in frac");
583            assert_eq!(parse_duration_string("-5.-3s"), None, "double neg frac");
584            assert_eq!(parse_duration_string("5.3as"), None, "alpha in frac");
585            assert_eq!(parse_duration_string("5. s"), None, "space in frac");
586            // Valid fractional still works.
587            assert_eq!(parse_duration_string("5.3s"), Some((5, 300_000_000)));
588            assert_eq!(parse_duration_string("-5.3s"), Some((-5, -300_000_000)));
589        }
590    }
591
592    #[cfg(feature = "std")]
593    #[test]
594    fn negative_nanos_on_positive_seconds_is_invalid_nanos() {
595        // Duration { seconds: 5, nanos: -1 } has a sign mismatch — nanos is negative
596        // while seconds is positive.  This should be InvalidNanos, not NegativeDuration.
597        let bad = Duration {
598            seconds: 5,
599            nanos: -1,
600            ..Default::default()
601        };
602        let result: Result<std::time::Duration, _> = bad.try_into();
603        assert_eq!(result, Err(DurationError::InvalidNanos));
604    }
605}