Skip to main content

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