gateway_api/
duration.rs

1//! GEP-2257-compliant Duration type for Gateway API
2//!
3//! `gateway_api::Duration` is a duration type where parsing and formatting
4//! obey GEP-2257. It is based on `std::time::Duration` and uses
5//! `kube::core::Duration` for the heavy lifting of parsing.
6//!
7//! GEP-2257 defines a duration format for the Gateway API that is based on
8//! Go's `time.ParseDuration`, with additional restrictions: negative
9//! durations, units smaller than millisecond, and floating point are not
10//! allowed, and durations are limited to four components of no more than five
11//! digits each. See https://gateway-api.sigs.k8s.io/geps/gep-2257 for the
12//! complete specification.
13
14use kube::core::Duration as k8sDuration;
15use once_cell::sync::Lazy;
16use regex::Regex;
17use std::fmt;
18use std::str::FromStr;
19use std::time::Duration as stdDuration;
20
21/// GEP-2257-compliant Duration type for Gateway API
22///
23/// `gateway_api::Duration` is a duration type where parsing and formatting
24/// obey GEP-2257. It is based on `std::time::Duration` and uses
25/// `kube::core::Duration` for the heavy lifting of parsing.
26///
27/// See https://gateway-api.sigs.k8s.io/geps/gep-2257 for the complete
28/// specification.
29///
30/// Per GEP-2257, when parsing a `gateway_api::Duration` from a string, the
31/// string must match
32///
33/// `^([0-9]{1,5}(h|m|s|ms)){1,4}$`
34///
35/// and is otherwise parsed the same way that Go's `time.ParseDuration` parses
36/// durations. When formatting a `gateway_api::Duration` as a string,
37/// zero-valued durations must always be formatted as `0s`, and non-zero
38/// durations must be formatted to with only one instance of each applicable
39/// unit, greatest unit first.
40///
41/// The rules above imply that `gateway_api::Duration` cannot represent
42/// negative durations, durations with sub-millisecond precision, or durations
43/// larger than 99999h59m59s999ms. Since there's no meaningful way in Rust to
44/// allow string formatting to fail, these conditions are checked instead when
45/// instantiating `gateway_api::Duration`.
46#[derive(Copy, Clone, PartialEq, Eq)]
47pub struct Duration(stdDuration);
48
49/// Regex pattern defining valid GEP-2257 Duration strings.
50const GEP2257_PATTERN: &str = r"^([0-9]{1,5}(h|m|s|ms)){1,4}$";
51
52/// Maximum duration that can be represented by GEP-2257, in milliseconds.
53const MAX_DURATION_MS: u128 = (((99999 * 3600) + (59 * 60) + 59) * 1_000) + 999;
54
55/// Checks if a duration is valid according to GEP-2257. If it's not, return
56/// an error result explaining why the duration is not valid.
57///
58/// ```rust
59/// use gateway_api::duration::is_valid;
60/// use std::time::Duration as stdDuration;
61///
62/// // sub-millisecond precision is not allowed
63/// let sub_millisecond_duration = stdDuration::from_nanos(600);
64/// # assert!(is_valid(sub_millisecond_duration).is_err());
65///
66/// // but precision at a millisecond is fine
67/// let non_sub_millisecond_duration = stdDuration::from_millis(1);
68/// # assert!(is_valid(non_sub_millisecond_duration).is_ok());
69/// ```
70pub fn is_valid(duration: stdDuration) -> Result<(), String> {
71    // Check nanoseconds to see if we have sub-millisecond precision in
72    // this duration.
73    if duration.subsec_nanos() % 1_000_000 != 0 {
74        return Err("Cannot express sub-millisecond precision in GEP-2257".to_string());
75    }
76
77    // Check the duration to see if it's greater than GEP-2257's maximum.
78    if duration.as_millis() > MAX_DURATION_MS {
79        return Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string());
80    }
81
82    Ok(())
83}
84
85/// Converting from `std::time::Duration` to `gateway_api::Duration` is
86/// allowed, but we need to make sure that the incoming duration is valid
87/// according to GEP-2257.
88///
89/// ```rust
90/// use gateway_api::Duration;
91/// use std::convert::TryFrom;
92/// use std::time::Duration as stdDuration;
93///
94/// // A one-hour duration is valid according to GEP-2257.
95/// let std_duration = stdDuration::from_secs(3600);
96/// let duration = Duration::try_from(std_duration);
97/// # assert!(duration.as_ref().is_ok());
98/// # assert_eq!(format!("{}", duration.as_ref().unwrap()), "1h");
99///
100/// // This should output "Duration: 1h".
101/// match duration {
102///    Ok(d) => println!("Duration: {}", d),
103///   Err(e) => eprintln!("Error: {}", e),
104/// }
105///
106/// // A 600-nanosecond duration is not valid according to GEP-2257.
107/// let std_duration = stdDuration::from_nanos(600);
108/// let duration = Duration::try_from(std_duration);
109/// # assert!(duration.is_err());
110///
111/// // This should output "Error: Cannot express sub-millisecond
112/// // precision in GEP-2257".
113/// match duration {
114///    Ok(d) => println!("Duration: {}", d),
115///   Err(e) => eprintln!("Error: {}", e),
116/// }
117/// ```
118impl TryFrom<stdDuration> for Duration {
119    type Error = String;
120
121    fn try_from(duration: stdDuration) -> Result<Self, Self::Error> {
122        // Check validity, and propagate any error if it's not.
123        is_valid(duration)?;
124
125        // It's valid, so we can safely convert it to a gateway_api::Duration.
126        Ok(Duration(duration))
127    }
128}
129
130/// Converting from `k8s::time::Duration` to `gateway_api::Duration` is
131/// allowed, but we need to make sure that the incoming duration is valid
132/// according to GEP-2257.
133///
134/// ```rust
135/// use gateway_api::Duration;
136/// use std::convert::TryFrom;
137/// use std::str::FromStr;
138/// use kube::core::Duration as k8sDuration;
139///
140/// // A one-hour duration is valid according to GEP-2257.
141/// let k8s_duration = k8sDuration::from_str("1h").unwrap();
142/// let duration = Duration::try_from(k8s_duration);
143/// # assert!(duration.as_ref().is_ok());
144/// # assert_eq!(format!("{}", duration.as_ref().unwrap()), "1h");
145///
146/// // This should output "Duration: 1h".
147/// match duration {
148///    Ok(d) => println!("Duration: {}", d),
149///   Err(e) => eprintln!("Error: {}", e),
150/// }
151///
152/// // A 600-nanosecond duration is not valid according to GEP-2257.
153/// let k8s_duration = k8sDuration::from_str("600ns").unwrap();
154/// let duration = Duration::try_from(k8s_duration);
155/// # assert!(duration.as_ref().is_err());
156///
157/// // This should output "Error: Cannot express sub-millisecond
158/// // precision in GEP-2257".
159/// match duration {
160///    Ok(d) => println!("Duration: {}", d),
161///   Err(e) => eprintln!("Error: {}", e),
162/// }
163///
164/// // kube::core::Duration can also express negative durations, which are not
165/// // valid according to GEP-2257.
166/// let k8s_duration = k8sDuration::from_str("-5s").unwrap();
167/// let duration = Duration::try_from(k8s_duration);
168/// # assert!(duration.as_ref().is_err());
169///
170/// // This should output "Error: Cannot express sub-millisecond
171/// // precision in GEP-2257".
172/// match duration {
173///    Ok(d) => println!("Duration: {}", d),
174///   Err(e) => eprintln!("Error: {}", e),
175/// }
176/// ```
177
178impl TryFrom<k8sDuration> for Duration {
179    type Error = String;
180
181    fn try_from(duration: k8sDuration) -> Result<Self, Self::Error> {
182        // We can't rely on kube::core::Duration to check validity for
183        // gateway_api::Duration, so first we need to make sure that our
184        // k8sDuration is not negative...
185        if duration.is_negative() {
186            return Err("Duration cannot be negative".to_string());
187        }
188
189        // Once we know it's not negative, we can safely convert it to a
190        // std::time::Duration (which will always succeed) and then check it
191        // for validity as in TryFrom<stdDuration>.
192        let stddur = stdDuration::from(duration);
193        is_valid(stddur)?;
194        Ok(Duration(stddur))
195    }
196}
197
198impl Duration {
199    /// Create a new `gateway_api::Duration` from seconds and nanoseconds,
200    /// while requiring that the resulting duration is valid according to
201    /// GEP-2257.
202    ///
203    /// ```rust
204    /// use gateway_api::Duration;
205    ///
206    /// let duration = Duration::new(7200, 600_000_000);
207    /// # assert!(duration.as_ref().is_ok());
208    /// # assert_eq!(format!("{}", duration.unwrap()), "2h600ms");
209    /// ```
210    pub fn new(secs: u64, nanos: u32) -> Result<Self, String> {
211        let stddur = stdDuration::new(secs, nanos);
212
213        // Propagate errors if not valid, or unwrap the new Duration if all's
214        // well.
215        is_valid(stddur)?;
216        Ok(Self(stddur))
217    }
218
219    /// Create a new `gateway_api::Duration` from seconds, while requiring
220    /// that the resulting duration is valid according to GEP-2257.
221    ///
222    /// ```rust
223    /// use gateway_api::Duration;
224    /// let duration = Duration::from_secs(3600);
225    /// # assert!(duration.as_ref().is_ok());
226    /// # assert_eq!(format!("{}", duration.unwrap()), "1h");
227    /// ```
228    pub fn from_secs(secs: u64) -> Result<Self, String> {
229        Self::new(secs, 0)
230    }
231
232    /// Create a new `gateway_api::Duration` from microseconds, while
233    /// requiring that the resulting duration is valid according to GEP-2257.
234    ///
235    /// ```rust
236    /// use gateway_api::Duration;
237    /// let duration = Duration::from_micros(1_000_000);
238    /// # assert!(duration.as_ref().is_ok());
239    /// # assert_eq!(format!("{}", duration.unwrap()), "1s");
240    /// ```
241    pub fn from_micros(micros: u64) -> Result<Self, String> {
242        let sec = micros / 1_000_000;
243        let ns = ((micros % 1_000_000) * 1_000) as u32;
244
245        Self::new(sec, ns)
246    }
247
248    /// Create a new `gateway_api::Duration` from milliseconds, while
249    /// requiring that the resulting duration is valid according to GEP-2257.
250    ///
251    /// ```rust
252    /// use gateway_api::Duration;
253    /// let duration = Duration::from_millis(1000);
254    /// # assert!(duration.as_ref().is_ok());
255    /// # assert_eq!(format!("{}", duration.unwrap()), "1s");
256    /// ```
257    pub fn from_millis(millis: u64) -> Result<Self, String> {
258        let sec = millis / 1_000;
259        let ns = ((millis % 1_000) * 1_000_000) as u32;
260
261        Self::new(sec, ns)
262    }
263
264    /// The number of whole seconds in the entire duration.
265    ///
266    /// ```rust
267    /// use gateway_api::Duration;
268    ///
269    /// let duration = Duration::from_secs(3600);     // 1h
270    /// # assert!(duration.as_ref().is_ok());
271    /// let seconds = duration.unwrap().as_secs();    // 3600
272    /// # assert_eq!(seconds, 3600);
273    ///
274    /// let duration = Duration::from_millis(1500);   // 1s500ms
275    /// # assert!(duration.as_ref().is_ok());
276    /// let seconds = duration.unwrap().as_secs();    // 1
277    /// # assert_eq!(seconds, 1);
278    /// ```
279    pub fn as_secs(&self) -> u64 {
280        self.0.as_secs()
281    }
282
283    /// The number of milliseconds in the whole duration. GEP-2257 doesn't
284    /// support sub-millisecond precision, so this is always exact.
285    ///
286    /// ```rust
287    /// use gateway_api::Duration;
288    ///
289    /// let duration = Duration::from_millis(1500);   // 1s500ms
290    /// # assert!(duration.as_ref().is_ok());
291    /// let millis = duration.unwrap().as_millis();   // 1500
292    /// # assert_eq!(millis, 1500);
293    /// ```
294    pub fn as_millis(&self) -> u128 {
295        self.0.as_millis()
296    }
297
298    /// The number of nanoseconds in the whole duration. This is always exact.
299    ///
300    /// ```rust
301    /// use gateway_api::Duration;
302    ///
303    /// let duration = Duration::from_millis(1500);   // 1s500ms
304    /// # assert!(duration.as_ref().is_ok());
305    /// let nanos = duration.unwrap().as_nanos();     // 1_500_000_000
306    /// # assert_eq!(nanos, 1_500_000_000);
307    /// ```
308    pub fn as_nanos(&self) -> u128 {
309        self.0.as_nanos()
310    }
311
312    /// The number of nanoseconds in the part of the duration that's not whole
313    /// seconds. Since GEP-2257 doesn't support sub-millisecond precision, this
314    /// will always be 0 or a multiple of 1,000,000.
315    ///
316    /// ```rust
317    /// use gateway_api::Duration;
318    ///
319    /// let duration = Duration::from_millis(1500);          // 1s500ms
320    /// # assert!(duration.as_ref().is_ok());
321    /// let subsec_nanos = duration.unwrap().subsec_nanos(); // 500_000_000
322    /// # assert_eq!(subsec_nanos, 500_000_000);
323    /// ```
324    pub fn subsec_nanos(&self) -> u32 {
325        self.0.subsec_nanos()
326    }
327
328    /// Checks whether the duration is zero.
329    ///
330    /// ```rust
331    /// use gateway_api::Duration;
332    ///
333    /// let duration = Duration::from_secs(0);
334    /// # assert!(duration.as_ref().is_ok());
335    /// assert!(duration.unwrap().is_zero());
336    ///
337    /// let duration = Duration::from_secs(1);
338    /// # assert!(duration.as_ref().is_ok());
339    /// assert!(!duration.unwrap().is_zero());
340    /// ```
341    pub fn is_zero(&self) -> bool {
342        self.0.is_zero()
343    }
344}
345
346/// Parsing a `gateway_api::Duration` from a string requires that the input
347/// string obey GEP-2257:
348///
349/// - input strings must match `^([0-9]{1,5}(h|m|s|ms)){1,4}$`
350/// - durations are parsed the same way that Go's `time.ParseDuration` does
351///
352/// If the input string is not valid according to GEP-2257, an error is
353/// returned explaining what went wrong.
354///
355/// ```rust
356/// use gateway_api::Duration;
357/// use std::str::FromStr;
358///
359/// let duration = Duration::from_str("1h");
360/// # assert!(duration.as_ref().is_ok());
361/// # assert_eq!(format!("{}", duration.as_ref().unwrap()), "1h");
362///
363/// // This should output "Parsed duration: 1h".
364/// match duration {
365///    Ok(d) => println!("Parsed duration: {}", d),
366///   Err(e) => eprintln!("Error: {}", e),
367/// }
368///
369/// let duration = Duration::from_str("1h30m500ns");
370/// # assert!(duration.as_ref().is_err());
371///
372/// // This should output "Error: Cannot express sub-millisecond
373/// // precision in GEP-2257".
374/// match duration {
375///    Ok(d) => println!("Parsed duration: {}", d),
376///   Err(e) => eprintln!("Error: {}", e),
377/// }
378/// ```
379impl FromStr for Duration {
380    type Err = String;
381
382    // Parse a GEP-2257-compliant duration string into a
383    // `gateway_api::Duration`.
384    fn from_str(duration_str: &str) -> Result<Self, Self::Err> {
385        // GEP-2257 dictates that string values must match GEP2257_PATTERN and
386        // be parsed the same way that Go's time.ParseDuration parses
387        // durations.
388        //
389        // This Lazy Regex::new should never ever fail, given that the regex
390        // is a compile-time constant. But just in case.....
391        static RE: Lazy<Regex> = Lazy::new(|| {
392            Regex::new(GEP2257_PATTERN).expect(
393                format!(
394                    r#"GEP2257 regex "{}" did not compile (this is a bug!)"#,
395                    GEP2257_PATTERN
396                )
397                .as_str(),
398            )
399        });
400
401        // If the string doesn't match the regex, it's invalid.
402        if !RE.is_match(duration_str) {
403            return Err("Invalid duration format".to_string());
404        }
405
406        // We use kube::core::Duration to do the heavy lifting of parsing.
407        match k8sDuration::from_str(duration_str) {
408            // If the parse fails, return an error immediately...
409            Err(err) => Err(err.to_string()),
410
411            // ...otherwise, we need to try to turn the k8sDuration into a
412            // gateway_api::Duration (which will check validity).
413            Ok(kd) => Duration::try_from(kd),
414        }
415    }
416}
417
418/// Formatting a `gateway_api::Duration` for display is defined only for valid
419/// durations, and must follow the GEP-2257 rules for formatting:
420///
421/// - zero-valued durations must always be formatted as `0s`
422/// - non-zero durations must be formatted with only one instance of each
423///   applicable unit, greatest unit first.
424///
425/// ```rust
426/// use gateway_api::Duration;
427/// use std::fmt::Display;
428///
429/// // Zero-valued durations are always formatted as "0s".
430/// let duration = Duration::from_secs(0);
431/// # assert!(duration.as_ref().is_ok());
432/// assert_eq!(format!("{}", duration.unwrap()), "0s");
433///
434/// // Non-zero durations are formatted with only one instance of each
435/// // applicable unit, greatest unit first.
436/// let duration = Duration::from_secs(3600);
437/// # assert!(duration.as_ref().is_ok());
438/// assert_eq!(format!("{}", duration.unwrap()), "1h");
439///
440/// let duration = Duration::from_millis(1500);
441/// # assert!(duration.as_ref().is_ok());
442/// assert_eq!(format!("{}", duration.unwrap()), "1s500ms");
443///
444/// let duration = Duration::from_millis(9005500);
445/// # assert!(duration.as_ref().is_ok());
446/// assert_eq!(format!("{}", duration.unwrap()), "2h30m5s500ms");
447/// ```
448impl fmt::Display for Duration {
449    /// Format a `gateway_api::Duration` for display, following GEP-2257 rules.
450    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
451        // Short-circuit if the duration is zero, since "0s" is the special
452        // case for a zero-valued duration.
453        if self.is_zero() {
454            return write!(f, "0s");
455        }
456
457        // Unfortunately, we can't rely on kube::core::Duration for
458        // formatting, since it can happily hand back things like "5400s"
459        // instead of "1h30m".
460        //
461        // So we'll do the formatting ourselves. Start by grabbing the
462        // milliseconds part of the Duration (remember, the constructors make
463        // sure that we don't have sub-millisecond precision)...
464        let ms = self.subsec_nanos() / 1_000_000;
465
466        // ...then after that, do the usual div & mod tree to take seconds and
467        // get hours, minutes, and seconds from it.
468        let mut secs = self.as_secs();
469
470        let hours = secs / 3600;
471
472        if hours > 0 {
473            secs -= hours * 3600;
474            write!(f, "{}h", hours)?;
475        }
476
477        let minutes = secs / 60;
478        if minutes > 0 {
479            secs -= minutes * 60;
480            write!(f, "{}m", minutes)?;
481        }
482
483        if secs > 0 {
484            write!(f, "{}s", secs)?;
485        }
486
487        if ms > 0 {
488            write!(f, "{}ms", ms)?;
489        }
490
491        Ok(())
492    }
493}
494
495/// Formatting a `gateway_api::Duration` for debug is the same as formatting
496/// it for display.
497impl fmt::Debug for Duration {
498    /// Format a `gateway_api::Duration` for debug, following GEP-2257 rules.
499    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500        // Yes, we format GEP-2257 Durations the same in debug and display.
501        fmt::Display::fmt(self, f)
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    /// Test that the validation logic in `Duration`'s constructor
511    /// method(s) correctly handles known-good durations. (The tests are
512    /// ordered to match the from_str test cases.)
513    fn test_gep2257_from_valid_duration() {
514        let test_cases = vec![
515            Duration::from_secs(0),                        // 0s / 0h0m0s / 0m0s
516            Duration::from_secs(3600),                     // 1h
517            Duration::from_secs(1800),                     // 30m
518            Duration::from_secs(10),                       // 10s
519            Duration::from_millis(500),                    // 500ms
520            Duration::from_secs(9000),                     // 2h30m / 150m
521            Duration::from_secs(5410),                     // 1h30m10s / 10s30m1h
522            Duration::new(7200, 600_000_000),              // 2h600ms
523            Duration::new(7200 + 1800, 600_000_000),       // 2h30m600ms
524            Duration::new(7200 + 1800 + 10, 600_000_000),  // 2h30m10s600ms
525            Duration::from_millis(MAX_DURATION_MS as u64), // 99999h59m59s999ms
526        ];
527
528        for (idx, duration) in test_cases.iter().enumerate() {
529            assert!(
530                duration.is_ok(),
531                "{:?}: Duration {:?} should be OK",
532                idx,
533                duration
534            );
535        }
536    }
537
538    #[test]
539    /// Test that the validation logic in `Duration`'s constructor
540    /// method(s) correctly handles known-bad durations.
541    fn test_gep2257_from_invalid_duration() {
542        let test_cases = vec![
543            (
544                Duration::from_micros(100),
545                Err("Cannot express sub-millisecond precision in GEP-2257".to_string()),
546            ),
547            (
548                Duration::from_secs(10000 * 86400),
549                Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
550            ),
551            (
552                Duration::from_millis((MAX_DURATION_MS + 1) as u64),
553                Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
554            ),
555        ];
556
557        for (idx, (duration, expected)) in test_cases.into_iter().enumerate() {
558            assert_eq!(
559                duration, expected,
560                "{:?}: Duration {:?} should be an error",
561                idx, duration
562            );
563        }
564    }
565
566    #[test]
567    /// Test that the TryFrom implementation for k8sDuration correctly converts
568    /// to gateway_api::Duration and validates the result.
569    fn test_gep2257_from_valid_k8s_duration() {
570        let test_cases = vec![
571            (
572                k8sDuration::from_str("0s").unwrap(),
573                Duration::from_secs(0).unwrap(),
574            ),
575            (
576                k8sDuration::from_str("1h").unwrap(),
577                Duration::from_secs(3600).unwrap(),
578            ),
579            (
580                k8sDuration::from_str("500ms").unwrap(),
581                Duration::from_millis(500).unwrap(),
582            ),
583            (
584                k8sDuration::from_str("2h600ms").unwrap(),
585                Duration::new(7200, 600_000_000).unwrap(),
586            ),
587        ];
588
589        for (idx, (k8s_duration, expected)) in test_cases.into_iter().enumerate() {
590            let duration = Duration::try_from(k8s_duration);
591
592            assert!(
593                duration.as_ref().is_ok_and(|d| *d == expected),
594                "{:?}: Duration {:?} should be {:?}",
595                idx,
596                duration,
597                expected
598            );
599        }
600    }
601
602    #[test]
603    /// Test that the TryFrom implementation for k8sDuration correctly fails
604    /// for kube::core::Durations that aren't valid GEP-2257 durations.
605    fn test_gep2257_from_invalid_k8s_duration() {
606        let test_cases: Vec<(k8sDuration, Result<Duration, String>)> = vec![
607            (
608                k8sDuration::from_str("100us").unwrap(),
609                Err("Cannot express sub-millisecond precision in GEP-2257".to_string()),
610            ),
611            (
612                k8sDuration::from_str("100000h").unwrap(),
613                Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
614            ),
615            (
616                k8sDuration::from(stdDuration::from_millis((MAX_DURATION_MS + 1) as u64)),
617                Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
618            ),
619            (
620                k8sDuration::from_str("-5s").unwrap(),
621                Err("Duration cannot be negative".to_string()),
622            ),
623        ];
624
625        for (idx, (k8s_duration, expected)) in test_cases.into_iter().enumerate() {
626            assert_eq!(
627                Duration::try_from(k8s_duration),
628                expected,
629                "{:?}: k8sDuration {:?} should be error {:?}",
630                idx,
631                k8s_duration,
632                expected
633            );
634        }
635    }
636
637    #[test]
638    fn test_gep2257_from_str() {
639        // Test vectors are mostly taken directly from GEP-2257, but there are
640        // some extras thrown in and it's not meaningful to test e.g. "0.5m"
641        // in Rust.
642        let test_cases = vec![
643            ("0h", Duration::from_secs(0)),
644            ("0s", Duration::from_secs(0)),
645            ("0h0m0s", Duration::from_secs(0)),
646            ("1h", Duration::from_secs(3600)),
647            ("30m", Duration::from_secs(1800)),
648            ("10s", Duration::from_secs(10)),
649            ("500ms", Duration::from_millis(500)),
650            ("2h30m", Duration::from_secs(9000)),
651            ("150m", Duration::from_secs(9000)),
652            ("7230s", Duration::from_secs(7230)),
653            ("1h30m10s", Duration::from_secs(5410)),
654            ("10s30m1h", Duration::from_secs(5410)),
655            ("100ms200ms300ms", Duration::from_millis(600)),
656            ("100ms200ms300ms", Duration::from_millis(600)),
657            (
658                "99999h59m59s999ms",
659                Duration::from_millis(MAX_DURATION_MS as u64),
660            ),
661            ("1d", Err("Invalid duration format".to_string())),
662            ("1", Err("Invalid duration format".to_string())),
663            ("1m1", Err("Invalid duration format".to_string())),
664            (
665                "1h30m10s20ms50h",
666                Err("Invalid duration format".to_string()),
667            ),
668            ("999999h", Err("Invalid duration format".to_string())),
669            ("1.5h", Err("Invalid duration format".to_string())),
670            ("-15m", Err("Invalid duration format".to_string())),
671            (
672                "99999h59m59s1000ms",
673                Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
674            ),
675        ];
676
677        for (idx, (duration_str, expected)) in test_cases.into_iter().enumerate() {
678            assert_eq!(
679                Duration::from_str(duration_str),
680                expected,
681                "{:?}: Duration {:?} should be {:?}",
682                idx,
683                duration_str,
684                expected
685            );
686        }
687    }
688
689    #[test]
690    fn test_gep2257_format() {
691        // Formatting should always succeed for valid durations, and we've
692        // covered invalid durations in the constructor and parse tests.
693        let test_cases = vec![
694            (Duration::from_secs(0), "0s".to_string()),
695            (Duration::from_secs(3600), "1h".to_string()),
696            (Duration::from_secs(1800), "30m".to_string()),
697            (Duration::from_secs(10), "10s".to_string()),
698            (Duration::from_millis(500), "500ms".to_string()),
699            (Duration::from_secs(9000), "2h30m".to_string()),
700            (Duration::from_secs(5410), "1h30m10s".to_string()),
701            (Duration::from_millis(600), "600ms".to_string()),
702            (Duration::new(7200, 600_000_000), "2h600ms".to_string()),
703            (
704                Duration::new(7200 + 1800, 600_000_000),
705                "2h30m600ms".to_string(),
706            ),
707            (
708                Duration::new(7200 + 1800 + 10, 600_000_000),
709                "2h30m10s600ms".to_string(),
710            ),
711        ];
712
713        for (idx, (duration, expected)) in test_cases.into_iter().enumerate() {
714            assert!(
715                duration
716                    .as_ref()
717                    .is_ok_and(|d| format!("{}", d) == expected),
718                "{:?}: Duration {:?} should be {:?}",
719                idx,
720                duration,
721                expected
722            );
723        }
724    }
725}