1const MILLISECOND: f64 = 1.0;
20const SECOND: f64 = 1_000.0;
21const MINUTE: f64 = SECOND * 60.0;
22const HOUR: f64 = MINUTE * 60.0;
23const DAY: f64 = HOUR * 24.0;
24const WEEK: f64 = DAY * 7.0;
25const MONTH: f64 = DAY * 30.0;
26const YEAR: f64 = DAY * 365.25;
27
28pub fn parse(input: &str) -> Result<f64, ParseError> {
49 if input.is_empty() {
55 return Err(ParseError::EmptyInput);
56 }
57
58 let input = input.trim();
59
60 if input.len() > 100 {
61 return Err(ParseError::TooLong);
62 }
63
64 let split = input
66 .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
67 .ok_or(ParseError::InvalidFormat)?;
68
69 let (num_part, unit_part) = input.split_at(split);
70 let num: f64 = num_part.parse().map_err(|_| ParseError::InvalidFormat)?;
71 let unit = unit_part.trim();
72
73 let multiplier = match unit {
74 "ms" | "millisecond" | "milliseconds" => MILLISECOND,
75 "s"| "sec" | "second" | "seconds" => SECOND,
76 "m" | "min" | "minute" | "minutes" => MINUTE,
77 "h" | "hr" | "hour" | "hours" => HOUR,
78 "d" | "day" | "days" => DAY,
79 "w" | "week" | "weeks" => WEEK,
80 "mo" | "month" | "months" => MONTH,
81 "y" | "year" | "years" => YEAR,
82 _ => return Err(ParseError::UnknownUnit(unit.to_string()))
83 };
84
85 Ok(num * multiplier)
86}
87
88pub fn format(ms: f64, long: bool) -> String {
103 let abs = ms.abs();
104
105 let (val, short, singular, plural) = if abs >= YEAR {
106 (ms / YEAR, "y", "year", "years")
107 } else if abs >= MONTH {
108 (ms / MONTH, "mo", "month", "months")
109 } else if abs >= WEEK {
110 (ms / WEEK, "w", "week", "weeks")
111 } else if abs >= DAY {
112 (ms / DAY, "d", "day", "days")
113 } else if abs >= HOUR {
114 (ms / HOUR, "h", "hour", "hours")
115 } else if abs >= MINUTE {
116 (ms / MINUTE, "m", "minute", "minutes")
117 } else if abs >= SECOND {
118 (ms / SECOND, "s", "second", "seconds")
119 } else {
120 (ms, "ms", "millisecond", "milliseconds")
121 };
122
123 let rounded = val.round() as i64;
124
125 if long {
126 let unit = if rounded.abs() == 1 { singular } else { plural };
127 format!("{rounded} {unit}")
128 } else {
129 format!("{rounded}{short}")
130 }
131}
132
133#[derive(Debug, PartialEq)]
135pub enum ParseError {
136 EmptyInput,
137 TooLong,
138 InvalidFormat,
139 UnknownUnit(String),
140}
141
142impl std::fmt::Display for ParseError {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 match self {
145 ParseError::EmptyInput => write!(f, "input must not be empty"),
146 ParseError::TooLong => write!(f, "input exceeds 100 characters"),
147 ParseError::InvalidFormat => write!(f, "invalid time format"),
148 ParseError::UnknownUnit(u) => write!(f, "unknown unit: {u}"),
149 }
150 }
151}
152
153impl std::error::Error for ParseError {}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
162 fn parse_milliseconds() {
163 assert_eq!(parse("100ms").unwrap(), 100.0);
164 }
165
166 #[test]
167 fn parse_seconds() {
168 assert_eq!(parse("1s").unwrap(), 1_000.0);
169 }
170
171 #[test]
172 fn parse_minutes() {
173 assert_eq!(parse("1m").unwrap(), 60_000.0);
174 }
175
176 #[test]
177 fn parse_hours() {
178 assert_eq!(parse("1h").unwrap(), 3_600_000.0);
179 }
180
181 #[test]
182 fn parse_days() {
183 assert_eq!(parse("2d").unwrap(), 172_800_000.0);
184 }
185
186 #[test]
187 fn parse_weeks() {
188 assert_eq!(parse("3w").unwrap(), 1_814_400_000.0);
189 }
190
191 #[test]
192 fn parse_months() {
193 assert_eq!(parse("1mo").unwrap(), 2_592_000_000.0);
194 }
195
196 #[test]
197 fn parse_years() {
198 assert_eq!(parse("1y").unwrap(), 31_557_600_000.0);
199 }
200
201 #[test]
204 fn parse_long_milliseconds() {
205 assert_eq!(parse("1 millisecond").unwrap(), 1.0);
206 assert_eq!(parse("53 milliseconds").unwrap(), 53.0);
207 }
208
209 #[test]
210 fn parse_long_seconds() {
211 assert_eq!(parse("1 sec").unwrap(), 1_000.0);
212 assert_eq!(parse("1 second").unwrap(), 1_000.0);
213 assert_eq!(parse("2 seconds").unwrap(), 2_000.0);
214 }
215
216 #[test]
217 fn parse_long_minutes() {
218 assert_eq!(parse("1 min").unwrap(), 60_000.0);
219 assert_eq!(parse("1 minute").unwrap(), 60_000.0);
220 assert_eq!(parse("2 minutes").unwrap(), 120_000.0);
221 }
222
223 #[test]
224 fn parse_long_hours() {
225 assert_eq!(parse("1 hr").unwrap(), 3_600_000.0);
226 assert_eq!(parse("1 hour").unwrap(), 3_600_000.0);
227 assert_eq!(parse("2 hours").unwrap(), 7_200_000.0);
228 }
229
230 #[test]
231 fn parse_long_days() {
232 assert_eq!(parse("1 day").unwrap(), 86_400_000.0);
233 assert_eq!(parse("2 days").unwrap(), 172_800_000.0);
234 }
235
236 #[test]
237 fn parse_long_weeks() {
238 assert_eq!(parse("1 week").unwrap(), 604_800_000.0);
239 assert_eq!(parse("2 weeks").unwrap(), 1_209_600_000.0);
240 }
241
242 #[test]
243 fn parse_long_months() {
244 assert_eq!(parse("1 month").unwrap(), 2_592_000_000.0);
245 assert_eq!(parse("2 months").unwrap(), 5_184_000_000.0);
246 }
247
248 #[test]
249 fn parse_long_years() {
250 assert_eq!(parse("1 year").unwrap(), 31_557_600_000.0);
251 assert_eq!(parse("2 years").unwrap(), 63_115_200_000.0);
252 }
253
254 #[test]
257 fn parse_zero() {
258 assert_eq!(parse("0ms").unwrap(), 0.0);
259 }
260
261 #[test]
264 fn parse_decimal() {
265 assert_eq!(parse("1.5h").unwrap(), 5_400_000.0);
266 }
267
268 #[test]
269 fn parse_leading_dot() {
270 assert_eq!(parse(".5ms").unwrap(), 0.5);
271 }
272
273 #[test]
274 fn parse_negative() {
275 assert_eq!(parse("-3 days").unwrap(), -259_200_000.0);
276 assert_eq!(parse("-100ms").unwrap(), -100.0);
277 assert_eq!(parse("-1.5h").unwrap(), -5_400_000.0);
278 }
279
280 #[test]
281 fn parse_negative_leading_dot() {
282 assert_eq!(parse("-.5h").unwrap(), -1_800_000.0);
283 }
284
285 #[test]
288 fn parse_extra_whitespace() {
289 assert_eq!(parse("1 s").unwrap(), 1_000.0);
290 }
291
292 #[test]
295 fn parse_leading_trailing_whitespace() {
296 assert_eq!(parse(" 1s ").unwrap(), 1_000.0);
297 }
298
299 #[test]
302 fn parse_empty() {
303 assert_eq!(parse("").unwrap_err(), ParseError::EmptyInput);
304 }
305
306 #[test]
307 fn parse_whitespace_only() {
308 assert_eq!(parse(" ").unwrap_err(), ParseError::InvalidFormat);
309 }
310
311 #[test]
312 fn parse_number_only() {
313 assert_eq!(parse("100").unwrap_err(), ParseError::InvalidFormat);
314 }
315
316 #[test]
317 fn parse_no_unit() {
318 assert_eq!(parse("abc").unwrap_err(), ParseError::InvalidFormat);
319 }
320
321 #[test]
322 fn parse_unknown_unit() {
323 assert_eq!(
324 parse("1xyz").unwrap_err(),
325 ParseError::UnknownUnit("xyz".to_string())
326 );
327 }
328
329 #[test]
330 fn parse_boundary_100_chars() {
331 let input = format!("1{}", " ".repeat(97) + "s");
332 assert_eq!(parse(&input).unwrap(), 1_000.0);
333 }
334
335 #[test]
336 fn parse_too_long() {
337 let long_input = format!("1{}", "a".repeat(101));
338 assert_eq!(parse(&long_input).unwrap_err(), ParseError::TooLong);
339 }
340
341 #[test]
344 fn format_short_ms() {
345 assert_eq!(format(500.0, false), "500ms");
346 }
347
348 #[test]
349 fn format_short_seconds() {
350 assert_eq!(format(1_000.0, false), "1s");
351 }
352
353 #[test]
354 fn format_short_minutes() {
355 assert_eq!(format(60_000.0, false), "1m");
356 }
357
358 #[test]
359 fn format_short_hours() {
360 assert_eq!(format(3_600_000.0, false), "1h");
361 }
362
363 #[test]
364 fn format_short_days() {
365 assert_eq!(format(86_400_000.0, false), "1d");
366 }
367
368 #[test]
369 fn format_short_weeks() {
370 assert_eq!(format(604_800_000.0, false), "1w");
371 }
372
373 #[test]
374 fn format_short_months() {
375 assert_eq!(format(2_628_000_000.0, false), "1mo");
376 }
377
378 #[test]
379 fn format_short_years() {
380 assert_eq!(format(31_557_600_000.0, false), "1y");
381 }
382
383 #[test]
384 fn format_short_rounding() {
385 assert_eq!(format(234_234_234.0, false), "3d");
386 }
387
388 #[test]
391 fn format_long_ms() {
392 assert_eq!(format(500.0, true), "500 milliseconds");
393 }
394
395 #[test]
396 fn format_long_negative_ms() {
397 assert_eq!(format(-500.0, true), "-500 milliseconds");
398 }
399
400 #[test]
401 fn format_long_second() {
402 assert_eq!(format(1_000.0, true), "1 second");
403 }
404
405 #[test]
406 fn format_long_seconds() {
407 assert_eq!(format(10_000.0, true), "10 seconds");
408 }
409
410 #[test]
411 fn format_long_minute() {
412 assert_eq!(format(60_000.0, true), "1 minute");
413 }
414
415 #[test]
416 fn format_long_minutes() {
417 assert_eq!(format(120_000.0, true), "2 minutes");
418 assert_eq!(format(600_000.0, true), "10 minutes");
419 }
420
421 #[test]
422 fn format_long_hour() {
423 assert_eq!(format(3_600_000.0, true), "1 hour");
424 }
425
426 #[test]
427 fn format_long_hours() {
428 assert_eq!(format(36_000_000.0, true), "10 hours");
429 }
430
431 #[test]
432 fn format_long_day() {
433 assert_eq!(format(86_400_000.0, true), "1 day");
434 }
435
436 #[test]
437 fn format_long_week() {
438 assert_eq!(format(604_800_000.0, true), "1 week");
439 }
440
441 #[test]
442 fn format_long_month() {
443 assert_eq!(format(2_628_000_000.0, true), "1 month");
444 }
445
446 #[test]
447 fn format_long_year() {
448 assert_eq!(format(31_557_600_000.0, true), "1 year");
449 }
450
451 #[test]
452 fn format_long_rounding() {
453 assert_eq!(format(234_234_234.0, true), "3 days");
454 }
455}