kube_core/
duration.rs

1//! Kubernetes [`Duration`]s.
2use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
3#[cfg(feature = "schema")] use std::borrow::Cow;
4use std::{cmp::Ordering, fmt, str::FromStr, time};
5
6/// A Kubernetes duration.
7///
8/// This is equivalent to the [`metav1.Duration`] type in the Go Kubernetes
9/// apimachinery package. A [`metav1.Duration`] is serialized in YAML and JSON
10/// as a string formatted in the format accepted by the Go standard library's
11/// [`time.ParseDuration()`] function. This type is a similar wrapper around
12/// Rust's [`std::time::Duration`] that can be serialized and deserialized using
13/// the same format as `metav1.Duration`.
14///
15/// # On Signedness
16///
17/// Go's [`time.Duration`] type is a signed integer type, while Rust's
18/// [`std::time::Duration`] is unsigned. Therefore, this type is also capable of
19/// representing both positive and negative durations. This is implemented by
20/// storing whether or not the parsed duration was negative as a boolean field
21/// in the wrapper type. The [`Duration::is_negative`] method returns this
22/// value, and when a [`Duration`] is serialized, the negative sign is included
23/// if the duration is negative.
24///
25/// [`Duration`]s can be compared with [`std::time::Duration`]s. If the
26/// [`Duration`] is negative, it will always be considered less than the
27/// [`std::time::Duration`]. Similarly, because [`std::time::Duration`]s are
28/// unsigned, a negative [`Duration`] will never be equal to a
29/// [`std::time::Duration`], even if the wrapped [`std::time::Duration`] (the
30/// negative duration's absolute value) is equal.
31///
32/// When converting a [`Duration`] into a [`std::time::Duration`], be aware that
33/// *this information is lost*: if a negative [`Duration`] is converted into a
34/// [`std::time::Duration`] and then that [`std::time::Duration`] is converted
35/// back into a [`Duration`], the second [`Duration`] will *not* be negative.
36///
37/// [`metav1.Duration`]: https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration
38/// [`time.Duration`]: https://pkg.go.dev/time#Duration
39/// [`time.ParseDuration()`]: https://pkg.go.dev/time#ParseDuration
40#[derive(Copy, Clone, PartialEq, Eq)]
41pub struct Duration {
42    duration: time::Duration,
43    is_negative: bool,
44}
45
46/// Errors returned by the [`FromStr`] implementation for [`Duration`].
47
48#[derive(Debug, thiserror::Error, Eq, PartialEq)]
49#[non_exhaustive]
50pub enum ParseError {
51    /// An invalid unit was provided. Units must be one of 'ns', 'us', 'μs',
52    /// 's', 'ms', 's', 'm', or 'h'.
53    #[error("invalid unit: {}", EXPECTED_UNITS)]
54    InvalidUnit,
55
56    /// No unit was provided.
57    #[error("missing a unit: {}", EXPECTED_UNITS)]
58    NoUnit,
59
60    /// The number associated with a given unit was invalid.
61    #[error("invalid floating-point number: {}", .0)]
62    NotANumber(#[from] std::num::ParseFloatError),
63}
64
65const EXPECTED_UNITS: &str = "expected one of 'ns', 'us', '\u{00b5}s', 'ms', 's', 'm', or 'h'";
66
67impl From<time::Duration> for Duration {
68    fn from(duration: time::Duration) -> Self {
69        Self {
70            duration,
71            is_negative: false,
72        }
73    }
74}
75
76impl From<Duration> for time::Duration {
77    fn from(Duration { duration, .. }: Duration) -> Self {
78        duration
79    }
80}
81
82impl Duration {
83    /// Returns `true` if this `Duration` is negative.
84    #[inline]
85    #[must_use]
86    pub fn is_negative(&self) -> bool {
87        self.is_negative
88    }
89}
90
91impl fmt::Debug for Duration {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        use std::fmt::Write;
94        if self.is_negative {
95            f.write_char('-')?;
96        }
97        fmt::Debug::fmt(&self.duration, f)
98    }
99}
100
101impl fmt::Display for Duration {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        use std::fmt::Write;
104        if self.is_negative {
105            f.write_char('-')?;
106        }
107        fmt::Debug::fmt(&self.duration, f)
108    }
109}
110
111impl FromStr for Duration {
112    type Err = ParseError;
113
114    fn from_str(mut s: &str) -> Result<Self, Self::Err> {
115        // implements the same format as
116        // https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/time/format.go;l=1589
117        const MINUTE: time::Duration = time::Duration::from_secs(60);
118
119        // Go durations are signed. Rust durations aren't.
120        let is_negative = s.starts_with('-');
121        s = s.trim_start_matches('+').trim_start_matches('-');
122
123        let mut total = time::Duration::from_secs(0);
124        while !s.is_empty() && s != "0" {
125            let unit_start = s.find(|c: char| c.is_alphabetic()).ok_or(ParseError::NoUnit)?;
126
127            let (val, rest) = s.split_at(unit_start);
128            let val = val.parse::<f64>()?;
129            let unit = if let Some(next_numeric_start) = rest.find(|c: char| !c.is_alphabetic()) {
130                let (unit, rest) = rest.split_at(next_numeric_start);
131                s = rest;
132                unit
133            } else {
134                s = "";
135                rest
136            };
137
138            // https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/time/format.go;l=1573
139            let base = match unit {
140                "ns" => time::Duration::from_nanos(1),
141                // U+00B5 is the "micro sign" while U+03BC is "Greek letter mu"
142                "us" | "\u{00b5}s" | "\u{03bc}s" => time::Duration::from_micros(1),
143                "ms" => time::Duration::from_millis(1),
144                "s" => time::Duration::from_secs(1),
145                "m" => MINUTE,
146                "h" => MINUTE * 60,
147                _ => return Err(ParseError::InvalidUnit),
148            };
149
150            total += base.mul_f64(val);
151        }
152
153        Ok(Duration {
154            duration: total,
155            is_negative,
156        })
157    }
158}
159
160impl Serialize for Duration {
161    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
162    where
163        S: Serializer,
164    {
165        serializer.collect_str(self)
166    }
167}
168
169impl<'de> Deserialize<'de> for Duration {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: Deserializer<'de>,
173    {
174        struct Visitor;
175        impl de::Visitor<'_> for Visitor {
176            type Value = Duration;
177
178            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179                f.write_str("a string in Go `time.Duration.String()` format")
180            }
181
182            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
183            where
184                E: de::Error,
185            {
186                let val = value.parse::<Duration>().map_err(de::Error::custom)?;
187                Ok(val)
188            }
189        }
190        deserializer.deserialize_str(Visitor)
191    }
192}
193
194impl PartialEq<time::Duration> for Duration {
195    fn eq(&self, other: &time::Duration) -> bool {
196        // Since `std::time::Duration` is unsigned, a negative `Duration` is
197        // never equal to a `std::time::Duration`.
198        if self.is_negative {
199            return false;
200        }
201
202        self.duration == *other
203    }
204}
205
206impl PartialEq<time::Duration> for &'_ Duration {
207    fn eq(&self, other: &time::Duration) -> bool {
208        // Since `std::time::Duration` is unsigned, a negative `Duration` is
209        // never equal to a `std::time::Duration`.
210        if self.is_negative {
211            return false;
212        }
213
214        self.duration == *other
215    }
216}
217
218impl PartialEq<Duration> for time::Duration {
219    fn eq(&self, other: &Duration) -> bool {
220        // Since `std::time::Duration` is unsigned, a negative `Duration` is
221        // never equal to a `std::time::Duration`.
222        if other.is_negative {
223            return false;
224        }
225
226        self == &other.duration
227    }
228}
229
230impl PartialEq<Duration> for &'_ time::Duration {
231    fn eq(&self, other: &Duration) -> bool {
232        // Since `std::time::Duration` is unsigned, a negative `Duration` is
233        // never equal to a `std::time::Duration`.
234        if other.is_negative {
235            return false;
236        }
237
238        *self == &other.duration
239    }
240}
241
242impl PartialOrd for Duration {
243    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
244        Some(self.cmp(other))
245    }
246}
247
248impl Ord for Duration {
249    fn cmp(&self, other: &Self) -> Ordering {
250        match (self.is_negative, other.is_negative) {
251            (true, false) => Ordering::Less,
252            (false, true) => Ordering::Greater,
253            // if both durations are negative, the "higher" Duration value is
254            // actually the lower one
255            (true, true) => self.duration.cmp(&other.duration).reverse(),
256            (false, false) => self.duration.cmp(&other.duration),
257        }
258    }
259}
260
261impl PartialOrd<time::Duration> for Duration {
262    fn partial_cmp(&self, other: &time::Duration) -> Option<Ordering> {
263        // Since `std::time::Duration` is unsigned, a negative `Duration` is
264        // always less than the `std::time::Duration`.
265        if self.is_negative {
266            return Some(Ordering::Less);
267        }
268
269        self.duration.partial_cmp(other)
270    }
271}
272
273#[cfg(feature = "schema")]
274impl schemars::JsonSchema for Duration {
275    // see
276    // https://github.com/kubernetes/apimachinery/blob/756e2227bf3a486098f504af1a0ffb736ad16f4c/pkg/apis/meta/v1/duration.go#L61
277    fn schema_name() -> Cow<'static, str> {
278        "Duration".into()
279    }
280
281    fn inline_schema() -> bool {
282        true
283    }
284
285    fn json_schema(_: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
286        use schemars::json_schema;
287
288        // the format should *not* be "duration", because "duration" means
289        // the duration is formatted in ISO 8601, as described here:
290        // https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02#section-7.3.1
291        json_schema!({
292            "type": "string",
293        })
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn parses_the_same_as_go() {
303        const MINUTE: time::Duration = time::Duration::from_secs(60);
304        const HOUR: time::Duration = time::Duration::from_secs(60 * 60);
305        // from Go:
306        // https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/time/time_test.go;l=891-951
307        // ```
308        // var parseDurationTests = []struct {
309        // 	in   string
310        // 	want time::Duration
311        // }{
312        let cases: &[(&str, Duration)] = &[
313            // 	// simple
314            // 	{"0", 0},
315            ("0", time::Duration::from_secs(0).into()),
316            // 	{"5s", 5 * Second},
317            ("5s", time::Duration::from_secs(5).into()),
318            // 	{"30s", 30 * Second},
319            ("30s", time::Duration::from_secs(30).into()),
320            // 	{"1478s", 1478 * Second},
321            ("1478s", time::Duration::from_secs(1478).into()),
322            // 	// sign
323            // 	{"-5s", -5 * Second},
324            ("-5s", Duration {
325                duration: time::Duration::from_secs(5),
326                is_negative: true,
327            }),
328            // 	{"+5s", 5 * Second},
329            ("+5s", time::Duration::from_secs(5).into()),
330            // 	{"-0", 0},
331            ("-0", Duration {
332                duration: time::Duration::from_secs(0),
333                is_negative: true,
334            }),
335            // 	{"+0", 0},
336            ("+0", time::Duration::from_secs(0).into()),
337            // 	// decimal
338            // 	{"5.0s", 5 * Second},
339            ("5s", time::Duration::from_secs(5).into()),
340            // 	{"5.6s", 5*Second + 600*Millisecond},
341            (
342                "5.6s",
343                (time::Duration::from_secs(5) + time::Duration::from_millis(600)).into(),
344            ),
345            // 	{"5.s", 5 * Second},
346            ("5.s", time::Duration::from_secs(5).into()),
347            // 	{".5s", 500 * Millisecond},
348            (".5s", time::Duration::from_millis(500).into()),
349            // 	{"1.0s", 1 * Second},
350            ("1.0s", time::Duration::from_secs(1).into()),
351            // 	{"1.00s", 1 * Second},
352            ("1.00s", time::Duration::from_secs(1).into()),
353            // 	{"1.004s", 1*Second + 4*Millisecond},
354            (
355                "1.004s",
356                (time::Duration::from_secs(1) + time::Duration::from_millis(4)).into(),
357            ),
358            // 	{"1.0040s", 1*Second + 4*Millisecond},
359            (
360                "1.0040s",
361                (time::Duration::from_secs(1) + time::Duration::from_millis(4)).into(),
362            ),
363            // 	{"100.00100s", 100*Second + 1*Millisecond},
364            (
365                "100.00100s",
366                (time::Duration::from_secs(100) + time::Duration::from_millis(1)).into(),
367            ),
368            // 	// different units
369            // 	{"10ns", 10 * Nanosecond},
370            ("10ns", time::Duration::from_nanos(10).into()),
371            // 	{"11us", 11 * Microsecond},
372            ("11us", time::Duration::from_micros(11).into()),
373            // 	{"12µs", 12 * Microsecond}, // U+00B5
374            ("12µs", time::Duration::from_micros(12).into()),
375            // 	{"12μs", 12 * Microsecond}, // U+03BC
376            ("12μs", time::Duration::from_micros(12).into()),
377            // 	{"13ms", 13 * Millisecond},
378            ("13ms", time::Duration::from_millis(13).into()),
379            // 	{"14s", 14 * Second},
380            ("14s", time::Duration::from_secs(14).into()),
381            // 	{"15m", 15 * Minute},
382            ("15m", (15 * MINUTE).into()),
383            // 	{"16h", 16 * Hour},
384            ("16h", (16 * HOUR).into()),
385            // 	// composite durations
386            // 	{"3h30m", 3*Hour + 30*Minute},
387            ("3h30m", (3 * HOUR + 30 * MINUTE).into()),
388            // 	{"10.5s4m", 4*Minute + 10*Second + 500*Millisecond},
389            (
390                "10.5s4m",
391                (4 * MINUTE + time::Duration::from_secs(10) + time::Duration::from_millis(500)).into(),
392            ),
393            // 	{"-2m3.4s", -(2*Minute + 3*Second + 400*Millisecond)},
394            ("-2m3.4s", Duration {
395                duration: 2 * MINUTE + time::Duration::from_secs(3) + time::Duration::from_millis(400),
396                is_negative: true,
397            }),
398            // 	{"1h2m3s4ms5us6ns", 1*Hour + 2*Minute + 3*Second + 4*Millisecond + 5*Microsecond + 6*Nanosecond},
399            (
400                "1h2m3s4ms5us6ns",
401                (1 * HOUR
402                    + 2 * MINUTE
403                    + time::Duration::from_secs(3)
404                    + time::Duration::from_millis(4)
405                    + time::Duration::from_micros(5)
406                    + time::Duration::from_nanos(6))
407                .into(),
408            ),
409            // 	{"39h9m14.425s", 39*Hour + 9*Minute + 14*Second + 425*Millisecond},
410            (
411                "39h9m14.425s",
412                (39 * HOUR + 9 * MINUTE + time::Duration::from_secs(14) + time::Duration::from_millis(425))
413                    .into(),
414            ),
415            // 	// large value
416            // 	{"52763797000ns", 52763797000 * Nanosecond},
417            ("52763797000ns", time::Duration::from_nanos(52763797000).into()),
418            // 	// more than 9 digits after decimal point, see https://golang.org/issue/6617
419            // 	{"0.3333333333333333333h", 20 * Minute},
420            ("0.3333333333333333333h", (20 * MINUTE).into()),
421            // 	// 9007199254740993 = 1<<53+1 cannot be stored precisely in a float64
422            // 	{"9007199254740993ns", (1<<53 + 1) * Nanosecond},
423            (
424                "9007199254740993ns",
425                time::Duration::from_nanos((1 << 53) + 1).into(),
426            ),
427            // Rust Durations can handle larger durations than Go's
428            // representation, so skip these tests for their precision limits
429
430            // 	// largest duration that can be represented by int64 in nanoseconds
431            // 	{"9223372036854775807ns", (1<<63 - 1) * Nanosecond},
432            // ("9223372036854775807ns", time::Duration::from_nanos((1 << 63) - 1).into()),
433            // 	{"9223372036854775.807us", (1<<63 - 1) * Nanosecond},
434            // ("9223372036854775.807us", time::Duration::from_nanos((1 << 63) - 1).into()),
435            // 	{"9223372036s854ms775us807ns", (1<<63 - 1) * Nanosecond},
436            // 	{"-9223372036854775808ns", -1 << 63 * Nanosecond},
437            // 	{"-9223372036854775.808us", -1 << 63 * Nanosecond},
438            // 	{"-9223372036s854ms775us808ns", -1 << 63 * Nanosecond},
439            // 	// largest negative value
440            // 	{"-9223372036854775808ns", -1 << 63 * Nanosecond},
441            // 	// largest negative round trip value, see https://golang.org/issue/48629
442            // 	{"-2562047h47m16.854775808s", -1 << 63 * Nanosecond},
443
444            // 	// huge string; issue 15011.
445            // 	{"0.100000000000000000000h", 6 * Minute},
446            ("0.100000000000000000000h", (6 * MINUTE).into()), // 	// This value tests the first overflow check in leadingFraction.
447                                                               // 	{"0.830103483285477580700h", 49*Minute + 48*Second + 372539827*Nanosecond},
448                                                               // }
449                                                               // ```
450        ];
451
452        for (input, expected) in cases {
453            let parsed = dbg!(input).parse::<Duration>().unwrap();
454            assert_eq!(&dbg!(parsed), expected);
455        }
456    }
457}