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}