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#[derive(Clone)]
30pub struct PrettyDuration {
31 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 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 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 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 pub fn from_mins(mins: u64) -> Self {
85 Self::new(Duration::from_secs(mins.saturating_mul(60)))
86 }
87
88 pub fn from_hours(hours: u64) -> Self {
90 Self::new(Duration::from_secs(hours.saturating_mul(60 * 60)))
91 }
92
93 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
117impl 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
189fn 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 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 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 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}