kube_core/duration.rs
1//! Kubernetes [`Duration`]s.
2use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
3use std::{cmp::Ordering, fmt, str::FromStr, time};
4
5/// A Kubernetes duration.
6///
7/// This is equivalent to the [`metav1.Duration`] type in the Go Kubernetes
8/// apimachinery package. A [`metav1.Duration`] is serialized in YAML and JSON
9/// as a string formatted in the format accepted by the Go standard library's
10/// [`time.ParseDuration()`] function. This type is a similar wrapper around
11/// Rust's [`std::time::Duration`] that can be serialized and deserialized using
12/// the same format as `metav1.Duration`.
13///
14/// # On Signedness
15///
16/// Go's [`time.Duration`] type is a signed integer type, while Rust's
17/// [`std::time::Duration`] is unsigned. Therefore, this type is also capable of
18/// representing both positive and negative durations. This is implemented by
19/// storing whether or not the parsed duration was negative as a boolean field
20/// in the wrapper type. The [`Duration::is_negative`] method returns this
21/// value, and when a [`Duration`] is serialized, the negative sign is included
22/// if the duration is negative.
23///
24/// [`Duration`]s can be compared with [`std::time::Duration`]s. If the
25/// [`Duration`] is negative, it will always be considered less than the
26/// [`std::time::Duration`]. Similarly, because [`std::time::Duration`]s are
27/// unsigned, a negative [`Duration`] will never be equal to a
28/// [`std::time::Duration`], even if the wrapped [`std::time::Duration`] (the
29/// negative duration's absolute value) is equal.
30///
31/// When converting a [`Duration`] into a [`std::time::Duration`], be aware that
32/// *this information is lost*: if a negative [`Duration`] is converted into a
33/// [`std::time::Duration`] and then that [`std::time::Duration`] is converted
34/// back into a [`Duration`], the second [`Duration`] will *not* be negative.
35///
36/// [`metav1.Duration`]: https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration
37/// [`time.Duration`]: https://pkg.go.dev/time#Duration
38/// [`time.ParseDuration()`]: https://pkg.go.dev/time#ParseDuration
39#[derive(Copy, Clone, PartialEq, Eq)]
40pub struct Duration {
41 duration: time::Duration,
42 is_negative: bool,
43}
44
45/// Errors returned by the [`FromStr`] implementation for [`Duration`].
46
47#[derive(Debug, thiserror::Error, Eq, PartialEq)]
48#[non_exhaustive]
49pub enum ParseError {
50 /// An invalid unit was provided. Units must be one of 'ns', 'us', 'μs',
51 /// 's', 'ms', 's', 'm', or 'h'.
52 #[error("invalid unit: {}", EXPECTED_UNITS)]
53 InvalidUnit,
54
55 /// No unit was provided.
56 #[error("missing a unit: {}", EXPECTED_UNITS)]
57 NoUnit,
58
59 /// The number associated with a given unit was invalid.
60 #[error("invalid floating-point number: {}", .0)]
61 NotANumber(#[from] std::num::ParseFloatError),
62}
63
64const EXPECTED_UNITS: &str = "expected one of 'ns', 'us', '\u{00b5}s', 'ms', 's', 'm', or 'h'";
65
66impl From<time::Duration> for Duration {
67 fn from(duration: time::Duration) -> Self {
68 Self {
69 duration,
70 is_negative: false,
71 }
72 }
73}
74
75impl From<Duration> for time::Duration {
76 fn from(Duration { duration, .. }: Duration) -> Self {
77 duration
78 }
79}
80
81impl Duration {
82 /// Returns `true` if this `Duration` is negative.
83 #[inline]
84 #[must_use]
85 pub fn is_negative(&self) -> bool {
86 self.is_negative
87 }
88}
89
90impl fmt::Debug for Duration {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 use std::fmt::Write;
93 if self.is_negative {
94 f.write_char('-')?;
95 }
96 fmt::Debug::fmt(&self.duration, f)
97 }
98}
99
100impl fmt::Display for Duration {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 use std::fmt::Write;
103 if self.is_negative {
104 f.write_char('-')?;
105 }
106 fmt::Debug::fmt(&self.duration, f)
107 }
108}
109
110impl FromStr for Duration {
111 type Err = ParseError;
112
113 fn from_str(mut s: &str) -> Result<Self, Self::Err> {
114 // implements the same format as
115 // https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/time/format.go;l=1589
116 const MINUTE: time::Duration = time::Duration::from_secs(60);
117
118 // Go durations are signed. Rust durations aren't.
119 let is_negative = s.starts_with('-');
120 s = s.trim_start_matches('+').trim_start_matches('-');
121
122 let mut total = time::Duration::from_secs(0);
123 while !s.is_empty() && s != "0" {
124 let unit_start = s.find(|c: char| c.is_alphabetic()).ok_or(ParseError::NoUnit)?;
125
126 let (val, rest) = s.split_at(unit_start);
127 let val = val.parse::<f64>()?;
128 let unit = if let Some(next_numeric_start) = rest.find(|c: char| !c.is_alphabetic()) {
129 let (unit, rest) = rest.split_at(next_numeric_start);
130 s = rest;
131 unit
132 } else {
133 s = "";
134 rest
135 };
136
137 // https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/time/format.go;l=1573
138 let base = match unit {
139 "ns" => time::Duration::from_nanos(1),
140 // U+00B5 is the "micro sign" while U+03BC is "Greek letter mu"
141 "us" | "\u{00b5}s" | "\u{03bc}s" => time::Duration::from_micros(1),
142 "ms" => time::Duration::from_millis(1),
143 "s" => time::Duration::from_secs(1),
144 "m" => MINUTE,
145 "h" => MINUTE * 60,
146 _ => return Err(ParseError::InvalidUnit),
147 };
148
149 total += base.mul_f64(val);
150 }
151
152 Ok(Duration {
153 duration: total,
154 is_negative,
155 })
156 }
157}
158
159impl Serialize for Duration {
160 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
161 where
162 S: Serializer,
163 {
164 serializer.collect_str(self)
165 }
166}
167
168impl<'de> Deserialize<'de> for Duration {
169 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170 where
171 D: Deserializer<'de>,
172 {
173 struct Visitor;
174 impl de::Visitor<'_> for Visitor {
175 type Value = Duration;
176
177 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 f.write_str("a string in Go `time.Duration.String()` format")
179 }
180
181 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
182 where
183 E: de::Error,
184 {
185 let val = value.parse::<Duration>().map_err(de::Error::custom)?;
186 Ok(val)
187 }
188 }
189 deserializer.deserialize_str(Visitor)
190 }
191}
192
193impl PartialEq<time::Duration> for Duration {
194 fn eq(&self, other: &time::Duration) -> bool {
195 // Since `std::time::Duration` is unsigned, a negative `Duration` is
196 // never equal to a `std::time::Duration`.
197 if self.is_negative {
198 return false;
199 }
200
201 self.duration == *other
202 }
203}
204
205impl PartialEq<time::Duration> for &'_ Duration {
206 fn eq(&self, other: &time::Duration) -> bool {
207 // Since `std::time::Duration` is unsigned, a negative `Duration` is
208 // never equal to a `std::time::Duration`.
209 if self.is_negative {
210 return false;
211 }
212
213 self.duration == *other
214 }
215}
216
217impl PartialEq<Duration> for time::Duration {
218 fn eq(&self, other: &Duration) -> bool {
219 // Since `std::time::Duration` is unsigned, a negative `Duration` is
220 // never equal to a `std::time::Duration`.
221 if other.is_negative {
222 return false;
223 }
224
225 self == &other.duration
226 }
227}
228
229impl PartialEq<Duration> for &'_ time::Duration {
230 fn eq(&self, other: &Duration) -> bool {
231 // Since `std::time::Duration` is unsigned, a negative `Duration` is
232 // never equal to a `std::time::Duration`.
233 if other.is_negative {
234 return false;
235 }
236
237 *self == &other.duration
238 }
239}
240
241impl PartialOrd for Duration {
242 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
243 Some(self.cmp(other))
244 }
245}
246
247impl Ord for Duration {
248 fn cmp(&self, other: &Self) -> Ordering {
249 match (self.is_negative, other.is_negative) {
250 (true, false) => Ordering::Less,
251 (false, true) => Ordering::Greater,
252 // if both durations are negative, the "higher" Duration value is
253 // actually the lower one
254 (true, true) => self.duration.cmp(&other.duration).reverse(),
255 (false, false) => self.duration.cmp(&other.duration),
256 }
257 }
258}
259
260impl PartialOrd<time::Duration> for Duration {
261 fn partial_cmp(&self, other: &time::Duration) -> Option<Ordering> {
262 // Since `std::time::Duration` is unsigned, a negative `Duration` is
263 // always less than the `std::time::Duration`.
264 if self.is_negative {
265 return Some(Ordering::Less);
266 }
267
268 self.duration.partial_cmp(other)
269 }
270}
271
272#[cfg(feature = "schema")]
273impl schemars::JsonSchema for Duration {
274 // see
275 // https://github.com/kubernetes/apimachinery/blob/756e2227bf3a486098f504af1a0ffb736ad16f4c/pkg/apis/meta/v1/duration.go#L61
276 fn schema_name() -> String {
277 "Duration".to_owned()
278 }
279
280 fn is_referenceable() -> bool {
281 false
282 }
283
284 fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
285 schemars::schema::SchemaObject {
286 instance_type: Some(schemars::schema::InstanceType::String.into()),
287 // the format should *not* be "duration", because "duration" means
288 // the duration is formatted in ISO 8601, as described here:
289 // https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02#section-7.3.1
290 format: None,
291 ..Default::default()
292 }
293 .into()
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}