1pub use std::time::Duration;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct DurationParseError {
15 pub input: String,
17}
18
19impl std::fmt::Display for DurationParseError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 write!(f, "cannot parse {:?} as a duration", self.input)
22 }
23}
24
25impl std::error::Error for DurationParseError {}
26
27#[allow(clippy::module_inception)] pub mod duration {
32 use super::{Duration, DurationParseError};
33
34 pub fn nanos(n: u64) -> Duration {
38 Duration::from_nanos(n)
39 }
40
41 pub fn micros(n: u64) -> Duration {
43 Duration::from_micros(n)
44 }
45
46 pub fn millis(n: u64) -> Duration {
48 Duration::from_millis(n)
49 }
50
51 pub fn seconds(n: u64) -> Duration {
53 Duration::from_secs(n)
54 }
55
56 pub fn seconds_f64(n: f64) -> Duration {
58 Duration::from_secs_f64(n)
59 }
60
61 pub fn minutes(n: u64) -> Duration {
63 Duration::from_secs(n * 60)
64 }
65
66 pub fn hours(n: u64) -> Duration {
68 Duration::from_secs(n * 3_600)
69 }
70
71 pub fn days(n: u64) -> Duration {
73 Duration::from_secs(n * 86_400)
74 }
75
76 pub fn weeks(n: u64) -> Duration {
78 Duration::from_secs(n * 604_800)
79 }
80
81 pub const INFINITY: Duration = Duration::MAX;
83
84 pub const ZERO: Duration = Duration::ZERO;
86
87 pub fn decode(input: &str) -> Result<Duration, DurationParseError> {
106 let err = || DurationParseError {
107 input: input.to_string(),
108 };
109 let s = input.trim();
110 if s.is_empty() {
111 return Err(err());
112 }
113
114 if let Ok(n) = s.parse::<f64>() {
116 if n < 0.0 {
117 return Err(err());
118 }
119 return Ok(Duration::from_secs_f64(n / 1_000.0));
120 }
121
122 let split_pos = s.find(|c: char| c.is_alphabetic()).ok_or_else(err)?;
124
125 if split_pos == 0 {
126 return Err(err());
127 }
128
129 let num_str = s[..split_pos].trim();
130 let unit_str = s[split_pos..].trim().to_lowercase();
131
132 let n: f64 = num_str.parse().map_err(|_| err())?;
133 if n < 0.0 {
134 return Err(err());
135 }
136
137 let d = match unit_str.as_str() {
138 "ns" | "nanos" | "nanosecond" | "nanoseconds" => Duration::from_secs_f64(n / 1_000_000_000.0),
139 "us" | "\u{b5}s" | "micros" | "microsecond" | "microseconds" => {
140 Duration::from_secs_f64(n / 1_000_000.0)
141 }
142 "ms" | "millis" | "millisecond" | "milliseconds" => Duration::from_secs_f64(n / 1_000.0),
143 "s" | "sec" | "secs" | "second" | "seconds" => Duration::from_secs_f64(n),
144 "m" | "min" | "mins" | "minute" | "minutes" => Duration::from_secs_f64(n * 60.0),
145 "h" | "hr" | "hrs" | "hour" | "hours" => Duration::from_secs_f64(n * 3_600.0),
146 "d" | "day" | "days" => Duration::from_secs_f64(n * 86_400.0),
147 "w" | "week" | "weeks" => Duration::from_secs_f64(n * 604_800.0),
148 _ => return Err(err()),
149 };
150 Ok(d)
151 }
152
153 pub fn sum(a: Duration, b: Duration) -> Duration {
157 a + b
158 }
159
160 pub fn subtract(a: Duration, b: Duration) -> Duration {
162 a.saturating_sub(b)
163 }
164
165 pub fn times(a: Duration, n: u32) -> Duration {
167 a * n
168 }
169
170 pub fn min(a: Duration, b: Duration) -> Duration {
172 a.min(b)
173 }
174
175 pub fn max(a: Duration, b: Duration) -> Duration {
177 a.max(b)
178 }
179
180 pub fn clamp(d: Duration, minimum: Duration, maximum: Duration) -> Duration {
182 d.max(minimum).min(maximum)
183 }
184
185 pub fn between(d: Duration, minimum: Duration, maximum: Duration) -> bool {
187 d >= minimum && d <= maximum
188 }
189
190 pub fn to_millis(d: Duration) -> f64 {
194 d.as_millis() as f64
195 }
196
197 pub fn to_nanos(d: Duration) -> u128 {
199 d.as_nanos()
200 }
201
202 pub fn to_seconds(d: Duration) -> f64 {
204 d.as_secs_f64()
205 }
206
207 pub fn to_hours(d: Duration) -> f64 {
209 d.as_secs_f64() / 3_600.0
210 }
211
212 pub fn format(d: Duration) -> String {
216 let total_secs = d.as_secs();
217 let subsec_nanos = d.subsec_nanos();
218
219 let weeks = total_secs / 604_800;
220 let rem = total_secs % 604_800;
221 let days = rem / 86_400;
222 let rem = rem % 86_400;
223 let hours = rem / 3_600;
224 let rem = rem % 3_600;
225 let minutes = rem / 60;
226 let secs = rem % 60;
227 let millis = subsec_nanos / 1_000_000;
228
229 let mut parts = Vec::new();
230 if weeks > 0 {
231 parts.push(format!("{weeks}w"));
232 }
233 if days > 0 {
234 parts.push(format!("{days}d"));
235 }
236 if hours > 0 {
237 parts.push(format!("{hours}h"));
238 }
239 if minutes > 0 {
240 parts.push(format!("{minutes}m"));
241 }
242 match (secs, millis) {
243 (0, 0) => {}
244 (s, 0) => parts.push(format!("{s}s")),
245 (0, ms) => parts.push(format!("0.{ms:03}s")),
246 (s, ms) => parts.push(format!("{s}.{ms:03}s")),
247 }
248
249 if parts.is_empty() {
250 "0s".to_string()
251 } else {
252 parts.join(" ")
253 }
254 }
255
256 pub fn is_zero(d: Duration) -> bool {
260 d.is_zero()
261 }
262
263 pub fn is_finite(d: Duration) -> bool {
265 d != Duration::MAX
266 }
267}
268
269#[cfg(test)]
272mod tests {
273 use super::Duration;
274 use super::duration;
275 use rstest::rstest;
276
277 mod constructors {
280 use super::*;
281
282 #[test]
283 fn nanos_round_trips_to_nanos() {
284 assert_eq!(duration::nanos(500).as_nanos(), 500);
285 }
286
287 #[test]
288 fn micros_round_trips() {
289 assert_eq!(duration::micros(200).as_micros(), 200);
290 }
291
292 #[test]
293 fn millis_round_trips() {
294 assert_eq!(duration::millis(1_000).as_millis(), 1_000);
295 }
296
297 #[test]
298 fn seconds_round_trips() {
299 assert_eq!(duration::seconds(60).as_secs(), 60);
300 }
301
302 #[test]
303 fn minutes_is_60_seconds() {
304 assert_eq!(duration::minutes(1), duration::seconds(60));
305 }
306
307 #[test]
308 fn hours_is_3600_seconds() {
309 assert_eq!(duration::hours(1), duration::seconds(3_600));
310 }
311
312 #[test]
313 fn days_is_86400_seconds() {
314 assert_eq!(duration::days(1), duration::seconds(86_400));
315 }
316
317 #[test]
318 fn weeks_is_7_days() {
319 assert_eq!(duration::weeks(1), duration::days(7));
320 }
321
322 #[test]
323 fn zero_constant_is_zero_duration() {
324 assert!(duration::ZERO.is_zero());
325 }
326
327 #[test]
328 fn infinity_constant_is_max_duration() {
329 assert_eq!(duration::INFINITY, Duration::MAX);
330 }
331
332 #[test]
333 fn seconds_f64_half_second() {
334 let d = duration::seconds_f64(0.5);
335 assert_eq!(d.as_millis(), 500);
336 }
337 }
338
339 mod decode {
342 use super::*;
343
344 #[rstest]
345 #[case::millis_abbrev("100ms", 100)]
346 #[case::millis_word("100 millis", 100)]
347 #[case::millis_full("100 milliseconds", 100)]
348 fn millis_forms(#[case] input: &str, #[case] expected_ms: u64) {
349 let d = duration::decode(input).expect("should parse");
350 assert_eq!(d.as_millis(), expected_ms as u128);
351 }
352
353 #[rstest]
354 #[case::s_abbrev("2s", 2)]
355 #[case::sec("2 sec", 2)]
356 #[case::secs("2 secs", 2)]
357 #[case::second("2 second", 2)]
358 #[case::seconds("2 seconds", 2)]
359 fn seconds_forms(#[case] input: &str, #[case] expected_s: u64) {
360 let d = duration::decode(input).expect("should parse");
361 assert_eq!(d.as_secs(), expected_s);
362 }
363
364 #[rstest]
365 #[case::m("5m", 5)]
366 #[case::min("5 min", 5)]
367 #[case::mins("5 mins", 5)]
368 #[case::minute("5 minute", 5)]
369 #[case::minutes("5 minutes", 5)]
370 fn minutes_forms(#[case] input: &str, #[case] expected_m: u64) {
371 let d = duration::decode(input).expect("should parse");
372 assert_eq!(d.as_secs(), expected_m * 60);
373 }
374
375 #[rstest]
376 #[case::h("1h")]
377 #[case::hr("1 hr")]
378 #[case::hrs("1 hrs")]
379 #[case::hour("1 hour")]
380 #[case::hours("1 hours")]
381 fn hours_forms(#[case] input: &str) {
382 let d = duration::decode(input).expect("should parse");
383 assert_eq!(d.as_secs(), 3_600);
384 }
385
386 #[test]
387 fn days_form() {
388 assert_eq!(duration::decode("1d").unwrap().as_secs(), 86_400);
389 assert_eq!(duration::decode("1 day").unwrap().as_secs(), 86_400);
390 assert_eq!(duration::decode("1 days").unwrap().as_secs(), 86_400);
391 }
392
393 #[test]
394 fn weeks_form() {
395 assert_eq!(duration::decode("1w").unwrap().as_secs(), 604_800);
396 assert_eq!(duration::decode("1 week").unwrap().as_secs(), 604_800);
397 assert_eq!(duration::decode("1 weeks").unwrap().as_secs(), 604_800);
398 }
399
400 #[test]
401 fn nanos_form() {
402 let d = duration::decode("500ns").unwrap();
403 assert_eq!(d.as_nanos(), 500);
404 }
405
406 #[test]
407 fn micros_form() {
408 let d = duration::decode("10us").unwrap();
409 assert_eq!(d.as_micros(), 10);
410 }
411
412 #[test]
413 fn bare_number_is_millis() {
414 let d = duration::decode("500").unwrap();
415 assert_eq!(d.as_millis(), 500);
416 }
417
418 #[test]
419 fn fractional_seconds() {
420 let d = duration::decode("1.5s").unwrap();
421 assert_eq!(d.as_millis(), 1_500);
422 }
423
424 #[test]
425 fn empty_string_returns_error() {
426 assert!(duration::decode("").is_err());
427 }
428
429 #[test]
430 fn whitespace_only_returns_error() {
431 assert!(duration::decode(" ").is_err());
432 }
433
434 #[test]
435 fn unknown_unit_returns_error() {
436 assert!(duration::decode("5 fortnights").is_err());
437 }
438
439 #[test]
440 fn unit_only_no_number_returns_error() {
441 assert!(duration::decode("ms").is_err());
442 }
443
444 #[test]
445 fn error_carries_original_input() {
446 let err = duration::decode("bad input").unwrap_err();
447 assert_eq!(err.input, "bad input");
448 }
449 }
450
451 mod math {
454 use super::*;
455
456 #[test]
457 fn sum_adds_durations() {
458 let a = duration::seconds(1);
459 let b = duration::millis(500);
460 assert_eq!(duration::sum(a, b).as_millis(), 1_500);
461 }
462
463 #[test]
464 fn subtract_normal_case() {
465 let a = duration::seconds(2);
466 let b = duration::seconds(1);
467 assert_eq!(duration::subtract(a, b), duration::seconds(1));
468 }
469
470 #[test]
471 fn subtract_saturates_at_zero() {
472 assert_eq!(
473 duration::subtract(duration::seconds(1), duration::seconds(5)),
474 duration::ZERO
475 );
476 }
477
478 #[test]
479 fn times_multiplies() {
480 assert_eq!(
481 duration::times(duration::seconds(3), 4),
482 duration::seconds(12)
483 );
484 }
485
486 #[test]
487 fn min_returns_shorter() {
488 assert_eq!(
489 duration::min(duration::seconds(1), duration::seconds(5)),
490 duration::seconds(1)
491 );
492 }
493
494 #[test]
495 fn max_returns_longer() {
496 assert_eq!(
497 duration::max(duration::seconds(1), duration::seconds(5)),
498 duration::seconds(5)
499 );
500 }
501
502 #[rstest]
503 #[case::below_min(
504 duration::ZERO,
505 duration::seconds(1),
506 duration::seconds(10),
507 duration::seconds(1)
508 )]
509 #[case::in_range(
510 duration::seconds(5),
511 duration::seconds(1),
512 duration::seconds(10),
513 duration::seconds(5)
514 )]
515 #[case::above_max(
516 duration::seconds(20),
517 duration::seconds(1),
518 duration::seconds(10),
519 duration::seconds(10)
520 )]
521 #[case::at_min(
522 duration::seconds(1),
523 duration::seconds(1),
524 duration::seconds(10),
525 duration::seconds(1)
526 )]
527 #[case::at_max(
528 duration::seconds(10),
529 duration::seconds(1),
530 duration::seconds(10),
531 duration::seconds(10)
532 )]
533 fn clamp_cases(
534 #[case] d: Duration,
535 #[case] min: Duration,
536 #[case] max: Duration,
537 #[case] expected: Duration,
538 ) {
539 assert_eq!(duration::clamp(d, min, max), expected);
540 }
541
542 #[rstest]
543 #[case::in_range(
544 duration::seconds(5),
545 duration::seconds(1),
546 duration::seconds(10),
547 true
548 )]
549 #[case::below(duration::ZERO, duration::seconds(1), duration::seconds(10), false)]
550 #[case::above(
551 duration::seconds(20),
552 duration::seconds(1),
553 duration::seconds(10),
554 false
555 )]
556 #[case::at_min(
557 duration::seconds(1),
558 duration::seconds(1),
559 duration::seconds(10),
560 true
561 )]
562 #[case::at_max(
563 duration::seconds(10),
564 duration::seconds(1),
565 duration::seconds(10),
566 true
567 )]
568 fn between_cases(
569 #[case] d: Duration,
570 #[case] min: Duration,
571 #[case] max: Duration,
572 #[case] expected: bool,
573 ) {
574 assert_eq!(duration::between(d, min, max), expected);
575 }
576 }
577
578 mod extraction {
581 use super::*;
582
583 #[test]
584 fn to_millis_converts_correctly() {
585 assert_eq!(duration::to_millis(duration::seconds(2)), 2_000.0);
586 }
587
588 #[test]
589 fn to_nanos_converts_correctly() {
590 assert_eq!(duration::to_nanos(duration::millis(1)), 1_000_000);
591 }
592
593 #[test]
594 fn to_seconds_converts_correctly() {
595 assert!((duration::to_seconds(duration::millis(500)) - 0.5).abs() < 1e-10);
596 }
597
598 #[test]
599 fn to_hours_converts_correctly() {
600 assert!((duration::to_hours(duration::hours(2)) - 2.0).abs() < 1e-10);
601 }
602 }
603
604 mod format {
607 use super::*;
608
609 #[test]
610 fn zero_formats_as_0s() {
611 assert_eq!(duration::format(duration::ZERO), "0s");
612 }
613
614 #[test]
615 fn whole_seconds_format() {
616 assert_eq!(duration::format(duration::seconds(5)), "5s");
617 }
618
619 #[test]
620 fn millis_format() {
621 assert_eq!(duration::format(duration::millis(500)), "0.500s");
622 }
623
624 #[test]
625 fn minutes_format() {
626 assert_eq!(duration::format(duration::minutes(3)), "3m");
627 }
628
629 #[test]
630 fn hours_format() {
631 assert_eq!(duration::format(duration::hours(2)), "2h");
632 }
633
634 #[test]
635 fn combined_format() {
636 let d = duration::hours(1) + duration::minutes(2) + duration::seconds(3);
637 assert_eq!(duration::format(d), "1h 2m 3s");
638 }
639
640 #[test]
641 fn days_format() {
642 assert_eq!(duration::format(duration::days(1)), "1d");
643 }
644
645 #[test]
646 fn weeks_format() {
647 assert_eq!(duration::format(duration::weeks(1)), "1w");
648 }
649
650 #[test]
651 fn seconds_with_millis_format() {
652 let d = duration::seconds(3) + duration::millis(4);
653 assert_eq!(duration::format(d), "3.004s");
654 }
655 }
656
657 mod checks {
660 use super::*;
661
662 #[test]
663 fn is_zero_true_for_zero() {
664 assert!(duration::is_zero(duration::ZERO));
665 }
666
667 #[test]
668 fn is_zero_false_for_nonzero() {
669 assert!(!duration::is_zero(duration::millis(1)));
670 }
671
672 #[test]
673 fn is_finite_true_for_ordinary_duration() {
674 assert!(duration::is_finite(duration::seconds(100)));
675 }
676
677 #[test]
678 fn is_finite_false_for_max_duration() {
679 assert!(!duration::is_finite(duration::INFINITY));
680 }
681 }
682}