1use std::time::Duration;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ParseDurationError {
15 pub input: String,
17 pub message: String,
19}
20
21impl std::fmt::Display for ParseDurationError {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 write!(f, "invalid duration '{}': {}", self.input, self.message)
24 }
25}
26
27impl std::error::Error for ParseDurationError {}
28
29pub fn parse_duration(s: &str) -> Result<Duration, ParseDurationError> {
52 let s = s.trim();
53 if s.is_empty() {
54 return Err(ParseDurationError {
55 input: s.to_string(),
56 message: "empty string".to_string(),
57 });
58 }
59
60 let mut total_millis: u64 = 0;
61 let mut current_num = String::new();
62 let mut chars = s.chars().peekable();
63
64 while let Some(c) = chars.next() {
65 if c.is_ascii_digit() {
66 current_num.push(c);
67 } else if c.is_ascii_alphabetic() {
68 if current_num.is_empty() {
69 return Err(ParseDurationError {
70 input: s.to_string(),
71 message: format!("unexpected unit character '{c}' without preceding number"),
72 });
73 }
74
75 let num: u64 = current_num.parse().map_err(|_| ParseDurationError {
76 input: s.to_string(),
77 message: format!("invalid number: {current_num}"),
78 })?;
79
80 let unit = if c == 'm' && chars.peek() == Some(&'s') {
82 chars.next(); "ms"
84 } else {
85 match c {
87 'h' => "h",
88 'm' => "m",
89 's' => "s",
90 _ => {
91 return Err(ParseDurationError {
92 input: s.to_string(),
93 message: format!("unknown unit '{c}'"),
94 });
95 }
96 }
97 };
98
99 let millis = match unit {
100 "ms" => num,
101 "s" => num * 1000,
102 "m" => num * 60 * 1000,
103 "h" => num * 60 * 60 * 1000,
104 _ => unreachable!(),
105 };
106
107 total_millis = total_millis.saturating_add(millis);
108 current_num.clear();
109 } else if c.is_whitespace() {
110 continue;
112 } else {
113 return Err(ParseDurationError {
114 input: s.to_string(),
115 message: format!("unexpected character '{c}'"),
116 });
117 }
118 }
119
120 if !current_num.is_empty() {
122 return Err(ParseDurationError {
123 input: s.to_string(),
124 message: format!("number '{current_num}' missing unit (use s, m, h, or ms)"),
125 });
126 }
127
128 if total_millis == 0 {
129 return Err(ParseDurationError {
130 input: s.to_string(),
131 message: "duration must be greater than zero".to_string(),
132 });
133 }
134
135 Ok(Duration::from_millis(total_millis))
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn test_parse_seconds() {
144 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
145 assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
146 assert_eq!(parse_duration("120s").unwrap(), Duration::from_secs(120));
147 }
148
149 #[test]
150 fn test_parse_minutes() {
151 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
152 assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
153 assert_eq!(parse_duration("90m").unwrap(), Duration::from_secs(5400));
154 }
155
156 #[test]
157 fn test_parse_hours() {
158 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
159 assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
160 assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86400));
161 }
162
163 #[test]
164 #[allow(clippy::duration_suboptimal_units)]
165 fn test_parse_milliseconds() {
166 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
167 assert_eq!(
168 parse_duration("1000ms").unwrap(),
169 Duration::from_millis(1000)
170 );
171 assert_eq!(parse_duration("1ms").unwrap(), Duration::from_millis(1));
172 }
173
174 #[test]
175 fn test_parse_combined() {
176 assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400));
177 assert_eq!(parse_duration("2m30s").unwrap(), Duration::from_secs(150));
178 assert_eq!(
179 parse_duration("1h30m45s").unwrap(),
180 Duration::from_secs(5445)
181 );
182 assert_eq!(
183 parse_duration("1m500ms").unwrap(),
184 Duration::from_millis(60500)
185 );
186 }
187
188 #[test]
189 fn test_parse_with_whitespace() {
190 assert_eq!(parse_duration(" 30s ").unwrap(), Duration::from_secs(30));
191 assert_eq!(parse_duration("1h 30m").unwrap(), Duration::from_secs(5400));
192 }
193
194 #[test]
195 fn test_parse_errors() {
196 assert!(parse_duration("").is_err());
197 assert!(parse_duration("abc").is_err());
198 assert!(parse_duration("30").is_err()); assert!(parse_duration("30x").is_err()); assert!(parse_duration("0s").is_err()); }
202
203 #[test]
208 fn error_display_format() {
209 let err = parse_duration("").unwrap_err();
210 let msg = err.to_string();
211 assert!(msg.contains("invalid duration"));
212 assert!(msg.contains("empty string"));
213 }
214
215 #[test]
216 fn error_is_std_error() {
217 let err = parse_duration("bad").unwrap_err();
218 let _: &dyn std::error::Error = &err;
220 }
221
222 #[test]
223 fn error_debug_clone_eq() {
224 let err = parse_duration("30").unwrap_err();
225 let debug = format!("{err:?}");
226 assert!(debug.contains("ParseDurationError"));
227
228 let cloned = err.clone();
229 assert_eq!(err, cloned);
230 }
231
232 #[test]
233 fn error_unit_without_number() {
234 let err = parse_duration("s").unwrap_err();
235 assert!(err.message.contains("without preceding number"));
236 }
237
238 #[test]
239 fn error_unknown_unit() {
240 let err = parse_duration("30x").unwrap_err();
241 assert!(err.message.contains("unknown unit"));
242 }
243
244 #[test]
245 fn error_unexpected_character() {
246 let err = parse_duration("30s$").unwrap_err();
247 assert!(err.message.contains("unexpected character"));
248 }
249
250 #[test]
251 fn error_missing_unit() {
252 let err = parse_duration("42").unwrap_err();
253 assert!(err.message.contains("missing unit"));
254 }
255
256 #[test]
257 fn error_zero_duration() {
258 let err = parse_duration("0s").unwrap_err();
259 assert!(err.message.contains("greater than zero"));
260 }
261
262 #[test]
263 fn whitespace_between_components() {
264 assert_eq!(
265 parse_duration("2h 30m 15s").unwrap(),
266 Duration::from_secs(2 * 3600 + 30 * 60 + 15)
267 );
268 }
269
270 #[test]
275 fn saturating_add_overflow() {
276 let result = parse_duration(&format!("{}ms {}ms", u64::MAX, 1));
278 assert!(result.is_ok());
279 assert_eq!(result.unwrap(), Duration::from_millis(u64::MAX));
280 }
281
282 #[test]
283 fn error_fields_accessible() {
284 let err = parse_duration("42").unwrap_err();
285 assert_eq!(err.input, "42");
286 assert!(err.message.contains("missing unit"));
287 }
288
289 #[test]
290 fn only_whitespace_input() {
291 let err = parse_duration(" ").unwrap_err();
292 assert!(err.message.contains("empty string"));
293 }
294
295 #[test]
296 fn combined_with_ms() {
297 assert_eq!(
298 parse_duration("1h 30m 45s 500ms").unwrap(),
299 Duration::from_millis(3_600_000 + 30 * 60_000 + 45_000 + 500)
300 );
301 }
302}