Skip to main content

effectful/scheduling/
duration.rs

1//! Duration constructors, helpers, and string decode — mirrors Effect.ts `Duration`.
2//!
3//! `std::time::Duration` is re-exported as the base type; this module adds the
4//! Effect.ts naming layer (`millis`, `seconds`, `minutes`, …), a `decode` function
5//! that parses human-readable strings ("2 seconds", "100ms", etc.), a `format`
6//! function, and the standard math/extraction helpers.
7
8pub use std::time::Duration;
9
10// ── Parse error ───────────────────────────────────────────────────────────────
11
12/// Returned by [`duration::decode`] when a string cannot be parsed.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct DurationParseError {
15  /// Original string that failed to parse.
16  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// ── duration module ───────────────────────────────────────────────────────────
28
29/// Free functions on `Duration` — mirrors the Effect.ts `Duration` namespace.
30#[allow(clippy::module_inception)] // intentional `duration::duration::…` mirror of Effect.ts
31pub mod duration {
32  use super::{Duration, DurationParseError};
33
34  // ── Constructors ──────────────────────────────────────────────────────────
35
36  /// `Duration.nanos(n)` — *n* nanoseconds.
37  pub fn nanos(n: u64) -> Duration {
38    Duration::from_nanos(n)
39  }
40
41  /// `Duration.micros(n)` — *n* microseconds.
42  pub fn micros(n: u64) -> Duration {
43    Duration::from_micros(n)
44  }
45
46  /// `Duration.millis(n)` — *n* milliseconds.
47  pub fn millis(n: u64) -> Duration {
48    Duration::from_millis(n)
49  }
50
51  /// `Duration.seconds(n)` — *n* whole seconds.
52  pub fn seconds(n: u64) -> Duration {
53    Duration::from_secs(n)
54  }
55
56  /// `Duration.seconds_f64(n)` — *n* seconds as a floating-point number.
57  pub fn seconds_f64(n: f64) -> Duration {
58    Duration::from_secs_f64(n)
59  }
60
61  /// `Duration.minutes(n)` — *n* minutes.
62  pub fn minutes(n: u64) -> Duration {
63    Duration::from_secs(n * 60)
64  }
65
66  /// `Duration.hours(n)` — *n* hours.
67  pub fn hours(n: u64) -> Duration {
68    Duration::from_secs(n * 3_600)
69  }
70
71  /// `Duration.days(n)` — *n* calendar days (24 h each).
72  pub fn days(n: u64) -> Duration {
73    Duration::from_secs(n * 86_400)
74  }
75
76  /// `Duration.weeks(n)` — *n* weeks (7 days each).
77  pub fn weeks(n: u64) -> Duration {
78    Duration::from_secs(n * 604_800)
79  }
80
81  /// `Duration.infinity` — the maximum representable duration.
82  pub const INFINITY: Duration = Duration::MAX;
83
84  /// `Duration.zero` — zero duration.
85  pub const ZERO: Duration = Duration::ZERO;
86
87  // ── Decode ────────────────────────────────────────────────────────────────
88
89  /// Parse a human-readable duration string.
90  ///
91  /// Accepted forms (case-insensitive, optional space between number and unit):
92  ///
93  /// | Input examples | Unit |
94  /// |---|---|
95  /// | `"100ns"` / `"100 nanos"` / `"100 nanoseconds"` | nanoseconds |
96  /// | `"5us"` / `"5 micros"` / `"5 microseconds"` | microseconds |
97  /// | `"30ms"` / `"30 millis"` / `"30 milliseconds"` | milliseconds |
98  /// | `"2s"` / `"2 sec"` / `"2 secs"` / `"2 seconds"` | seconds |
99  /// | `"5m"` / `"5 min"` / `"5 mins"` / `"5 minutes"` | minutes |
100  /// | `"1h"` / `"1 hr"` / `"1 hrs"` / `"1 hour"` / `"1 hours"` | hours |
101  /// | `"1d"` / `"1 day"` / `"1 days"` | days |
102  /// | `"1w"` / `"1 week"` / `"1 weeks"` | weeks |
103  ///
104  /// A bare number (e.g. `"500"`) is interpreted as milliseconds (Effect.ts behaviour).
105  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    // Bare number → milliseconds
115    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    // Find where digits (and optional leading minus / decimal point) end
123    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  // ── Math ──────────────────────────────────────────────────────────────────
154
155  /// `Duration.sum` — add two durations.
156  pub fn sum(a: Duration, b: Duration) -> Duration {
157    a + b
158  }
159
160  /// `Duration.subtract` — saturating subtraction (never goes below zero).
161  pub fn subtract(a: Duration, b: Duration) -> Duration {
162    a.saturating_sub(b)
163  }
164
165  /// `Duration.times` / `Duration.multiply` — multiply by a scalar.
166  pub fn times(a: Duration, n: u32) -> Duration {
167    a * n
168  }
169
170  /// Return the shorter duration.
171  pub fn min(a: Duration, b: Duration) -> Duration {
172    a.min(b)
173  }
174
175  /// Return the longer duration.
176  pub fn max(a: Duration, b: Duration) -> Duration {
177    a.max(b)
178  }
179
180  /// Clamp `d` to `[minimum, maximum]`.
181  pub fn clamp(d: Duration, minimum: Duration, maximum: Duration) -> Duration {
182    d.max(minimum).min(maximum)
183  }
184
185  /// True when `minimum <= d <= maximum`.
186  pub fn between(d: Duration, minimum: Duration, maximum: Duration) -> bool {
187    d >= minimum && d <= maximum
188  }
189
190  // ── Extraction ────────────────────────────────────────────────────────────
191
192  /// Total milliseconds as `f64`.
193  pub fn to_millis(d: Duration) -> f64 {
194    d.as_millis() as f64
195  }
196
197  /// Total nanoseconds as `u128`.
198  pub fn to_nanos(d: Duration) -> u128 {
199    d.as_nanos()
200  }
201
202  /// Total seconds as `f64`.
203  pub fn to_seconds(d: Duration) -> f64 {
204    d.as_secs_f64()
205  }
206
207  /// Total hours as `f64`.
208  pub fn to_hours(d: Duration) -> f64 {
209    d.as_secs_f64() / 3_600.0
210  }
211
212  // ── Format ────────────────────────────────────────────────────────────────
213
214  /// Format a duration as a compact human-readable string, e.g. `"1h 2m 3.004s"`.
215  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  // ── Checks ────────────────────────────────────────────────────────────────
257
258  /// True when `d` is zero.
259  pub fn is_zero(d: Duration) -> bool {
260    d.is_zero()
261  }
262
263  /// True when `d` is not `Duration::MAX` (the sentinel for infinity).
264  pub fn is_finite(d: Duration) -> bool {
265    d != Duration::MAX
266  }
267}
268
269// ── Tests ─────────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272mod tests {
273  use super::Duration;
274  use super::duration;
275  use rstest::rstest;
276
277  // ── constructors ─────────────────────────────────────────────────────────
278
279  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  // ── decode ────────────────────────────────────────────────────────────────
340
341  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  // ── math ─────────────────────────────────────────────────────────────────
452
453  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  // ── extraction ────────────────────────────────────────────────────────────
579
580  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  // ── format ────────────────────────────────────────────────────────────────
605
606  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  // ── checks ────────────────────────────────────────────────────────────────
658
659  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}