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(std::time::Duration::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        Duration {
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/// Format a protobuf Duration as a decimal seconds string with an `s` suffix.
76///
77/// The nanos field is formatted with 0, 3, 6, or 9 fractional digits depending
78/// on precision needed. Negative durations (where `seconds < 0` or
79/// `seconds == 0 && nanos < 0`) are prefixed with `-`.
80#[cfg(feature = "json")]
81fn duration_to_string(secs: i64, nanos: i32) -> alloc::string::String {
82    use alloc::format;
83    use alloc::string::String;
84    let negative = secs < 0 || (secs == 0 && nanos < 0);
85    let abs_secs = secs.unsigned_abs();
86    let abs_nanos = nanos.unsigned_abs();
87    let sign = if negative { "-" } else { "" };
88    let frac = if abs_nanos == 0 {
89        String::new()
90    } else if abs_nanos % 1_000_000 == 0 {
91        format!(".{:03}", abs_nanos / 1_000_000)
92    } else if abs_nanos % 1_000 == 0 {
93        format!(".{:06}", abs_nanos / 1_000)
94    } else {
95        format!(".{:09}", abs_nanos)
96    };
97    format!("{sign}{abs_secs}{frac}s")
98}
99
100/// Parse a decimal seconds string (e.g. `"1.5s"`, `"-0.001s"`) to (seconds, nanos).
101/// Returns `None` if the string is malformed.
102#[cfg(feature = "json")]
103fn parse_duration_string(s: &str) -> Option<(i64, i32)> {
104    let body = s.strip_suffix('s')?;
105    let negative = body.starts_with('-');
106    let body = if negative {
107        body.strip_prefix('-')?
108    } else {
109        body
110    };
111    // Reject residual sign after stripping: "--5s" would otherwise parse as
112    // -5 via i64::parse and the double negation would yield +5 silently.
113    if body.starts_with(['-', '+']) {
114        return None;
115    }
116
117    let (sec_str, nano_str) = match body.find('.') {
118        Some(dot) => (&body[..dot], &body[dot + 1..]),
119        None => (body, ""),
120    };
121
122    let abs_secs: i64 = sec_str.parse().ok()?;
123    let abs_nanos: i32 = if nano_str.is_empty() {
124        0
125    } else {
126        // All chars must be digits (i32::parse accepts '+'/'-', which would
127        // let e.g. "5.-3s" produce negative nanos).
128        if nano_str.len() > 9 || !nano_str.bytes().all(|b| b.is_ascii_digit()) {
129            return None;
130        }
131        let n: i32 = nano_str.parse().ok()?;
132        n * 10_i32.pow(9 - nano_str.len() as u32)
133    };
134
135    let (secs, nanos) = if negative {
136        (-abs_secs, -abs_nanos)
137    } else {
138        (abs_secs, abs_nanos)
139    };
140    if !is_valid_duration(secs, nanos) {
141        return None;
142    }
143    Some((secs, nanos))
144}
145
146// ── serde impls ──────────────────────────────────────────────────────────────
147
148// Protobuf spec: Duration is restricted to ±10,000 years ≈ ±315,576,000,000s.
149#[cfg(feature = "json")]
150const MAX_DURATION_SECS: i64 = 315_576_000_000;
151
152#[cfg(feature = "json")]
153fn is_valid_duration(secs: i64, nanos: i32) -> bool {
154    if !(-999_999_999..=999_999_999).contains(&nanos) {
155        return false;
156    }
157    if !(-MAX_DURATION_SECS..=MAX_DURATION_SECS).contains(&secs) {
158        return false;
159    }
160    // Sign consistency: nanos must match seconds sign (or be zero).
161    if (secs > 0 && nanos < 0) || (secs < 0 && nanos > 0) {
162        return false;
163    }
164    true
165}
166
167#[cfg(feature = "json")]
168impl serde::Serialize for Duration {
169    /// Serializes as a decimal seconds string (e.g. `"1.5s"`, `"-0.001s"`).
170    ///
171    /// # Errors
172    ///
173    /// Returns a serialization error if the duration is outside the proto
174    /// spec range of ±315,576,000,000 seconds, or if nanos is invalid.
175    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
176        use alloc::format;
177        if !is_valid_duration(self.seconds, self.nanos) {
178            return Err(serde::ser::Error::custom(format!(
179                "invalid Duration: seconds={}, nanos={} is out of range",
180                self.seconds, self.nanos
181            )));
182        }
183        s.serialize_str(&duration_to_string(self.seconds, self.nanos))
184    }
185}
186
187#[cfg(feature = "json")]
188impl<'de> serde::Deserialize<'de> for Duration {
189    /// Deserializes from a decimal seconds string (e.g. `"1.5s"`).
190    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
191        use alloc::{format, string::String};
192        let s: String = serde::Deserialize::deserialize(d)?;
193        let (seconds, nanos) = parse_duration_string(&s)
194            .ok_or_else(|| serde::de::Error::custom(format!("invalid Duration string: {s}")))?;
195        Ok(Duration {
196            seconds,
197            nanos,
198            ..Default::default()
199        })
200    }
201}
202
203impl Duration {
204    /// Create a [`Duration`] from a whole number of seconds.
205    pub fn from_secs(seconds: i64) -> Self {
206        Duration {
207            seconds,
208            nanos: 0,
209            ..Default::default()
210        }
211    }
212
213    /// Create a [`Duration`] from seconds and nanoseconds.
214    ///
215    /// # Panics
216    ///
217    /// Panics in debug mode if `nanos` is outside `[-999_999_999, 999_999_999]`
218    /// or if its sign is inconsistent with `seconds`.  When `seconds` is zero,
219    /// `nanos` may be positive, negative, or zero.  In release mode the value
220    /// is stored as-is.  Use [`Duration::from_secs_nanos_checked`] for a variant
221    /// that returns `None` on invalid input.
222    pub fn from_secs_nanos(seconds: i64, nanos: i32) -> Self {
223        // Use a range check rather than .abs() to avoid overflow on i32::MIN.
224        debug_assert!(
225            (-999_999_999..=999_999_999).contains(&nanos),
226            "nanos ({nanos}) must be in [-999_999_999, 999_999_999]"
227        );
228        debug_assert!(
229            !((seconds > 0 && nanos < 0) || (seconds < 0 && nanos > 0)),
230            "nanos sign must be consistent with seconds sign"
231        );
232        Duration {
233            seconds,
234            nanos,
235            ..Default::default()
236        }
237    }
238
239    /// Create a [`Duration`] from seconds and nanoseconds, returning `None`
240    /// if `nanos` is out of range or has a sign inconsistent with `seconds`.
241    pub fn from_secs_nanos_checked(seconds: i64, nanos: i32) -> Option<Self> {
242        // Use a range check rather than .abs() to avoid overflow on i32::MIN.
243        if !(-999_999_999..=999_999_999).contains(&nanos) {
244            return None;
245        }
246        if (seconds > 0 && nanos < 0) || (seconds < 0 && nanos > 0) {
247            return None;
248        }
249        Some(Duration {
250            seconds,
251            nanos,
252            ..Default::default()
253        })
254    }
255
256    /// Create a [`Duration`] from a number of milliseconds.
257    ///
258    /// The sign of `millis` determines the sign of both `seconds` and the
259    /// sub-second `nanos` field, per the protobuf sign-consistency rule.
260    pub fn from_millis(millis: i64) -> Self {
261        Duration {
262            seconds: millis / 1_000,
263            // Remainder is in [-999, 999]; after ×1_000_000 → [-999_000_000, 999_000_000],
264            // which fits in i32 (max ≈ ±2.1 billion). Cast is lossless.
265            nanos: ((millis % 1_000) * 1_000_000) as i32,
266            ..Default::default()
267        }
268    }
269
270    /// Create a [`Duration`] from a number of microseconds.
271    pub fn from_micros(micros: i64) -> Self {
272        Duration {
273            seconds: micros / 1_000_000,
274            // Remainder is in [-999_999, 999_999]; after ×1_000 → [-999_999_000, 999_999_000],
275            // which fits in i32. Cast is lossless.
276            nanos: ((micros % 1_000_000) * 1_000) as i32,
277            ..Default::default()
278        }
279    }
280
281    /// Create a [`Duration`] from a number of nanoseconds.
282    pub fn from_nanos(nanos: i64) -> Self {
283        Duration {
284            seconds: nanos / 1_000_000_000,
285            // Remainder is in [-999_999_999, 999_999_999], which fits in i32. Cast is lossless.
286            nanos: (nanos % 1_000_000_000) as i32,
287            ..Default::default()
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[cfg(feature = "std")]
297    #[test]
298    fn std_duration_roundtrip() {
299        let d = std::time::Duration::new(300, 500_000_000);
300        let proto: Duration = d.into();
301        assert_eq!(proto.seconds, 300);
302        assert_eq!(proto.nanos, 500_000_000);
303        let back: std::time::Duration = proto.try_into().unwrap();
304        assert_eq!(back, d);
305    }
306
307    #[cfg(feature = "std")]
308    #[test]
309    fn zero_duration_roundtrip() {
310        let d = std::time::Duration::ZERO;
311        let proto: Duration = d.into();
312        let back: std::time::Duration = proto.try_into().unwrap();
313        assert_eq!(back, d);
314    }
315
316    #[cfg(feature = "std")]
317    #[test]
318    fn negative_duration_rejected() {
319        let neg = Duration {
320            seconds: -5,
321            nanos: 0,
322            ..Default::default()
323        };
324        let result: Result<std::time::Duration, _> = neg.try_into();
325        assert_eq!(result, Err(DurationError::NegativeDuration));
326    }
327
328    #[cfg(feature = "std")]
329    #[test]
330    fn invalid_nanos_rejected() {
331        let bad = Duration {
332            seconds: 1,
333            nanos: 1_000_000_000,
334            ..Default::default()
335        };
336        let result: Result<std::time::Duration, _> = bad.try_into();
337        assert_eq!(result, Err(DurationError::InvalidNanos));
338    }
339
340    // ---- from_millis / from_micros / from_nanos ---------------------------
341
342    #[test]
343    fn from_millis_positive() {
344        let d = Duration::from_millis(1_500);
345        assert_eq!(d.seconds, 1);
346        assert_eq!(d.nanos, 500_000_000);
347    }
348
349    #[test]
350    fn from_millis_negative() {
351        let d = Duration::from_millis(-1_500);
352        assert_eq!(d.seconds, -1);
353        assert_eq!(d.nanos, -500_000_000);
354    }
355
356    #[test]
357    fn from_millis_exact_seconds() {
358        let d = Duration::from_millis(2_000);
359        assert_eq!(d.seconds, 2);
360        assert_eq!(d.nanos, 0);
361    }
362
363    #[test]
364    fn from_micros_positive() {
365        let d = Duration::from_micros(1_500_000);
366        assert_eq!(d.seconds, 1);
367        assert_eq!(d.nanos, 500_000_000);
368    }
369
370    #[test]
371    fn from_micros_negative() {
372        let d = Duration::from_micros(-750);
373        assert_eq!(d.seconds, 0);
374        assert_eq!(d.nanos, -750_000);
375    }
376
377    #[test]
378    fn from_nanos_positive() {
379        let d = Duration::from_nanos(1_500_000_000);
380        assert_eq!(d.seconds, 1);
381        assert_eq!(d.nanos, 500_000_000);
382    }
383
384    #[test]
385    fn from_nanos_negative() {
386        let d = Duration::from_nanos(-2_000_000_000);
387        assert_eq!(d.seconds, -2);
388        assert_eq!(d.nanos, 0);
389    }
390
391    #[test]
392    fn from_nanos_sub_second() {
393        let d = Duration::from_nanos(999_999_999);
394        assert_eq!(d.seconds, 0);
395        assert_eq!(d.nanos, 999_999_999);
396    }
397
398    #[test]
399    fn from_millis_i64_min() {
400        // i64::MIN = -9_223_372_036_854_775_808
401        // remainder = i64::MIN % 1_000 = -808 (Rust truncation remainder)
402        // nanos cast: -808 * 1_000_000 = -808_000_000, fits in i32
403        let d = Duration::from_millis(i64::MIN);
404        assert_eq!(d.nanos, -808_000_000_i32);
405    }
406
407    #[test]
408    fn from_millis_i64_max() {
409        // i64::MAX = 9_223_372_036_854_775_807; remainder = 807
410        let d = Duration::from_millis(i64::MAX);
411        assert_eq!(d.nanos, 807_000_000_i32);
412    }
413
414    #[test]
415    fn from_micros_i64_min() {
416        // remainder = i64::MIN % 1_000_000 = -775_808
417        // nanos cast: -775_808 * 1_000 = -775_808_000, fits in i32
418        let d = Duration::from_micros(i64::MIN);
419        assert_eq!(d.nanos, -775_808_000_i32);
420    }
421
422    #[test]
423    fn from_nanos_i64_min() {
424        // remainder = i64::MIN % 1_000_000_000 = -854_775_808, fits in i32
425        let d = Duration::from_nanos(i64::MIN);
426        assert_eq!(d.nanos, -854_775_808_i32);
427    }
428
429    #[test]
430    fn from_nanos_i64_max() {
431        // remainder = i64::MAX % 1_000_000_000 = 854_775_807, fits in i32
432        let d = Duration::from_nanos(i64::MAX);
433        assert_eq!(d.nanos, 854_775_807_i32);
434    }
435
436    // ---- TryFrom edge cases -----------------------------------------------
437
438    #[cfg(feature = "std")]
439    #[test]
440    fn nanos_i32_min_is_invalid() {
441        // i32::MIN cannot be represented as a valid protobuf nanos value
442        // (valid range is [-999_999_999, 999_999_999]).  Using .abs() on
443        // i32::MIN overflows; we use a range check to avoid that.
444        let bad = Duration {
445            seconds: 0,
446            nanos: i32::MIN,
447            ..Default::default()
448        };
449        let result: Result<std::time::Duration, _> = bad.try_into();
450        assert_eq!(result, Err(DurationError::InvalidNanos));
451    }
452
453    #[cfg(feature = "std")]
454    #[test]
455    fn negative_seconds_and_negative_nanos_is_negative_duration() {
456        // A well-formed negative duration (sign-consistent) must return
457        // NegativeDuration, not InvalidNanos.
458        let neg = Duration {
459            seconds: -5,
460            nanos: -500_000_000,
461            ..Default::default()
462        };
463        let result: Result<std::time::Duration, _> = neg.try_into();
464        assert_eq!(result, Err(DurationError::NegativeDuration));
465    }
466
467    // ---- from_secs --------------------------------------------------------
468
469    #[test]
470    fn from_secs_zero() {
471        let d = Duration::from_secs(0);
472        assert_eq!(d.seconds, 0);
473        assert_eq!(d.nanos, 0);
474    }
475
476    #[test]
477    fn from_secs_positive() {
478        let d = Duration::from_secs(300);
479        assert_eq!(d.seconds, 300);
480        assert_eq!(d.nanos, 0);
481    }
482
483    #[test]
484    fn from_secs_negative() {
485        let d = Duration::from_secs(-7);
486        assert_eq!(d.seconds, -7);
487        assert_eq!(d.nanos, 0);
488    }
489
490    // ---- from_secs_nanos_checked ------------------------------------------
491
492    #[test]
493    fn from_secs_nanos_checked_valid_positive() {
494        let d = Duration::from_secs_nanos_checked(1, 999_999_999).unwrap();
495        assert_eq!(d.seconds, 1);
496        assert_eq!(d.nanos, 999_999_999);
497    }
498
499    #[test]
500    fn from_secs_nanos_checked_valid_negative() {
501        let d = Duration::from_secs_nanos_checked(-1, -999_999_999).unwrap();
502        assert_eq!(d.seconds, -1);
503        assert_eq!(d.nanos, -999_999_999);
504    }
505
506    #[test]
507    fn from_secs_nanos_checked_nanos_out_of_range() {
508        assert!(Duration::from_secs_nanos_checked(1, 1_000_000_000).is_none());
509    }
510
511    #[test]
512    fn from_secs_nanos_checked_i32_min_nanos_is_none() {
513        // i32::MIN would overflow .abs(); the range check must handle it.
514        assert!(Duration::from_secs_nanos_checked(0, i32::MIN).is_none());
515    }
516
517    #[test]
518    fn from_secs_nanos_checked_sign_mismatch_is_none() {
519        assert!(Duration::from_secs_nanos_checked(-1, 1).is_none());
520        assert!(Duration::from_secs_nanos_checked(1, -1).is_none());
521    }
522
523    #[test]
524    fn from_secs_nanos_checked_zero_seconds_allows_negative_nanos() {
525        // When seconds == 0, the sign rule does not apply; nanos may be negative.
526        let d = Duration::from_secs_nanos_checked(0, -500_000_000).unwrap();
527        assert_eq!(d.seconds, 0);
528        assert_eq!(d.nanos, -500_000_000);
529    }
530
531    // ---- from_secs_nanos (panic path tested via checked variant above) ----
532
533    #[test]
534    fn from_secs_nanos_valid() {
535        let d = Duration::from_secs_nanos(2, 500_000_000);
536        assert_eq!(d.seconds, 2);
537        assert_eq!(d.nanos, 500_000_000);
538    }
539
540    // ---- saturation -------------------------------------------------------
541
542    #[cfg(feature = "std")]
543    #[test]
544    fn large_std_duration_saturates_to_i64_max_seconds() {
545        // std::time::Duration can represent values far beyond i64::MAX seconds
546        // (its seconds are stored as u64).  The From impl must saturate rather
547        // than wrap, which would produce a negative seconds value.
548        let huge = std::time::Duration::from_secs(u64::MAX);
549        let proto: Duration = huge.into();
550        assert_eq!(proto.seconds, i64::MAX);
551        // Subsecond nanos are zero because u64::MAX is a whole number of seconds.
552        assert_eq!(proto.nanos, 0);
553    }
554
555    // ---- serde ----------------------------------------------------------------
556
557    #[cfg(feature = "json")]
558    mod serde_tests {
559        use super::*;
560
561        #[test]
562        fn duration_zero_roundtrip() {
563            let d = Duration::from_secs(0);
564            let json = serde_json::to_string(&d).unwrap();
565            assert_eq!(json, r#""0s""#);
566            let back: Duration = serde_json::from_str(&json).unwrap();
567            assert_eq!(back.seconds, 0);
568            assert_eq!(back.nanos, 0);
569        }
570
571        #[test]
572        fn duration_positive_whole_seconds_roundtrip() {
573            let d = Duration::from_secs(300);
574            let json = serde_json::to_string(&d).unwrap();
575            assert_eq!(json, r#""300s""#);
576            let back: Duration = serde_json::from_str(&json).unwrap();
577            assert_eq!(back.seconds, 300);
578            assert_eq!(back.nanos, 0);
579        }
580
581        #[test]
582        fn duration_millis_precision_roundtrip() {
583            let d = Duration::from_secs_nanos(1, 500_000_000);
584            let json = serde_json::to_string(&d).unwrap();
585            assert_eq!(json, r#""1.500s""#);
586            let back: Duration = serde_json::from_str(&json).unwrap();
587            assert_eq!(back.seconds, 1);
588            assert_eq!(back.nanos, 500_000_000);
589        }
590
591        #[test]
592        fn duration_micros_precision_roundtrip() {
593            let d = Duration::from_secs_nanos(0, 1_000);
594            let json = serde_json::to_string(&d).unwrap();
595            assert_eq!(json, r#""0.000001s""#);
596            let back: Duration = serde_json::from_str(&json).unwrap();
597            assert_eq!(back.nanos, 1_000);
598        }
599
600        #[test]
601        fn duration_nanos_precision_roundtrip() {
602            let d = Duration::from_secs_nanos(0, 1);
603            let json = serde_json::to_string(&d).unwrap();
604            assert_eq!(json, r#""0.000000001s""#);
605            let back: Duration = serde_json::from_str(&json).unwrap();
606            assert_eq!(back.nanos, 1);
607        }
608
609        #[test]
610        fn duration_negative_roundtrip() {
611            let d = Duration::from_secs_nanos(-1, -500_000_000);
612            let json = serde_json::to_string(&d).unwrap();
613            assert_eq!(json, r#""-1.500s""#);
614            let back: Duration = serde_json::from_str(&json).unwrap();
615            assert_eq!(back.seconds, -1);
616            assert_eq!(back.nanos, -500_000_000);
617        }
618
619        #[test]
620        fn duration_invalid_string_is_error() {
621            let result: Result<Duration, _> = serde_json::from_str(r#""1.5""#); // missing 's'
622            assert!(result.is_err());
623        }
624
625        #[test]
626        fn parse_duration_rejects_double_sign() {
627            // Regression: "--5s" used to strip one '-' then parse "-5"
628            // via i64::parse, yielding +5 via double negation. Now rejected.
629            assert_eq!(parse_duration_string("--5s"), None);
630            assert_eq!(parse_duration_string("-+5s"), None);
631            assert_eq!(parse_duration_string("+5s"), None); // '+' never valid
632                                                            // The fractional variant was already caught by sign mismatch,
633                                                            // but verify it still is.
634            assert_eq!(parse_duration_string("--5.5s"), None);
635            // Sanity: valid negative still works.
636            assert_eq!(parse_duration_string("-5s"), Some((-5, 0)));
637        }
638
639        #[test]
640        fn parse_duration_rejects_non_digit_fractional() {
641            // Regression (fuzzer-found): "5.-3s" previously parsed with
642            // nano_str="-3" → i32::parse accepts it → nanos=-300000000.
643            // Same class as the double-sign bug but in the fractional part.
644            assert_eq!(parse_duration_string("5.-3s"), None, "minus in frac");
645            assert_eq!(parse_duration_string("5.+3s"), None, "plus in frac");
646            assert_eq!(parse_duration_string("-5.-3s"), None, "double neg frac");
647            assert_eq!(parse_duration_string("5.3as"), None, "alpha in frac");
648            assert_eq!(parse_duration_string("5. s"), None, "space in frac");
649            // Valid fractional still works.
650            assert_eq!(parse_duration_string("5.3s"), Some((5, 300_000_000)));
651            assert_eq!(parse_duration_string("-5.3s"), Some((-5, -300_000_000)));
652        }
653    }
654
655    #[cfg(feature = "std")]
656    #[test]
657    fn negative_nanos_on_positive_seconds_is_invalid_nanos() {
658        // Duration { seconds: 5, nanos: -1 } has a sign mismatch — nanos is negative
659        // while seconds is positive.  This should be InvalidNanos, not NegativeDuration.
660        let bad = Duration {
661            seconds: 5,
662            nanos: -1,
663            ..Default::default()
664        };
665        let result: Result<std::time::Duration, _> = bad.try_into();
666        assert_eq!(result, Err(DurationError::InvalidNanos));
667    }
668}