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