Skip to main content

recoco_core/base/
duration.rs

1// ReCoco is a Rust-only fork of CocoIndex, by [CocoIndex](https://CocoIndex)
2// Original code from CocoIndex is copyrighted by CocoIndex
3// SPDX-FileCopyrightText: 2025-2026 CocoIndex (upstream)
4// SPDX-FileContributor: CocoIndex Contributors
5//
6// All modifications from the upstream for ReCoco are copyrighted by Knitli Inc.
7// SPDX-FileCopyrightText: 2026 Knitli Inc. (ReCoco)
8// SPDX-FileContributor: Adam Poulemanos <adam@knit.li>
9//
10// Both the upstream CocoIndex code and the ReCoco modifications are licensed under the Apache-2.0 License.
11// SPDX-License-Identifier: Apache-2.0
12
13use std::f64;
14
15use crate::prelude::*;
16use chrono::Duration;
17
18/// Parses a string of number-unit pairs into a vector of (number, unit),
19/// ensuring units are among the allowed ones.
20fn 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        // Parse digits and optional decimal point
32        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
66/// Parses an ISO 8601 duration string into a `chrono::Duration`.
67fn 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    // Date components (Y, M, W, D)
86    let date_components = parse_components(date_part, &['Y', 'M', 'W', 'D'], original_input)?;
87
88    // Time components (H, M, S)
89    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    // Accumulate date duration
107    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    // Accumulate time duration
122    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
149/// Parses a human-readable duration string into a `chrono::Duration`.
150fn 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
181/// Parses a duration string into a `chrono::Duration`, trying ISO 8601 first, then human-readable format.
182pub 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", // Time part without 'T' is invalid
414            "\"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    // Human-readable Tests
528    #[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}