reliakit_primitives/
duration.rs1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, str::FromStr, time::Duration};
4
5#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct HumanDuration(Duration);
12
13impl HumanDuration {
14 pub fn parse(s: &str) -> PrimitiveResult<Self> {
24 if s.is_empty() {
25 return Err(PrimitiveError::Empty);
26 }
27
28 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 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 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 pub fn as_duration(self) -> Duration {
106 self.0
107 }
108
109 pub fn as_secs(self) -> u64 {
111 self.0.as_secs()
112 }
113
114 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 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}