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