1use std::f64;
14
15use crate::prelude::*;
16use chrono::Duration;
17
18fn parse_components(
21 s: &str,
22 allowed_units: &[char],
23 original_input: &str,
24) -> Result<Vec<(f64, char)>> {
25 let mut result = Vec::new();
26 let mut iter = s.chars().peekable();
27 while iter.peek().is_some() {
28 let mut num_str = String::new();
29 let mut has_decimal = false;
30
31 while let Some(&c) = iter.peek() {
33 if c.is_ascii_digit() || (c == '.' && !has_decimal) {
34 if c == '.' {
35 has_decimal = true;
36 }
37 num_str.push(iter.next().unwrap());
38 } else {
39 break;
40 }
41 }
42 if num_str.is_empty() {
43 client_bail!("Expected number in: {}", original_input);
44 }
45 let num = num_str
46 .parse::<f64>()
47 .map_err(|_| client_error!("Invalid number '{}' in: {}", num_str, original_input))?;
48 if let Some(&unit) = iter.peek() {
49 if allowed_units.contains(&unit) {
50 result.push((num, unit));
51 iter.next();
52 } else {
53 client_bail!("Invalid unit '{}' in: {}", unit, original_input);
54 }
55 } else {
56 client_bail!(
57 "Missing unit after number '{}' in: {}",
58 num_str,
59 original_input
60 );
61 }
62 }
63 Ok(result)
64}
65
66fn parse_iso8601_duration(s: &str, original_input: &str) -> Result<Duration> {
68 let (is_negative, s_after_sign) = if let Some(stripped) = s.strip_prefix('-') {
69 (true, stripped)
70 } else {
71 (false, s)
72 };
73
74 if !s_after_sign.starts_with('P') {
75 client_bail!("Duration must start with 'P' in: {}", original_input);
76 }
77 let s_after_p = &s_after_sign[1..];
78
79 let (date_part, time_part) = if let Some(pos) = s_after_p.find('T') {
80 (&s_after_p[..pos], Some(&s_after_p[pos + 1..]))
81 } else {
82 (s_after_p, None)
83 };
84
85 let date_components = parse_components(date_part, &['Y', 'M', 'W', 'D'], original_input)?;
87
88 let time_components = if let Some(time_str) = time_part {
90 let comps = parse_components(time_str, &['H', 'M', 'S'], original_input)?;
91 if comps.is_empty() {
92 client_bail!(
93 "Time part present but no time components in: {}",
94 original_input
95 );
96 }
97 comps
98 } else {
99 vec![]
100 };
101
102 if date_components.is_empty() && time_components.is_empty() {
103 client_bail!("No components in duration: {}", original_input);
104 }
105
106 let date_duration = date_components
108 .iter()
109 .fold(Duration::zero(), |acc, &(num, unit)| {
110 let days = match unit {
111 'Y' => num * 365.0,
112 'M' => num * 30.0,
113 'W' => num * 7.0,
114 'D' => num,
115 _ => unreachable!("Invalid date unit should be caught by prior validation"),
116 };
117 let microseconds = (days * 86_400_000_000.0) as i64;
118 acc + Duration::microseconds(microseconds)
119 });
120
121 let time_duration =
123 time_components
124 .iter()
125 .fold(Duration::zero(), |acc, &(num, unit)| match unit {
126 'H' => {
127 let nanoseconds = (num * 3_600_000_000_000.0).round() as i64;
128 acc + Duration::nanoseconds(nanoseconds)
129 }
130 'M' => {
131 let nanoseconds = (num * 60_000_000_000.0).round() as i64;
132 acc + Duration::nanoseconds(nanoseconds)
133 }
134 'S' => {
135 let nanoseconds = (num.fract() * 1_000_000_000.0).round() as i64;
136 acc + Duration::seconds(num as i64) + Duration::nanoseconds(nanoseconds)
137 }
138 _ => unreachable!("Invalid time unit should be caught by prior validation"),
139 });
140
141 let mut total = date_duration + time_duration;
142 if is_negative {
143 total = -total;
144 }
145
146 Ok(total)
147}
148
149fn parse_human_readable_duration(s: &str, original_input: &str) -> Result<Duration> {
151 let parts: Vec<&str> = s.split_whitespace().collect();
152 if parts.is_empty() || !parts.len().is_multiple_of(2) {
153 client_bail!(
154 "Invalid human-readable duration format in: {}",
155 original_input
156 );
157 }
158
159 let durations: Result<Vec<Duration>> = parts
160 .chunks(2)
161 .map(|chunk| {
162 let num: i64 = chunk[0].parse().map_err(|_| {
163 client_error!("Invalid number '{}' in: {}", chunk[0], original_input)
164 })?;
165
166 match chunk[1].to_lowercase().as_str() {
167 "day" | "days" => Ok(Duration::days(num)),
168 "hour" | "hours" => Ok(Duration::hours(num)),
169 "minute" | "minutes" => Ok(Duration::minutes(num)),
170 "second" | "seconds" => Ok(Duration::seconds(num)),
171 "millisecond" | "milliseconds" => Ok(Duration::milliseconds(num)),
172 "microsecond" | "microseconds" => Ok(Duration::microseconds(num)),
173 _ => client_bail!("Invalid unit '{}' in: {}", chunk[1], original_input),
174 }
175 })
176 .collect();
177
178 durations.map(|durs| durs.into_iter().sum())
179}
180
181pub fn parse_duration(s: &str) -> Result<Duration> {
183 let original_input = s;
184 let s = s.trim();
185 if s.is_empty() {
186 client_bail!("Empty duration string");
187 }
188
189 let is_likely_iso8601 = match s.as_bytes() {
190 [c, ..] if c.eq_ignore_ascii_case(&b'P') => true,
191 [b'-', c, ..] if c.eq_ignore_ascii_case(&b'P') => true,
192 _ => false,
193 };
194
195 if is_likely_iso8601 {
196 parse_iso8601_duration(s, original_input)
197 } else {
198 parse_human_readable_duration(s, original_input)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn check_ok(res: Result<Duration>, expected: Duration, input_str: &str) {
207 match res {
208 Ok(duration) => assert_eq!(duration, expected, "Input: '{input_str}'"),
209 Err(e) => panic!("Input: '{input_str}', expected Ok({expected:?}), but got Err: {e}"),
210 }
211 }
212
213 fn check_err_contains(res: Result<Duration>, expected_substring: &str, input_str: &str) {
214 match res {
215 Ok(d) => panic!(
216 "Input: '{input_str}', expected error containing '{expected_substring}', but got Ok({d:?})"
217 ),
218 Err(e) => {
219 let err_msg = e.to_string();
220 assert!(
221 err_msg.contains(expected_substring),
222 "Input: '{input_str}', error message '{err_msg}' does not contain expected substring '{expected_substring}'"
223 );
224 }
225 }
226 }
227
228 #[test]
229 fn test_empty_string() {
230 check_err_contains(parse_duration(""), "Empty duration string", "\"\"");
231 }
232
233 #[test]
234 fn test_whitespace_string() {
235 check_err_contains(parse_duration(" "), "Empty duration string", "\" \"");
236 }
237
238 #[test]
239 fn test_iso_just_p() {
240 check_err_contains(parse_duration("P"), "No components in duration: P", "\"P\"");
241 }
242
243 #[test]
244 fn test_iso_pt() {
245 check_err_contains(
246 parse_duration("PT"),
247 "Time part present but no time components in: PT",
248 "\"PT\"",
249 );
250 }
251
252 #[test]
253 fn test_iso_missing_number_before_unit_in_date_part() {
254 check_err_contains(parse_duration("PD"), "Expected number in: PD", "\"PD\"");
255 }
256 #[test]
257 fn test_iso_missing_number_before_unit_in_time_part() {
258 check_err_contains(parse_duration("PTM"), "Expected number in: PTM", "\"PTM\"");
259 }
260
261 #[test]
262 fn test_iso_time_unit_without_t() {
263 check_err_contains(parse_duration("P1H"), "Invalid unit 'H' in: P1H", "\"P1H\"");
264 check_err_contains(parse_duration("P1S"), "Invalid unit 'S' in: P1S", "\"P1S\"");
265 }
266
267 #[test]
268 fn test_iso_invalid_unit() {
269 check_err_contains(parse_duration("P1X"), "Invalid unit 'X' in: P1X", "\"P1X\"");
270 check_err_contains(
271 parse_duration("PT1X"),
272 "Invalid unit 'X' in: PT1X",
273 "\"PT1X\"",
274 );
275 }
276
277 #[test]
278 fn test_iso_valid_lowercase_unit_is_not_allowed() {
279 check_err_contains(
280 parse_duration("p1h"),
281 "Duration must start with 'P' in: p1h",
282 "\"p1h\"",
283 );
284 check_err_contains(
285 parse_duration("PT1h"),
286 "Invalid unit 'h' in: PT1h",
287 "\"PT1h\"",
288 );
289 }
290
291 #[test]
292 fn test_iso_trailing_number_error() {
293 check_err_contains(
294 parse_duration("P1D2"),
295 "Missing unit after number '2' in: P1D2",
296 "\"P1D2\"",
297 );
298 }
299
300 #[test]
301 fn test_iso_invalid_fractional_format() {
302 check_err_contains(
303 parse_duration("PT1..5S"),
304 "Invalid unit '.' in: PT1..5S",
305 "\"PT1..5S\"",
306 );
307 check_err_contains(
308 parse_duration("PT1.5.5S"),
309 "Invalid unit '.' in: PT1.5.5S",
310 "\"PT1.5.5S\"",
311 );
312 check_err_contains(
313 parse_duration("P1..5D"),
314 "Invalid unit '.' in: P1..5D",
315 "\"P1..5D\"",
316 );
317 }
318
319 #[test]
320 fn test_iso_misplaced_t() {
321 check_err_contains(
322 parse_duration("P1DT2H T3M"),
323 "Expected number in: P1DT2H T3M",
324 "\"P1DT2H T3M\"",
325 );
326 check_err_contains(
327 parse_duration("P1T2H"),
328 "Missing unit after number '1' in: P1T2H",
329 "\"P1T2H\"",
330 );
331 }
332
333 #[test]
334 fn test_iso_negative_number_after_p() {
335 check_err_contains(
336 parse_duration("P-1D"),
337 "Expected number in: P-1D",
338 "\"P-1D\"",
339 );
340 }
341
342 #[test]
343 fn test_iso_valid_months() {
344 check_ok(parse_duration("P1M"), Duration::days(30), "\"P1M\"");
345 check_ok(parse_duration(" P13M"), Duration::days(13 * 30), "\"P13M\"");
346 }
347
348 #[test]
349 fn test_iso_valid_weeks() {
350 check_ok(parse_duration("P1W"), Duration::days(7), "\"P1W\"");
351 check_ok(parse_duration(" P1W "), Duration::days(7), "\"P1W\"");
352 }
353
354 #[test]
355 fn test_iso_valid_days() {
356 check_ok(parse_duration("P1D"), Duration::days(1), "\"P1D\"");
357 }
358
359 #[test]
360 fn test_iso_valid_hours() {
361 check_ok(parse_duration("PT2H"), Duration::hours(2), "\"PT2H\"");
362 }
363
364 #[test]
365 fn test_iso_valid_minutes() {
366 check_ok(parse_duration("PT3M"), Duration::minutes(3), "\"PT3M\"");
367 }
368
369 #[test]
370 fn test_iso_valid_seconds() {
371 check_ok(parse_duration("PT4S"), Duration::seconds(4), "\"PT4S\"");
372 }
373
374 #[test]
375 fn test_iso_combined_units() {
376 check_ok(
377 parse_duration("P1Y2M3W4DT5H6M7S"),
378 Duration::days(365 + 60 + 3 * 7 + 4)
379 + Duration::hours(5)
380 + Duration::minutes(6)
381 + Duration::seconds(7),
382 "\"P1Y2M3DT4H5M6S\"",
383 );
384 check_ok(
385 parse_duration("P1DT2H3M4S"),
386 Duration::days(1) + Duration::hours(2) + Duration::minutes(3) + Duration::seconds(4),
387 "\"P1DT2H3M4S\"",
388 );
389 }
390
391 #[test]
392 fn test_iso_duplicated_unit() {
393 check_ok(parse_duration("P1D1D"), Duration::days(2), "\"P1D1D\"");
394 check_ok(parse_duration("PT1H1H"), Duration::hours(2), "\"PT1H1H\"");
395 }
396
397 #[test]
398 fn test_iso_out_of_order_unit() {
399 check_ok(
400 parse_duration("P1W1Y"),
401 Duration::days(365 + 7),
402 "\"P1W1Y\"",
403 );
404 check_ok(
405 parse_duration("PT2S1H"),
406 Duration::hours(1) + Duration::seconds(2),
407 "\"PT2S1H\"",
408 );
409 check_ok(parse_duration("P3M"), Duration::days(90), "\"PT2S1H\"");
410 check_ok(parse_duration("PT3M"), Duration::minutes(3), "\"PT2S1H\"");
411 check_err_contains(
412 parse_duration("P1H2D"),
413 "Invalid unit 'H' in: P1H2D", "\"P1H2D\"",
415 );
416 }
417
418 #[test]
419 fn test_iso_negative_duration_p1d() {
420 check_ok(parse_duration("-P1D"), -Duration::days(1), "\"-P1D\"");
421 }
422
423 #[test]
424 fn test_iso_zero_duration_pd0() {
425 check_ok(parse_duration("P0D"), Duration::zero(), "\"P0D\"");
426 }
427
428 #[test]
429 fn test_iso_zero_duration_pt0s() {
430 check_ok(parse_duration("PT0S"), Duration::zero(), "\"PT0S\"");
431 }
432
433 #[test]
434 fn test_iso_zero_duration_pt0h0m0s() {
435 check_ok(parse_duration("PT0H0M0S"), Duration::zero(), "\"PT0H0M0S\"");
436 }
437
438 #[test]
439 fn test_iso_fractional_seconds() {
440 check_ok(
441 parse_duration("PT1.5S"),
442 Duration::seconds(1) + Duration::milliseconds(500),
443 "\"PT1.5S\"",
444 );
445 check_ok(
446 parse_duration("PT441010.456123S"),
447 Duration::seconds(441010) + Duration::microseconds(456123),
448 "\"PT441010.456123S\"",
449 );
450 check_ok(
451 parse_duration("PT0.000001S"),
452 Duration::microseconds(1),
453 "\"PT0.000001S\"",
454 );
455 }
456
457 #[test]
458 fn test_iso_fractional_date_units() {
459 check_ok(
460 parse_duration("P1.5D"),
461 Duration::microseconds((1.5 * 86_400_000_000.0) as i64),
462 "\"P1.5D\"",
463 );
464 check_ok(
465 parse_duration("P1.25Y"),
466 Duration::microseconds((1.25 * 365.0 * 86_400_000_000.0) as i64),
467 "\"P1.25Y\"",
468 );
469 check_ok(
470 parse_duration("P2.75M"),
471 Duration::microseconds((2.75 * 30.0 * 86_400_000_000.0) as i64),
472 "\"P2.75M\"",
473 );
474 check_ok(
475 parse_duration("P0.5W"),
476 Duration::microseconds((0.5 * 7.0 * 86_400_000_000.0) as i64),
477 "\"P0.5W\"",
478 );
479 }
480
481 #[test]
482 fn test_iso_negative_fractional_date_units() {
483 check_ok(
484 parse_duration("-P1.5D"),
485 -Duration::microseconds((1.5 * 86_400_000_000.0) as i64),
486 "\"-P1.5D\"",
487 );
488 check_ok(
489 parse_duration("-P0.25Y"),
490 -Duration::microseconds((0.25 * 365.0 * 86_400_000_000.0) as i64),
491 "\"-P0.25Y\"",
492 );
493 }
494
495 #[test]
496 fn test_iso_combined_fractional_units() {
497 check_ok(
498 parse_duration("P1.5DT2.5H3.5M4.5S"),
499 Duration::microseconds((1.5 * 86_400_000_000.0) as i64)
500 + Duration::microseconds((2.5 * 3_600_000_000.0) as i64)
501 + Duration::microseconds((3.5 * 60_000_000.0) as i64)
502 + Duration::seconds(4)
503 + Duration::milliseconds(500),
504 "\"1.5DT2.5H3.5M4.5S\"",
505 );
506 }
507
508 #[test]
509 fn test_iso_multiple_fractional_time_units() {
510 check_ok(
511 parse_duration("PT1.5S2.5S"),
512 Duration::seconds(1 + 2) + Duration::milliseconds(500) + Duration::milliseconds(500),
513 "\"PT1.5S2.5S\"",
514 );
515 check_ok(
516 parse_duration("PT1.1H2.2M3.3S"),
517 Duration::hours(1)
518 + Duration::seconds((0.1 * 3600.0) as i64)
519 + Duration::minutes(2)
520 + Duration::seconds((0.2 * 60.0) as i64)
521 + Duration::seconds(3)
522 + Duration::milliseconds(300),
523 "\"PT1.1H2.2M3.3S\"",
524 );
525 }
526
527 #[test]
529 fn test_human_missing_unit() {
530 check_err_contains(
531 parse_duration("1"),
532 "Invalid human-readable duration format in: 1",
533 "\"1\"",
534 );
535 }
536
537 #[test]
538 fn test_human_missing_number() {
539 check_err_contains(
540 parse_duration("day"),
541 "Invalid human-readable duration format in: day",
542 "\"day\"",
543 );
544 }
545
546 #[test]
547 fn test_human_incomplete_pair() {
548 check_err_contains(
549 parse_duration("1 day 2"),
550 "Invalid human-readable duration format in: 1 day 2",
551 "\"1 day 2\"",
552 );
553 }
554
555 #[test]
556 fn test_human_invalid_number_at_start() {
557 check_err_contains(
558 parse_duration("one day"),
559 "Invalid number 'one' in: one day",
560 "\"one day\"",
561 );
562 }
563
564 #[test]
565 fn test_human_invalid_unit() {
566 check_err_contains(
567 parse_duration("1 hour 2 minutes 3 seconds four seconds"),
568 "Invalid number 'four' in: 1 hour 2 minutes 3 seconds four seconds",
569 "\"1 hour 2 minutes 3 seconds four seconds\"",
570 );
571 }
572
573 #[test]
574 fn test_human_float_number_fail() {
575 check_err_contains(
576 parse_duration("1.5 hours"),
577 "Invalid number '1.5' in: 1.5 hours",
578 "\"1.5 hours\"",
579 );
580 }
581
582 #[test]
583 fn test_invalid_human_readable_no_pairs() {
584 check_err_contains(
585 parse_duration("just some words"),
586 "Invalid human-readable duration format in: just some words",
587 "\"just some words\"",
588 );
589 }
590
591 #[test]
592 fn test_human_unknown_unit() {
593 check_err_contains(
594 parse_duration("1 year"),
595 "Invalid unit 'year' in: 1 year",
596 "\"1 year\"",
597 );
598 }
599
600 #[test]
601 fn test_human_valid_day() {
602 check_ok(parse_duration("1 day"), Duration::days(1), "\"1 day\"");
603 }
604
605 #[test]
606 fn test_human_valid_days_uppercase() {
607 check_ok(parse_duration("2 DAYS"), Duration::days(2), "\"2 DAYS\"");
608 }
609
610 #[test]
611 fn test_human_valid_hour() {
612 check_ok(parse_duration("3 hour"), Duration::hours(3), "\"3 hour\"");
613 }
614
615 #[test]
616 fn test_human_valid_hours_mixedcase() {
617 check_ok(parse_duration("4 HoUrS"), Duration::hours(4), "\"4 HoUrS\"");
618 }
619
620 #[test]
621 fn test_human_valid_minute() {
622 check_ok(
623 parse_duration("5 minute"),
624 Duration::minutes(5),
625 "\"5 minute\"",
626 );
627 }
628
629 #[test]
630 fn test_human_valid_minutes() {
631 check_ok(
632 parse_duration("6 minutes"),
633 Duration::minutes(6),
634 "\"6 minutes\"",
635 );
636 }
637
638 #[test]
639 fn test_human_valid_second() {
640 check_ok(
641 parse_duration("7 second"),
642 Duration::seconds(7),
643 "\"7 second\"",
644 );
645 }
646
647 #[test]
648 fn test_human_valid_seconds() {
649 check_ok(
650 parse_duration("8 seconds"),
651 Duration::seconds(8),
652 "\"8 seconds\"",
653 );
654 }
655
656 #[test]
657 fn test_human_valid_millisecond() {
658 check_ok(
659 parse_duration("9 millisecond"),
660 Duration::milliseconds(9),
661 "\"9 millisecond\"",
662 );
663 }
664
665 #[test]
666 fn test_human_valid_milliseconds() {
667 check_ok(
668 parse_duration("10 milliseconds"),
669 Duration::milliseconds(10),
670 "\"10 milliseconds\"",
671 );
672 }
673
674 #[test]
675 fn test_human_valid_microsecond() {
676 check_ok(
677 parse_duration("11 microsecond"),
678 Duration::microseconds(11),
679 "\"11 microsecond\"",
680 );
681 }
682
683 #[test]
684 fn test_human_valid_microseconds() {
685 check_ok(
686 parse_duration("12 microseconds"),
687 Duration::microseconds(12),
688 "\"12 microseconds\"",
689 );
690 }
691
692 #[test]
693 fn test_human_combined() {
694 let expected =
695 Duration::days(1) + Duration::hours(2) + Duration::minutes(3) + Duration::seconds(4);
696 check_ok(
697 parse_duration("1 day 2 hours 3 minutes 4 seconds"),
698 expected,
699 "\"1 day 2 hours 3 minutes 4 seconds\"",
700 );
701 }
702
703 #[test]
704 fn test_human_out_of_order() {
705 check_ok(
706 parse_duration("1 second 2 hours"),
707 Duration::hours(2) + Duration::seconds(1),
708 "\"1 second 2 hours\"",
709 );
710 check_ok(
711 parse_duration("7 minutes 6 hours 5 days"),
712 Duration::days(5) + Duration::hours(6) + Duration::minutes(7),
713 "\"7 minutes 6 hours 5 days\"",
714 )
715 }
716
717 #[test]
718 fn test_human_zero_duration_seconds() {
719 check_ok(
720 parse_duration("0 seconds"),
721 Duration::zero(),
722 "\"0 seconds\"",
723 );
724 }
725
726 #[test]
727 fn test_human_zero_duration_days_hours() {
728 check_ok(
729 parse_duration("0 day 0 hour"),
730 Duration::zero(),
731 "\"0 day 0 hour\"",
732 );
733 }
734
735 #[test]
736 fn test_human_zero_duration_multiple_zeros() {
737 check_ok(
738 parse_duration("0 days 0 hours 0 minutes 0 seconds"),
739 Duration::zero(),
740 "\"0 days 0 hours 0 minutes 0 seconds\"",
741 );
742 }
743
744 #[test]
745 fn test_human_no_space_between_num_unit() {
746 check_err_contains(
747 parse_duration("1day"),
748 "Invalid human-readable duration format in: 1day",
749 "\"1day\"",
750 );
751 }
752
753 #[test]
754 fn test_human_trimmed() {
755 check_ok(parse_duration(" 1 day "), Duration::days(1), "\" 1 day \"");
756 }
757
758 #[test]
759 fn test_human_extra_whitespace() {
760 check_ok(
761 parse_duration(" 1 day 2 hours "),
762 Duration::days(1) + Duration::hours(2),
763 "\" 1 day 2 hours \"",
764 );
765 }
766
767 #[test]
768 fn test_human_negative_numbers() {
769 check_ok(
770 parse_duration("-1 day 2 hours"),
771 Duration::days(-1) + Duration::hours(2),
772 "\"-1 day 2 hours\"",
773 );
774 check_ok(
775 parse_duration("1 day -2 hours"),
776 Duration::days(1) + Duration::hours(-2),
777 "\"1 day -2 hours\"",
778 );
779 }
780}