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        // Unit rank: higher = larger unit. Each unit may appear at most once,
28        // and units must be provided in strictly descending order (h > m > s > ms).
29        const RANK_H: u8 = 4;
30        const RANK_M: u8 = 3;
31        const RANK_S: u8 = 2;
32        const RANK_MS: u8 = 1;
33
34        let mut total_nanos: u128 = 0;
35        let mut last_rank: u8 = u8::MAX;
36        let mut found_any = false;
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            found_any = true;
96        }
97
98        if !found_any {
99            return Err(PrimitiveError::Invalid {
100                message: "no duration components found",
101            });
102        }
103
104        let secs = (total_nanos / 1_000_000_000) as u64;
105        let nanos = (total_nanos % 1_000_000_000) as u32;
106        Ok(Self(Duration::new(secs, nanos)))
107    }
108
109    /// Returns the underlying `core::time::Duration`.
110    pub fn as_duration(self) -> Duration {
111        self.0
112    }
113
114    /// Returns the total number of whole seconds.
115    pub fn as_secs(self) -> u64 {
116        self.0.as_secs()
117    }
118
119    /// Returns the total number of whole milliseconds.
120    pub fn as_millis(self) -> u128 {
121        self.0.as_millis()
122    }
123}
124
125fn parse_u64(s: &str) -> Option<u64> {
126    if s.is_empty() {
127        return None;
128    }
129    let mut result: u64 = 0;
130    for c in s.chars() {
131        let digit = c.to_digit(10)? as u64;
132        result = result.checked_mul(10)?.checked_add(digit)?;
133    }
134    Some(result)
135}
136
137impl fmt::Display for HumanDuration {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        let total_secs = self.0.as_secs();
140        let millis = self.0.subsec_millis();
141        let h = total_secs / 3600;
142        let m = (total_secs % 3600) / 60;
143        let s = total_secs % 60;
144
145        let mut wrote = false;
146        if h > 0 {
147            write!(f, "{h}h")?;
148            wrote = true;
149        }
150        if m > 0 {
151            write!(f, "{m}m")?;
152            wrote = true;
153        }
154        if s > 0 || millis > 0 {
155            if s > 0 {
156                write!(f, "{s}s")?;
157            }
158            if millis > 0 {
159                write!(f, "{millis}ms")?;
160            }
161            wrote = true;
162        }
163        if !wrote {
164            write!(f, "0s")?;
165        }
166        Ok(())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::HumanDuration;
173    use crate::PrimitiveError;
174    use alloc::string::ToString;
175
176    #[test]
177    fn parses_seconds() {
178        assert_eq!(HumanDuration::parse("45s").unwrap().as_secs(), 45);
179    }
180
181    #[test]
182    fn parses_minutes() {
183        assert_eq!(HumanDuration::parse("2m").unwrap().as_secs(), 120);
184    }
185
186    #[test]
187    fn parses_hours() {
188        assert_eq!(HumanDuration::parse("1h").unwrap().as_secs(), 3600);
189    }
190
191    #[test]
192    fn parses_milliseconds() {
193        assert_eq!(HumanDuration::parse("500ms").unwrap().as_millis(), 500);
194    }
195
196    #[test]
197    fn parses_combination() {
198        let d = HumanDuration::parse("1h30m45s").unwrap();
199        assert_eq!(d.as_secs(), 3600 + 1800 + 45);
200    }
201
202    #[test]
203    fn parses_minutes_and_seconds() {
204        assert_eq!(HumanDuration::parse("2m30s").unwrap().as_secs(), 150);
205    }
206
207    #[test]
208    fn rejects_empty() {
209        assert_eq!(HumanDuration::parse("").unwrap_err(), PrimitiveError::Empty);
210    }
211
212    #[test]
213    fn rejects_unknown_unit() {
214        assert!(HumanDuration::parse("5d").is_err());
215    }
216
217    #[test]
218    fn rejects_no_number() {
219        assert!(HumanDuration::parse("s").is_err());
220    }
221
222    #[test]
223    fn rejects_out_of_order_units() {
224        assert!(HumanDuration::parse("1s1h").is_err());
225    }
226
227    #[test]
228    fn rejects_duplicate_units() {
229        assert!(HumanDuration::parse("1h1h").is_err());
230    }
231
232    #[test]
233    fn rejects_ms_before_s() {
234        assert!(HumanDuration::parse("500ms30s").is_err());
235    }
236
237    #[test]
238    fn as_duration() {
239        let d = HumanDuration::parse("1s").unwrap();
240        assert_eq!(d.as_duration().as_secs(), 1);
241    }
242
243    #[test]
244    fn display_seconds() {
245        assert_eq!(HumanDuration::parse("45s").unwrap().to_string(), "45s");
246    }
247
248    #[test]
249    fn display_combined() {
250        assert_eq!(HumanDuration::parse("1h30m").unwrap().to_string(), "1h30m");
251    }
252
253    #[test]
254    fn display_zero() {
255        assert_eq!(HumanDuration::parse("0s").unwrap().to_string(), "0s");
256    }
257
258    #[test]
259    fn display_mixed_seconds_and_millis() {
260        assert_eq!(
261            HumanDuration::parse("1s500ms").unwrap().to_string(),
262            "1s500ms"
263        );
264    }
265
266    #[test]
267    fn display_millis_only() {
268        assert_eq!(HumanDuration::parse("500ms").unwrap().to_string(), "500ms");
269    }
270}