Skip to main content

reliakit_primitives/
duration.rs

1use crate::{PrimitiveError, PrimitiveResult};
2#[cfg(feature = "alloc")]
3use alloc::string::String;
4use core::{fmt, str::FromStr, time::Duration};
5
6/// Human-readable duration parsed from strings like `1h`, `30m`, `45s`,
7/// `500ms`, or combinations such as `1h30m45s`.
8///
9/// Supported units: `h` (hours), `m` (minutes), `s` (seconds), `ms`
10/// (milliseconds). Units must appear in descending order, each at most once.
11#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct HumanDuration(Duration);
13
14impl HumanDuration {
15    /// Parses a human-readable duration string.
16    ///
17    /// # Examples
18    ///
19    /// ```
20    /// # use reliakit_primitives::HumanDuration;
21    /// let d = HumanDuration::parse("1h30m").unwrap();
22    /// assert_eq!(d.as_secs(), 5400);
23    /// ```
24    pub fn parse(s: &str) -> PrimitiveResult<Self> {
25        if s.is_empty() {
26            return Err(PrimitiveError::Empty);
27        }
28
29        // Unit rank: higher = larger unit. Each unit may appear at most once,
30        // and units must be provided in strictly descending order (h > m > s > ms).
31        const RANK_H: u8 = 4;
32        const RANK_M: u8 = 3;
33        const RANK_S: u8 = 2;
34        const RANK_MS: u8 = 1;
35
36        let mut total_nanos: u128 = 0;
37        let mut last_rank: u8 = u8::MAX;
38        let mut pos = 0;
39        let bytes = s.as_bytes();
40
41        while pos < bytes.len() {
42            // Parse digits
43            let num_start = pos;
44            while pos < bytes.len() && bytes[pos].is_ascii_digit() {
45                pos += 1;
46            }
47            if pos == num_start {
48                return Err(PrimitiveError::Invalid {
49                    message: "expected a number before unit",
50                });
51            }
52            let num_str = &s[num_start..pos];
53            let num = parse_u64(num_str).ok_or(PrimitiveError::Invalid {
54                message: "duration number is too large",
55            })?;
56
57            // Parse unit (1 or 2 ASCII alpha chars)
58            let unit_start = pos;
59            while pos < bytes.len() && bytes[pos].is_ascii_alphabetic() {
60                pos += 1;
61            }
62            let unit = &s[unit_start..pos];
63
64            let (nanos_per_unit, rank): (u128, u8) = match unit {
65                "h" => (3_600 * 1_000_000_000, RANK_H),
66                "m" => (60 * 1_000_000_000, RANK_M),
67                "s" => (1_000_000_000, RANK_S),
68                "ms" => (1_000_000, RANK_MS),
69                _ => {
70                    return Err(PrimitiveError::Invalid {
71                        message: "unknown time unit; use h, m, s, or ms",
72                    });
73                }
74            };
75
76            if rank >= last_rank {
77                return Err(PrimitiveError::Invalid {
78                    message: "units must be in descending order (h, m, s, ms) with no duplicates",
79                });
80            }
81            last_rank = rank;
82
83            let component =
84                (num as u128)
85                    .checked_mul(nanos_per_unit)
86                    .ok_or(PrimitiveError::Invalid {
87                        message: "duration overflow",
88                    })?;
89
90            total_nanos = total_nanos
91                .checked_add(component)
92                .ok_or(PrimitiveError::Invalid {
93                    message: "duration overflow",
94                })?;
95        }
96
97        let secs =
98            u64::try_from(total_nanos / 1_000_000_000).map_err(|_| PrimitiveError::Invalid {
99                message: "duration overflow: total duration exceeds maximum representable value",
100            })?;
101        let nanos = (total_nanos % 1_000_000_000) as u32;
102        Ok(Self(Duration::new(secs, nanos)))
103    }
104
105    /// Returns the underlying `core::time::Duration`.
106    pub fn as_duration(self) -> Duration {
107        self.0
108    }
109
110    /// Returns the total number of whole seconds.
111    pub fn as_secs(self) -> u64 {
112        self.0.as_secs()
113    }
114
115    /// Returns the total number of whole milliseconds.
116    pub fn as_millis(self) -> u128 {
117        self.0.as_millis()
118    }
119}
120
121fn parse_u64(s: &str) -> Option<u64> {
122    if s.is_empty() {
123        return None;
124    }
125    let mut result: u64 = 0;
126    for c in s.chars() {
127        let digit = c.to_digit(10)? as u64;
128        result = result.checked_mul(10)?.checked_add(digit)?;
129    }
130    Some(result)
131}
132
133impl fmt::Display for HumanDuration {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        let total_secs = self.0.as_secs();
136        let millis = self.0.subsec_millis();
137        let h = total_secs / 3600;
138        let m = (total_secs % 3600) / 60;
139        let s = total_secs % 60;
140
141        let mut wrote = false;
142        if h > 0 {
143            write!(f, "{h}h")?;
144            wrote = true;
145        }
146        if m > 0 {
147            write!(f, "{m}m")?;
148            wrote = true;
149        }
150        if s > 0 || millis > 0 {
151            if s > 0 {
152                write!(f, "{s}s")?;
153            }
154            if millis > 0 {
155                write!(f, "{millis}ms")?;
156            }
157            wrote = true;
158        }
159        if !wrote {
160            write!(f, "0s")?;
161        }
162        Ok(())
163    }
164}
165
166impl FromStr for HumanDuration {
167    type Err = PrimitiveError;
168
169    fn from_str(s: &str) -> Result<Self, Self::Err> {
170        Self::parse(s)
171    }
172}
173
174impl PartialEq<str> for HumanDuration {
175    fn eq(&self, other: &str) -> bool {
176        Self::parse(other).is_ok_and(|other| self == &other)
177    }
178}
179
180impl PartialEq<&str> for HumanDuration {
181    fn eq(&self, other: &&str) -> bool {
182        self.eq(*other)
183    }
184}
185
186#[cfg(feature = "alloc")]
187impl PartialEq<String> for HumanDuration {
188    fn eq(&self, other: &String) -> bool {
189        self.eq(other.as_str())
190    }
191}
192
193#[cfg(feature = "alloc")]
194impl PartialEq<&String> for HumanDuration {
195    fn eq(&self, other: &&String) -> bool {
196        self.eq(other.as_str())
197    }
198}
199
200/// A [`Duration`] guaranteed to be strictly positive (non-zero).
201///
202/// Useful for timeouts and intervals where a zero duration is meaningless: the
203/// type turns "must be greater than zero" into a guarantee carried by the value
204/// instead of a check repeated at every call site.
205#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
206pub struct PositiveDuration(Duration);
207
208impl PositiveDuration {
209    /// Creates a `PositiveDuration`. Returns `Invalid` if `value` is
210    /// [`Duration::ZERO`].
211    pub fn new(value: Duration) -> PrimitiveResult<Self> {
212        if value.is_zero() {
213            return Err(PrimitiveError::Invalid {
214                message: "duration must be strictly positive",
215            });
216        }
217        Ok(Self(value))
218    }
219
220    /// Returns the wrapped [`Duration`].
221    pub fn get(self) -> Duration {
222        self.0
223    }
224}
225
226impl fmt::Display for PositiveDuration {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(f, "{:?}", self.0)
229    }
230}
231
232impl TryFrom<Duration> for PositiveDuration {
233    type Error = PrimitiveError;
234
235    fn try_from(value: Duration) -> Result<Self, Self::Error> {
236        Self::new(value)
237    }
238}
239
240impl AsRef<Duration> for PositiveDuration {
241    fn as_ref(&self) -> &Duration {
242        &self.0
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::{HumanDuration, PositiveDuration};
249    use crate::PrimitiveError;
250    use alloc::string::ToString;
251    use core::time::Duration;
252
253    #[test]
254    fn parses_seconds() {
255        assert_eq!(HumanDuration::parse("45s").unwrap().as_secs(), 45);
256    }
257
258    #[test]
259    fn parses_minutes() {
260        assert_eq!(HumanDuration::parse("2m").unwrap().as_secs(), 120);
261    }
262
263    #[test]
264    fn parses_hours() {
265        assert_eq!(HumanDuration::parse("1h").unwrap().as_secs(), 3600);
266    }
267
268    #[test]
269    fn parses_milliseconds() {
270        assert_eq!(HumanDuration::parse("500ms").unwrap().as_millis(), 500);
271    }
272
273    #[test]
274    fn parses_combination() {
275        let d = HumanDuration::parse("1h30m45s").unwrap();
276        assert_eq!(d.as_secs(), 3600 + 1800 + 45);
277    }
278
279    #[test]
280    fn parses_minutes_and_seconds() {
281        assert_eq!(HumanDuration::parse("2m30s").unwrap().as_secs(), 150);
282    }
283
284    #[test]
285    fn rejects_empty() {
286        assert_eq!(HumanDuration::parse("").unwrap_err(), PrimitiveError::Empty);
287    }
288
289    #[test]
290    fn rejects_unknown_unit() {
291        assert!(HumanDuration::parse("5d").is_err());
292    }
293
294    #[test]
295    fn rejects_no_number() {
296        assert!(HumanDuration::parse("s").is_err());
297    }
298
299    #[test]
300    fn rejects_out_of_order_units() {
301        assert!(HumanDuration::parse("1s1h").is_err());
302    }
303
304    #[test]
305    fn rejects_duplicate_units() {
306        assert!(HumanDuration::parse("1h1h").is_err());
307    }
308
309    #[test]
310    fn rejects_ms_before_s() {
311        assert!(HumanDuration::parse("500ms30s").is_err());
312    }
313
314    #[test]
315    fn as_duration() {
316        let d = HumanDuration::parse("1s").unwrap();
317        assert_eq!(d.as_duration().as_secs(), 1);
318    }
319
320    #[test]
321    fn display_seconds() {
322        assert_eq!(HumanDuration::parse("45s").unwrap().to_string(), "45s");
323    }
324
325    #[test]
326    fn display_combined() {
327        assert_eq!(HumanDuration::parse("1h30m").unwrap().to_string(), "1h30m");
328    }
329
330    #[test]
331    fn display_zero() {
332        assert_eq!(HumanDuration::parse("0s").unwrap().to_string(), "0s");
333    }
334
335    #[test]
336    fn display_mixed_seconds_and_millis() {
337        assert_eq!(
338            HumanDuration::parse("1s500ms").unwrap().to_string(),
339            "1s500ms"
340        );
341    }
342
343    #[test]
344    fn display_millis_only() {
345        assert_eq!(HumanDuration::parse("500ms").unwrap().to_string(), "500ms");
346    }
347
348    #[test]
349    fn rejects_duration_that_overflows_u64_seconds() {
350        // u64::MAX hours * 3600 seconds/hour >> u64::MAX seconds
351        assert!(HumanDuration::parse("18446744073709551615h").is_err());
352    }
353
354    #[test]
355    fn from_str_and_string_comparisons() {
356        let duration = "1m30s".parse::<HumanDuration>().unwrap();
357        let owned = "90s".to_string();
358        assert_eq!(duration, "1m30s");
359        assert_eq!(duration, owned);
360        assert!("1s1m".parse::<HumanDuration>().is_err());
361    }
362
363    #[test]
364    fn positive_duration_accepts_nonzero() {
365        let d = PositiveDuration::new(Duration::from_nanos(1)).unwrap();
366        assert_eq!(d.get(), Duration::from_nanos(1));
367    }
368
369    #[test]
370    fn positive_duration_rejects_zero() {
371        assert!(matches!(
372            PositiveDuration::new(Duration::ZERO),
373            Err(PrimitiveError::Invalid { .. })
374        ));
375    }
376
377    #[test]
378    fn positive_duration_try_from_and_as_ref() {
379        let d = PositiveDuration::try_from(Duration::from_secs(5)).unwrap();
380        assert_eq!(d.as_ref(), &Duration::from_secs(5));
381        assert!(PositiveDuration::try_from(Duration::ZERO).is_err());
382    }
383
384    #[test]
385    fn positive_duration_display() {
386        let d = PositiveDuration::new(Duration::from_millis(1500)).unwrap();
387        assert_eq!(d.to_string(), "1.5s");
388    }
389}