Skip to main content

wasmer_config/app/
pretty_duration.rs

1use std::{
2    borrow::Cow,
3    fmt::{Debug, Display},
4    str::FromStr,
5    time::Duration,
6};
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize, de::Error};
10
11/// A duration parsed from a human-readable value.
12///
13/// Format:
14/// ( [NUMBER][UNIT] [SPACE]? )+
15///
16/// Unit:
17/// * Seconds: s|sec|secs|seconds
18/// * Minutes: m|min|mins|minutes
19/// * Hours:   h|hour|hours
20/// * Days:    d|day|days
21///
22/// Examples: `30s`, `1m30s`, `1m 30s`, `2hours`, `7d`.
23///
24/// Resolution is one second; there are no sub-second units.
25///
26/// The spelling a value was parsed from is preserved, so a config that is read
27/// and written back keeps the author's formatting. Values constructed in code
28/// are formatted with the largest units that divide them evenly.
29#[derive(Clone)]
30pub struct PrettyDuration {
31    /// The text this value was parsed from, or the formatting of a value
32    /// constructed in code. Always re-parses to the same duration.
33    text: String,
34    duration: Duration,
35}
36
37#[derive(Clone, Copy, PartialEq, Eq, Hash)]
38pub enum DurationUnit {
39    Seconds,
40    Minutes,
41    Hours,
42    Days,
43}
44
45impl DurationUnit {
46    /// The number of seconds in one of this unit.
47    const fn secs(self) -> u64 {
48        match self {
49            Self::Seconds => 1,
50            Self::Minutes => 60,
51            Self::Hours => 60 * 60,
52            Self::Days => 24 * 60 * 60,
53        }
54    }
55}
56
57impl PrettyDuration {
58    /// Sub-second precision is truncated, since no unit can express it and the
59    /// spelling and the value would otherwise disagree.
60    pub fn new(duration: Duration) -> Self {
61        let duration = Duration::from_secs(duration.as_secs());
62        Self {
63            text: format_duration(duration),
64            duration,
65        }
66    }
67
68    pub fn as_duration(&self) -> Duration {
69        self.duration
70    }
71
72    /// The spelling this value was parsed from or formatted as.
73    pub fn as_str(&self) -> &str {
74        &self.text
75    }
76
77    pub fn from_secs(secs: u64) -> Self {
78        Self::new(Duration::from_secs(secs))
79    }
80
81    /// Saturates at [`u64::MAX`] seconds, matching [`Self::from_hours`] and
82    /// [`Self::from_days`]. Parsing rejects that magnitude instead, because
83    /// there the amount comes from a config file rather than from a caller.
84    pub fn from_mins(mins: u64) -> Self {
85        Self::new(Duration::from_secs(mins.saturating_mul(60)))
86    }
87
88    /// Saturates at [`u64::MAX`] seconds. See [`Self::from_mins`].
89    pub fn from_hours(hours: u64) -> Self {
90        Self::new(Duration::from_secs(hours.saturating_mul(60 * 60)))
91    }
92
93    /// Saturates at [`u64::MAX`] seconds. See [`Self::from_mins`].
94    pub fn from_days(days: u64) -> Self {
95        Self::new(Duration::from_secs(days.saturating_mul(24 * 60 * 60)))
96    }
97}
98
99impl From<Duration> for PrettyDuration {
100    fn from(duration: Duration) -> Self {
101        Self::new(duration)
102    }
103}
104
105impl From<PrettyDuration> for Duration {
106    fn from(duration: PrettyDuration) -> Self {
107        duration.duration
108    }
109}
110
111impl Default for PrettyDuration {
112    fn default() -> Self {
113        Self::new(Duration::ZERO)
114    }
115}
116
117// Compare by the duration rather than the spelling, so `60s` and `1m` are equal
118// and `Eq`/`Ord`/`Hash` stay consistent with each other.
119impl PartialEq for PrettyDuration {
120    fn eq(&self, other: &Self) -> bool {
121        self.duration == other.duration
122    }
123}
124
125impl Eq for PrettyDuration {}
126
127impl std::hash::Hash for PrettyDuration {
128    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
129        self.duration.hash(state);
130    }
131}
132
133impl PartialOrd for PrettyDuration {
134    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
135        Some(self.cmp(other))
136    }
137}
138
139impl Ord for PrettyDuration {
140    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
141        self.duration.cmp(&other.duration)
142    }
143}
144
145impl JsonSchema for PrettyDuration {
146    fn schema_name() -> Cow<'static, str> {
147        Cow::Borrowed("PrettyDuration")
148    }
149
150    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
151        String::json_schema(generator)
152    }
153
154    fn inline_schema() -> bool {
155        false
156    }
157
158    fn schema_id() -> Cow<'static, str> {
159        Self::schema_name()
160    }
161}
162
163impl Display for DurationUnit {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        let unit = match self {
166            DurationUnit::Seconds => "s",
167            DurationUnit::Minutes => "m",
168            DurationUnit::Hours => "h",
169            DurationUnit::Days => "d",
170        };
171        f.write_str(unit)
172    }
173}
174
175impl FromStr for DurationUnit {
176    type Err = ();
177
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        match s.to_lowercase().as_str() {
180            "s" | "sec" | "secs" | "seconds" => Ok(Self::Seconds),
181            "m" | "min" | "mins" | "minutes" => Ok(Self::Minutes),
182            "h" | "hour" | "hours" => Ok(Self::Hours),
183            "d" | "day" | "days" => Ok(Self::Days),
184            _ => Err(()),
185        }
186    }
187}
188
189/// Render a duration as the largest units that divide it evenly, e.g.
190/// `90s` as `1m30s`. Zero has no components, so it is spelled out explicitly.
191fn format_duration(duration: Duration) -> String {
192    if duration.is_zero() {
193        return "0s".to_string();
194    }
195
196    let mut secs = duration.as_secs();
197    let mut text = String::new();
198    for unit in [
199        DurationUnit::Days,
200        DurationUnit::Hours,
201        DurationUnit::Minutes,
202        DurationUnit::Seconds,
203    ] {
204        let scale = unit.secs();
205        let amount = secs / scale;
206        if amount > 0 {
207            text.push_str(&format!("{amount}{unit}"));
208            secs %= scale;
209        }
210    }
211    text
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct PrettyDurationParseError {
216    value: String,
217    message: String,
218}
219
220impl Display for PrettyDurationParseError {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        write!(
223            f,
224            "invalid duration '{}': {}. Expected a value such as '30s', '1m30s' or '2h'",
225            self.value, self.message
226        )
227    }
228}
229
230impl std::error::Error for PrettyDurationParseError {}
231
232impl Display for PrettyDuration {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.write_str(&self.text)
235    }
236}
237
238impl Debug for PrettyDuration {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        <Self as Display>::fmt(self, f)
241    }
242}
243
244impl FromStr for PrettyDuration {
245    type Err = PrettyDurationParseError;
246
247    fn from_str(s: &str) -> Result<Self, Self::Err> {
248        let fail = |message: &str| PrettyDurationParseError {
249            value: s.to_string(),
250            message: message.to_string(),
251        };
252
253        let mut rest = s;
254        let mut secs: u64 = 0;
255        let mut components = 0;
256        loop {
257            // A single space may separate components, as in `1m 30s`.
258            rest = rest.strip_prefix(' ').unwrap_or(rest);
259            if rest.is_empty() {
260                break;
261            }
262
263            let digits = rest.chars().take_while(char::is_ascii_digit).count();
264            if digits == 0 {
265                return Err(fail("every component must start with a number"));
266            }
267            let amount = rest[..digits]
268                .parse::<u64>()
269                .map_err(|_| fail("number is out of range"))?;
270            rest = &rest[digits..];
271
272            let letters = rest.chars().take_while(char::is_ascii_alphabetic).count();
273            let unit = rest[..letters]
274                .parse::<DurationUnit>()
275                .map_err(|()| fail(&format!("unknown unit '{}'", &rest[..letters])))?;
276            rest = &rest[letters..];
277
278            secs = amount
279                .checked_mul(unit.secs())
280                .and_then(|component| secs.checked_add(component))
281                .ok_or_else(|| fail("duration is too large"))?;
282            components += 1;
283        }
284
285        if components == 0 {
286            return Err(fail("must not be empty"));
287        }
288
289        Ok(Self {
290            text: s.to_string(),
291            duration: Duration::from_secs(secs),
292        })
293    }
294}
295
296impl Serialize for PrettyDuration {
297    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
298    where
299        S: serde::Serializer,
300    {
301        serializer.serialize_str(&self.text)
302    }
303}
304
305impl<'de> Deserialize<'de> for PrettyDuration {
306    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
307    where
308        D: serde::Deserializer<'de>,
309    {
310        let repr: Cow<'de, str> = Cow::deserialize(deserializer)?;
311        repr.parse().map_err(D::Error::custom)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    pub fn pretty_duration_serialize() {
321        assert_eq!(PrettyDuration::from_secs(1234).to_string(), "20m34s");
322        assert_eq!(PrettyDuration::from_mins(345).to_string(), "5h45m");
323        assert_eq!(PrettyDuration::from_hours(56).to_string(), "2d8h");
324        assert_eq!(PrettyDuration::from_days(7).to_string(), "7d");
325        assert_eq!(PrettyDuration::default().to_string(), "0s");
326    }
327
328    #[test]
329    pub fn pretty_duration_deserialize() {
330        fn assert_deserializes_to(repr1: &str, repr2: &str, duration: Duration) {
331            assert_eq!(
332                repr1.parse::<PrettyDuration>().unwrap().as_duration(),
333                duration
334            );
335            assert_eq!(
336                repr2.parse::<PrettyDuration>().unwrap().as_duration(),
337                duration
338            );
339        }
340
341        assert_deserializes_to("12s", "12S", Duration::from_secs(12));
342        assert_deserializes_to("34m", "34M", Duration::from_secs(34 * 60));
343        assert_deserializes_to("56h", "56H", Duration::from_secs(56 * 60 * 60));
344        assert_deserializes_to("7d", "7D", Duration::from_secs(7 * 24 * 60 * 60));
345    }
346
347    #[test]
348    fn compound_and_long_form_units_are_accepted() {
349        let cases = [
350            ("30s", Duration::from_secs(30)),
351            ("90seconds", Duration::from_secs(90)),
352            ("5mins", Duration::from_secs(5 * 60)),
353            ("2hours", Duration::from_secs(2 * 60 * 60)),
354            ("1m30s", Duration::from_secs(90)),
355            ("1m 30s", Duration::from_secs(90)),
356            ("1h1m1s", Duration::from_secs(61 * 60 + 1)),
357            ("1d 2h 30s", Duration::from_secs(26 * 60 * 60 + 30)),
358        ];
359        for (input, expected) in cases {
360            let parsed = input.parse::<PrettyDuration>().unwrap();
361            assert_eq!(parsed.as_duration(), expected, "parsing {input}");
362        }
363    }
364
365    #[test]
366    fn parsing_preserves_the_original_spelling() {
367        // Config files are read and written back, so the author's formatting
368        // must survive a round trip rather than being normalized.
369        for input in ["120s", "0s", "1m 30s", "2hours", "12S"] {
370            let parsed = input.parse::<PrettyDuration>().unwrap();
371            assert_eq!(parsed.to_string(), input);
372            assert_eq!(
373                serde_json::to_value(&parsed).unwrap(),
374                serde_json::json!(input)
375            );
376        }
377    }
378
379    #[test]
380    fn formatted_durations_reparse_to_the_same_value() {
381        for duration in [
382            Duration::ZERO,
383            Duration::from_secs(1),
384            Duration::from_secs(90),
385            Duration::from_secs(26 * 60 * 60 + 30),
386            Duration::from_secs(7 * 24 * 60 * 60),
387        ] {
388            let formatted = PrettyDuration::new(duration);
389            assert_eq!(
390                formatted.to_string().parse::<PrettyDuration>().unwrap(),
391                formatted
392            );
393        }
394    }
395
396    #[test]
397    fn equal_durations_compare_equal_regardless_of_spelling() {
398        assert_eq!(
399            "60s".parse::<PrettyDuration>().unwrap(),
400            "1m".parse::<PrettyDuration>().unwrap()
401        );
402        assert!("59s".parse::<PrettyDuration>().unwrap() < "1m".parse::<PrettyDuration>().unwrap());
403    }
404
405    #[test]
406    #[should_panic]
407    pub fn cant_parse_negative_duration() {
408        _ = "-12s".parse::<PrettyDuration>().unwrap();
409    }
410
411    #[test]
412    fn unit_constructors_saturate_instead_of_overflowing() {
413        for duration in [
414            PrettyDuration::from_mins(u64::MAX),
415            PrettyDuration::from_hours(u64::MAX),
416            PrettyDuration::from_days(u64::MAX),
417        ] {
418            assert_eq!(duration.as_duration(), Duration::from_secs(u64::MAX));
419            assert_eq!(
420                duration.to_string().parse::<PrettyDuration>().unwrap(),
421                duration
422            );
423        }
424    }
425
426    #[test]
427    fn sub_second_precision_is_truncated() {
428        // The unit set bottoms out at seconds, so a constructed value drops
429        // anything finer rather than formatting to a spelling it cannot parse.
430        assert_eq!(
431            PrettyDuration::new(Duration::from_millis(1500)).as_duration(),
432            Duration::from_secs(1)
433        );
434        assert_eq!(
435            PrettyDuration::new(Duration::from_millis(1500)).as_str(),
436            "1s"
437        );
438    }
439
440    #[test]
441    fn invalid_durations_are_rejected() {
442        for input in [
443            "",
444            " ",
445            "5",
446            "s",
447            "5x",
448            "500ms",
449            "1ns",
450            "1microsec",
451            "5 s",
452            "1m  30s",
453            "not a duration",
454            "18446744073709551616s",
455            "18446744073709551615d",
456        ] {
457            assert!(
458                input.parse::<PrettyDuration>().is_err(),
459                "accepted invalid duration {input:?}"
460            );
461            assert!(serde_json::from_value::<PrettyDuration>(serde_json::json!(input)).is_err());
462        }
463    }
464}