Skip to main content

reliakit_primitives/
duration.rs

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