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}