Skip to main content

objectstore_types/
duration.rs

1//! The wire format for durations.
2//!
3//! Durations are exchanged as part of the
4//! [`x-sn-expiration`](crate::metadata::HEADER_EXPIRATION) header, for instance as `ttl:7d 12h`.
5//!
6//! # Emitted format
7//!
8//! A duration is written as a space-separated list of `<integer><unit>` components, ordered from
9//! the largest unit to the smallest. Components that are zero are omitted, and a zero duration is
10//! written as `0s`. Only four units are ever emitted:
11//!
12//! | Unit | Meaning       |
13//! |------|---------------|
14//! | `d`  | day, 24 hours |
15//! | `h`  | hour          |
16//! | `m`  | minute        |
17//! | `s`  | second        |
18//!
19//! Day is deliberately the largest unit. Weeks, months, and years are never emitted, because they
20//! either have no fixed length (a calendar month) or invite a definition that differs between
21//! implementations (is a year 365 or 365.25 days?). Durations longer than a day therefore stay in
22//! days: 400 days is written as `400d`, never as `1y 1m 5d`.
23//!
24//! Second is the smallest unit; any sub-second remainder is truncated.
25//!
26//! # Parsing
27//!
28//! [`parse_duration`] accepts a superset of the emitted format, including units this crate never
29//! writes. That leniency exists to keep reading values that older versions persisted, and is not
30//! part of the wire format: do not rely on it, and do not reproduce it in clients.
31
32use std::error::Error;
33use std::fmt;
34use std::time::Duration;
35
36const SECS_PER_MINUTE: u64 = 60;
37const SECS_PER_HOUR: u64 = 60 * SECS_PER_MINUTE;
38const SECS_PER_DAY: u64 = 24 * SECS_PER_HOUR;
39
40/// Formats a duration in the wire format.
41///
42/// Returns a displayable value that writes the duration using the `d`, `h`, `m`, and `s` units,
43/// as described in the [module documentation](self). Sub-second remainders are truncated.
44///
45/// # Example
46///
47/// ```
48/// use std::time::Duration;
49/// use objectstore_types::duration::format_duration;
50///
51/// let formatted = format_duration(Duration::from_secs(400 * 86400 + 90));
52/// assert_eq!(formatted.to_string(), "400d 1m 30s");
53/// ```
54pub fn format_duration(duration: Duration) -> FormattedDuration {
55    FormattedDuration(duration)
56}
57
58/// Parses a duration from the wire format.
59///
60/// # Example
61///
62/// ```
63/// use std::time::Duration;
64/// use objectstore_types::duration::parse_duration;
65///
66/// let duration = parse_duration("400d 1m 30s")?;
67/// assert_eq!(duration, Duration::from_secs(400 * 86400 + 90));
68/// # Ok::<(), objectstore_types::duration::ParseDurationError>(())
69/// ```
70///
71/// # Errors
72///
73/// Returns a [`ParseDurationError`] if `input` is not a valid duration.
74pub fn parse_duration(input: &str) -> Result<Duration, ParseDurationError> {
75    humantime::parse_duration(input).map_err(ParseDurationError)
76}
77
78/// The error returned when a string is not a valid duration in the wire format.
79///
80/// Returned by [`parse_duration`].
81#[derive(Debug)]
82pub struct ParseDurationError(humantime::DurationError);
83
84impl fmt::Display for ParseDurationError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        self.0.fmt(f)
87    }
88}
89
90impl Error for ParseDurationError {}
91
92/// A [`Duration`] that displays in the wire format.
93///
94/// Created by [`format_duration`].
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct FormattedDuration(Duration);
97
98impl fmt::Display for FormattedDuration {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        let secs = self.0.as_secs();
101        if secs == 0 {
102            return f.write_str("0s");
103        }
104
105        let components = [
106            (secs / SECS_PER_DAY, "d"),
107            (secs % SECS_PER_DAY / SECS_PER_HOUR, "h"),
108            (secs % SECS_PER_HOUR / SECS_PER_MINUTE, "m"),
109            (secs % SECS_PER_MINUTE, "s"),
110        ];
111
112        let mut separator = "";
113        for (value, unit) in components {
114            if value > 0 {
115                write!(f, "{separator}{value}{unit}")?;
116                separator = " ";
117            }
118        }
119
120        Ok(())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn format(duration: Duration) -> String {
129        format_duration(duration).to_string()
130    }
131
132    #[test]
133    fn formats_units() {
134        assert_eq!(format(Duration::ZERO), "0s");
135        assert_eq!(format(Duration::from_secs(30)), "30s");
136        assert_eq!(format(Duration::from_secs(60)), "1m");
137        assert_eq!(format(Duration::from_secs(3600)), "1h");
138        assert_eq!(format(Duration::from_secs(86400)), "1d");
139    }
140
141    #[test]
142    fn formats_combined_units_and_skips_zeroes() {
143        let duration = Duration::from_secs(2 * 86400 + 3 * 3600 + 4);
144        assert_eq!(format(duration), "2d 3h 4s");
145    }
146
147    #[test]
148    fn keeps_long_durations_in_days() {
149        // Neither of these may roll over into weeks, months, or years.
150        assert_eq!(format(Duration::from_secs(7 * 86400)), "7d");
151        assert_eq!(format(Duration::from_secs(400 * 86400)), "400d");
152    }
153
154    #[test]
155    fn truncates_sub_second_remainder() {
156        assert_eq!(format(Duration::from_millis(1500)), "1s");
157        assert_eq!(format(Duration::from_millis(500)), "0s");
158    }
159
160    #[test]
161    fn round_trips_through_parse() {
162        for secs in [0, 1, 59, 60, 3661, 86400, 396 * 86400 + 62208] {
163            let duration = Duration::from_secs(secs);
164            let formatted = format(duration);
165            assert_eq!(parse_duration(&formatted).unwrap(), duration, "{formatted}");
166        }
167    }
168
169    #[test]
170    fn parses_units_that_are_never_emitted() {
171        assert_eq!(
172            parse_duration("2weeks").unwrap(),
173            Duration::from_secs(1_209_600)
174        );
175        assert_eq!(
176            parse_duration("1year").unwrap(),
177            Duration::from_secs(31_557_600)
178        );
179    }
180}