Skip to main content

reliakit_primitives/
duration.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use core::{fmt, time::Duration};
3
4/// Human-readable duration parsed from strings like `1h`, `30m`, `45s`,
5/// `500ms`, or combinations such as `1h30m45s`.
6///
7/// Supported units: `h` (hours), `m` (minutes), `s` (seconds), `ms`
8/// (milliseconds). Units must appear in descending order, each at most once.
9#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct HumanDuration(Duration);
11
12impl HumanDuration {
13    /// Parses a human-readable duration string.
14    ///
15    /// # Examples
16    ///
17    /// ```
18    /// # use reliakit_primitives::HumanDuration;
19    /// let d = HumanDuration::parse("1h30m").unwrap();
20    /// assert_eq!(d.as_secs(), 5400);
21    /// ```
22    pub fn parse(s: &str) -> PrimitiveResult<Self> {
23        if s.is_empty() {
24            return Err(PrimitiveError::Empty);
25        }
26
27        let mut total_nanos: u128 = 0;
28        let mut found_any = false;
29        let mut pos = 0;
30        let bytes = s.as_bytes();
31
32        while pos < bytes.len() {
33            // Parse digits
34            let num_start = pos;
35            while pos < bytes.len() && bytes[pos].is_ascii_digit() {
36                pos += 1;
37            }
38            if pos == num_start {
39                return Err(PrimitiveError::Invalid {
40                    message: "expected a number before unit",
41                });
42            }
43            let num_str = &s[num_start..pos];
44            let num = parse_u64(num_str).ok_or(PrimitiveError::Invalid {
45                message: "duration number is too large",
46            })?;
47
48            // Parse unit (1 or 2 ASCII alpha chars)
49            let unit_start = pos;
50            while pos < bytes.len() && bytes[pos].is_ascii_alphabetic() {
51                pos += 1;
52            }
53            let unit = &s[unit_start..pos];
54
55            let nanos_per_unit: u128 = match unit {
56                "ms" => 1_000_000,
57                "s" => 1_000_000_000,
58                "m" => 60 * 1_000_000_000,
59                "h" => 3_600 * 1_000_000_000,
60                _ => {
61                    return Err(PrimitiveError::Invalid {
62                        message: "unknown time unit; use h, m, s, or ms",
63                    })
64                }
65            };
66
67            let component =
68                (num as u128)
69                    .checked_mul(nanos_per_unit)
70                    .ok_or(PrimitiveError::Invalid {
71                        message: "duration overflow",
72                    })?;
73
74            total_nanos = total_nanos
75                .checked_add(component)
76                .ok_or(PrimitiveError::Invalid {
77                    message: "duration overflow",
78                })?;
79
80            found_any = true;
81        }
82
83        if !found_any {
84            return Err(PrimitiveError::Invalid {
85                message: "no duration components found",
86            });
87        }
88
89        let secs = (total_nanos / 1_000_000_000) as u64;
90        let nanos = (total_nanos % 1_000_000_000) as u32;
91        Ok(Self(Duration::new(secs, nanos)))
92    }
93
94    /// Returns the underlying `core::time::Duration`.
95    pub fn as_duration(self) -> Duration {
96        self.0
97    }
98
99    /// Returns the total number of whole seconds.
100    pub fn as_secs(self) -> u64 {
101        self.0.as_secs()
102    }
103
104    /// Returns the total number of whole milliseconds.
105    pub fn as_millis(self) -> u128 {
106        self.0.as_millis()
107    }
108}
109
110fn parse_u64(s: &str) -> Option<u64> {
111    if s.is_empty() {
112        return None;
113    }
114    let mut result: u64 = 0;
115    for c in s.chars() {
116        let digit = c.to_digit(10)? as u64;
117        result = result.checked_mul(10)?.checked_add(digit)?;
118    }
119    Some(result)
120}
121
122impl fmt::Display for HumanDuration {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        let total_secs = self.0.as_secs();
125        let millis = self.0.subsec_millis();
126        let h = total_secs / 3600;
127        let m = (total_secs % 3600) / 60;
128        let s = total_secs % 60;
129
130        let mut wrote = false;
131        if h > 0 {
132            write!(f, "{h}h")?;
133            wrote = true;
134        }
135        if m > 0 {
136            write!(f, "{m}m")?;
137            wrote = true;
138        }
139        if s > 0 || millis > 0 {
140            if s > 0 {
141                write!(f, "{s}s")?;
142            }
143            if millis > 0 {
144                write!(f, "{millis}ms")?;
145            }
146            wrote = true;
147        }
148        if !wrote {
149            write!(f, "0s")?;
150        }
151        Ok(())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::HumanDuration;
158    use crate::PrimitiveError;
159    use alloc::string::ToString;
160
161    #[test]
162    fn parses_seconds() {
163        assert_eq!(HumanDuration::parse("45s").unwrap().as_secs(), 45);
164    }
165
166    #[test]
167    fn parses_minutes() {
168        assert_eq!(HumanDuration::parse("2m").unwrap().as_secs(), 120);
169    }
170
171    #[test]
172    fn parses_hours() {
173        assert_eq!(HumanDuration::parse("1h").unwrap().as_secs(), 3600);
174    }
175
176    #[test]
177    fn parses_milliseconds() {
178        assert_eq!(HumanDuration::parse("500ms").unwrap().as_millis(), 500);
179    }
180
181    #[test]
182    fn parses_combination() {
183        let d = HumanDuration::parse("1h30m45s").unwrap();
184        assert_eq!(d.as_secs(), 3600 + 1800 + 45);
185    }
186
187    #[test]
188    fn parses_minutes_and_seconds() {
189        assert_eq!(HumanDuration::parse("2m30s").unwrap().as_secs(), 150);
190    }
191
192    #[test]
193    fn rejects_empty() {
194        assert_eq!(HumanDuration::parse("").unwrap_err(), PrimitiveError::Empty);
195    }
196
197    #[test]
198    fn rejects_unknown_unit() {
199        assert!(HumanDuration::parse("5d").is_err());
200    }
201
202    #[test]
203    fn rejects_no_number() {
204        assert!(HumanDuration::parse("s").is_err());
205    }
206
207    #[test]
208    fn as_duration() {
209        let d = HumanDuration::parse("1s").unwrap();
210        assert_eq!(d.as_duration().as_secs(), 1);
211    }
212
213    #[test]
214    fn display_seconds() {
215        assert_eq!(HumanDuration::parse("45s").unwrap().to_string(), "45s");
216    }
217
218    #[test]
219    fn display_combined() {
220        assert_eq!(HumanDuration::parse("1h30m").unwrap().to_string(), "1h30m");
221    }
222
223    #[test]
224    fn display_zero() {
225        assert_eq!(HumanDuration::parse("0s").unwrap().to_string(), "0s");
226    }
227
228    #[test]
229    fn display_mixed_seconds_and_millis() {
230        assert_eq!(
231            HumanDuration::parse("1s500ms").unwrap().to_string(),
232            "1s500ms"
233        );
234    }
235
236    #[test]
237    fn display_millis_only() {
238        assert_eq!(HumanDuration::parse("500ms").unwrap().to_string(), "500ms");
239    }
240}