parse_datetime/
lib.rs

1// For the full copyright and license information, please view the LICENSE
2// file that was distributed with this source code.
3//! A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`.
4//! The function supports the following formats for time:
5//!
6//! * ISO formats
7//! * timezone offsets, e.g., "UTC-0100"
8//! * unix timestamps, e.g., "@12"
9//! * relative time to now, e.g. "+1 hour"
10//!
11use std::error::Error;
12use std::fmt::{self, Display};
13
14use jiff::Zoned;
15
16mod items;
17
18#[derive(Debug, PartialEq)]
19pub enum ParseDateTimeError {
20    InvalidInput,
21}
22
23impl Display for ParseDateTimeError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            ParseDateTimeError::InvalidInput => {
27                write!(
28                    f,
29                    "Invalid input string: cannot be parsed as a relative time"
30                )
31            }
32        }
33    }
34}
35
36impl Error for ParseDateTimeError {}
37
38impl From<items::error::Error> for ParseDateTimeError {
39    fn from(_: items::error::Error) -> Self {
40        ParseDateTimeError::InvalidInput
41    }
42}
43
44/// Parses a time string and returns a `Zoned` object representing the absolute
45/// time of the string.
46///
47/// # Arguments
48///
49/// * `input` - A string slice representing the time.
50///
51/// # Examples
52///
53/// ```
54/// use jiff::Zoned;
55/// use parse_datetime::parse_datetime;
56///
57/// let time = parse_datetime("2023-06-03 12:00:01Z").unwrap();
58/// assert_eq!(time.strftime("%F %T").to_string(), "2023-06-03 12:00:01");
59/// ```
60///
61///
62/// # Returns
63///
64/// * `Ok(Zoned)` - If the input string can be parsed as a time
65/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a
66///   relative time
67///
68/// # Errors
69///
70/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the
71/// input string cannot be parsed as a relative time.
72pub fn parse_datetime<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, ParseDateTimeError> {
73    items::parse_at_local(input).map_err(|e| e.into())
74}
75
76/// Parses a time string at a specific date and returns a `Zoned` object
77/// representing the absolute time of the string.
78///
79/// # Arguments
80///
81/// * date - The date represented in local time
82/// * `input` - A string slice representing the time.
83///
84/// # Examples
85///
86/// ```
87/// use jiff::Zoned;
88/// use parse_datetime::parse_datetime_at_date;
89///
90///  let now = Zoned::now();
91///  let after = parse_datetime_at_date(now, "2024-09-13UTC +3 days").unwrap();
92///
93///  assert_eq!(
94///    "2024-09-16",
95///    after.strftime("%F").to_string()
96///  );
97/// ```
98///
99/// # Returns
100///
101/// * `Ok(Zoned)` - If the input string can be parsed as a time
102/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a
103///   relative time
104///
105/// # Errors
106///
107/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the
108/// input string cannot be parsed as a relative time.
109pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
110    date: Zoned,
111    input: S,
112) -> Result<Zoned, ParseDateTimeError> {
113    items::parse_at_date(date, input).map_err(|e| e.into())
114}
115
116#[cfg(test)]
117mod tests {
118    use jiff::{
119        civil::{date, time, Time, Weekday},
120        ToSpan, Zoned,
121    };
122
123    use crate::parse_datetime;
124
125    #[cfg(test)]
126    mod iso_8601 {
127        use crate::parse_datetime;
128
129        static TEST_TIME: i64 = 1613371067;
130
131        #[test]
132        fn test_t_sep() {
133            let dt = "2021-02-15T06:37:47 +0000";
134            let actual = parse_datetime(dt).unwrap();
135            assert_eq!(actual.timestamp().as_second(), TEST_TIME);
136        }
137
138        #[test]
139        fn test_space_sep() {
140            let dt = "2021-02-15 06:37:47 +0000";
141            let actual = parse_datetime(dt).unwrap();
142            assert_eq!(actual.timestamp().as_second(), TEST_TIME);
143        }
144
145        #[test]
146        fn test_space_sep_offset() {
147            let dt = "2021-02-14 22:37:47 -0800";
148            let actual = parse_datetime(dt).unwrap();
149            assert_eq!(actual.timestamp().as_second(), TEST_TIME);
150        }
151
152        #[test]
153        fn test_t_sep_offset() {
154            let dt = "2021-02-14T22:37:47 -0800";
155            let actual = parse_datetime(dt).unwrap();
156            assert_eq!(actual.timestamp().as_second(), TEST_TIME);
157        }
158
159        #[test]
160        fn test_t_sep_single_digit_offset_no_space() {
161            let dt = "2021-02-14T22:37:47-8";
162            let actual = parse_datetime(dt).unwrap();
163            assert_eq!(actual.timestamp().as_second(), TEST_TIME);
164        }
165
166        #[test]
167        fn invalid_formats() {
168            let invalid_dts = vec![
169                "NotADate",
170                "202104",
171                "202104-12T22:37:47",
172                "a774e26sec", // 774e26 is not a valid seconds value (we don't accept E-notation)
173                "12.",        // Invalid floating point number
174            ];
175            for dt in invalid_dts {
176                assert!(
177                    parse_datetime(dt).is_err(),
178                    "Expected error for input: {}",
179                    dt
180                );
181            }
182        }
183
184        #[test]
185        fn test_epoch_seconds() {
186            let dt = "@1613371067";
187            let actual = parse_datetime(dt).unwrap();
188            assert_eq!(actual.timestamp().as_second(), TEST_TIME);
189        }
190
191        // #[test]
192        // fn test_epoch_seconds_non_utc() {
193        //     env::set_var("TZ", "EST");
194        //     let dt = "@1613371067";
195        //     let actual = parse_datetime(dt).unwrap();
196        //     assert_eq!(actual.timestamp().as_second(), TEST_TIME);
197        // }
198    }
199
200    #[cfg(test)]
201    mod calendar_date_items {
202        use jiff::{
203            civil::{date, time},
204            Zoned,
205        };
206
207        use crate::parse_datetime;
208
209        #[test]
210        fn single_digit_month_day() {
211            let expected = Zoned::now()
212                .with()
213                .date(date(1987, 5, 7))
214                .time(time(0, 0, 0, 0))
215                .build()
216                .unwrap();
217
218            assert_eq!(expected, parse_datetime("1987-05-07").unwrap());
219            assert_eq!(expected, parse_datetime("1987-5-07").unwrap());
220            assert_eq!(expected, parse_datetime("1987-05-7").unwrap());
221            assert_eq!(expected, parse_datetime("1987-5-7").unwrap());
222            assert_eq!(expected, parse_datetime("5/7/1987").unwrap());
223            assert_eq!(expected, parse_datetime("5/07/1987").unwrap());
224            assert_eq!(expected, parse_datetime("05/7/1987").unwrap());
225            assert_eq!(expected, parse_datetime("05/07/1987").unwrap());
226        }
227    }
228
229    #[cfg(test)]
230    mod offsets {
231        use jiff::{civil::DateTime, tz, Zoned};
232
233        use crate::parse_datetime;
234
235        #[test]
236        fn test_positive_offsets() {
237            let offsets = vec![
238                "UTC+07:00",
239                "UTC+0700",
240                "UTC+07",
241                "Z+07:00",
242                "Z+0700",
243                "Z+07",
244            ];
245
246            let expected = format!("{}{}", Zoned::now().strftime("%Y%m%d"), "0000+0700");
247            for offset in offsets {
248                let actual = parse_datetime(offset).unwrap();
249                assert_eq!(expected, actual.strftime("%Y%m%d%H%M%z").to_string());
250            }
251        }
252
253        #[test]
254        fn test_partial_offset() {
255            let offsets = vec!["UTC+00:15", "UTC+0015", "Z+00:15", "Z+0015"];
256            let expected = format!("{}{}", Zoned::now().strftime("%Y%m%d"), "0000+0015");
257            for offset in offsets {
258                let actual = parse_datetime(offset).unwrap();
259                assert_eq!(expected, actual.strftime("%Y%m%d%H%M%z").to_string());
260            }
261        }
262
263        #[test]
264        fn test_datetime_with_offset() {
265            let actual = parse_datetime("1997-01-19 08:17:48 +2").unwrap();
266            let expected = "1997-01-19 08:17:48"
267                .parse::<DateTime>()
268                .unwrap()
269                .to_zoned(tz::TimeZone::fixed(tz::offset(2)))
270                .unwrap();
271            assert_eq!(actual, expected);
272        }
273
274        #[test]
275        fn test_datetime_with_timezone() {
276            let actual = parse_datetime("1997-01-19 08:17:48 BRT").unwrap();
277            let expected = "1997-01-19 08:17:48"
278                .parse::<DateTime>()
279                .unwrap()
280                .to_zoned(tz::TimeZone::fixed(tz::offset(-3)))
281                .unwrap();
282            assert_eq!(actual, expected);
283        }
284
285        #[test]
286        fn offset_overflow() {
287            assert!(parse_datetime("m+25").is_err());
288            assert!(parse_datetime("24:00").is_err());
289        }
290    }
291
292    #[cfg(test)]
293    mod relative_time {
294        use crate::parse_datetime;
295
296        #[test]
297        fn test_positive_offsets() {
298            let relative_times = vec![
299                "today",
300                "yesterday",
301                "1 minute",
302                "3 hours",
303                "1 year 3 months",
304            ];
305
306            for relative_time in relative_times {
307                assert!(parse_datetime(relative_time).is_ok());
308            }
309        }
310    }
311
312    #[cfg(test)]
313    mod weekday {
314        use jiff::{civil::DateTime, tz::TimeZone, Zoned};
315
316        use crate::parse_datetime_at_date;
317
318        fn get_formatted_date(date: &Zoned, weekday: &str) -> String {
319            let result = parse_datetime_at_date(date.clone(), weekday).unwrap();
320
321            result.strftime("%F %T %9f").to_string()
322        }
323
324        #[test]
325        fn test_weekday() {
326            // add some constant hours and minutes and seconds to check its reset
327            let date = "2023-02-28 10:12:03"
328                .parse::<DateTime>()
329                .unwrap()
330                .to_zoned(TimeZone::system())
331                .unwrap();
332
333            // 2023-2-28 is tuesday
334            assert_eq!(
335                get_formatted_date(&date, "tuesday"),
336                "2023-02-28 00:00:00 000000000"
337            );
338
339            // 2023-3-01 is wednesday
340            assert_eq!(
341                get_formatted_date(&date, "wed"),
342                "2023-03-01 00:00:00 000000000"
343            );
344
345            assert_eq!(
346                get_formatted_date(&date, "thu"),
347                "2023-03-02 00:00:00 000000000"
348            );
349
350            assert_eq!(
351                get_formatted_date(&date, "fri"),
352                "2023-03-03 00:00:00 000000000"
353            );
354
355            assert_eq!(
356                get_formatted_date(&date, "sat"),
357                "2023-03-04 00:00:00 000000000"
358            );
359
360            assert_eq!(
361                get_formatted_date(&date, "sun"),
362                "2023-03-05 00:00:00 000000000"
363            );
364        }
365    }
366
367    #[cfg(test)]
368    mod timestamp {
369        use jiff::Timestamp;
370
371        use crate::parse_datetime;
372
373        #[test]
374        fn test_positive_and_negative_offsets() {
375            let offsets: Vec<i64> = vec![
376                0, 1, 2, 10, 100, 150, 2000, 1234400000, 1334400000, 1692582913, 2092582910,
377            ];
378
379            for offset in offsets {
380                // positive offset
381                let time = Timestamp::from_second(offset).unwrap();
382                let dt = parse_datetime(format!("@{offset}")).unwrap();
383                assert_eq!(dt.timestamp(), time);
384
385                // negative offset
386                let time = Timestamp::from_second(-offset).unwrap();
387                let dt = parse_datetime(format!("@-{offset}")).unwrap();
388                assert_eq!(dt.timestamp(), time);
389            }
390        }
391    }
392
393    /// Used to test example code presented in the README.
394    mod readme_test {
395        use jiff::{civil::DateTime, tz::TimeZone};
396
397        use crate::parse_datetime;
398
399        #[test]
400        fn test_readme_code() {
401            let dt = parse_datetime("2021-02-14 06:37:47").unwrap();
402            let expected = "2021-02-14 06:37:47"
403                .parse::<DateTime>()
404                .unwrap()
405                .to_zoned(TimeZone::system())
406                .unwrap();
407
408            assert_eq!(dt, expected);
409        }
410    }
411
412    mod invalid_test {
413        use crate::parse_datetime;
414        use crate::ParseDateTimeError;
415
416        #[test]
417        fn test_invalid_input() {
418            let result = parse_datetime("foobar");
419            assert_eq!(result, Err(ParseDateTimeError::InvalidInput));
420
421            let result = parse_datetime("invalid 1");
422            assert_eq!(result, Err(ParseDateTimeError::InvalidInput));
423        }
424    }
425
426    #[test]
427    fn test_datetime_ending_in_z() {
428        let actual = parse_datetime("2023-06-03 12:00:01Z").unwrap();
429        let expected = "2023-06-03 12:00:01[UTC]".parse::<Zoned>().unwrap();
430        assert_eq!(actual, expected);
431    }
432
433    #[test]
434    fn test_parse_invalid_datetime() {
435        assert!(crate::parse_datetime("bogus +1 day").is_err());
436    }
437
438    #[test]
439    fn test_parse_invalid_delta() {
440        assert!(crate::parse_datetime("1997-01-01 bogus").is_err());
441    }
442
443    #[test]
444    fn test_parse_datetime_tz_nodelta() {
445        // 1997-01-01 00:00:00 +0000
446        let expected = "1997-01-01 00:00:00[UTC]".parse::<Zoned>().unwrap();
447
448        for s in [
449            "1997-01-01 00:00:00 +0000",
450            "1997-01-01 00:00:00 +00",
451            "1997-01-01 00:00 +0000",
452            "1997-01-01 00:00:00 +0000",
453            "1997-01-01T00:00:00+0000",
454            "1997-01-01T00:00:00+00",
455            "1997-01-01T00:00:00Z",
456            "@852076800",
457        ] {
458            let actual = crate::parse_datetime(s).unwrap();
459            assert_eq!(actual, expected);
460        }
461    }
462
463    #[test]
464    fn test_parse_datetime_notz_nodelta() {
465        let expected = Zoned::now()
466            .with()
467            .date(date(1997, 1, 1))
468            .time(time(0, 0, 0, 0))
469            .build()
470            .unwrap();
471
472        for s in [
473            "1997-01-01 00:00:00.000000000",
474            "Wed Jan  1 00:00:00 1997",
475            "1997-01-01T00:00:00",
476            "1997-01-01 00:00:00",
477            "1997-01-01 00:00",
478        ] {
479            let actual = crate::parse_datetime(s).unwrap();
480            assert_eq!(actual, expected);
481        }
482    }
483
484    #[test]
485    fn test_parse_date_notz_nodelta() {
486        let expected = Zoned::now()
487            .with()
488            .date(date(1997, 1, 1))
489            .time(time(0, 0, 0, 0))
490            .build()
491            .unwrap();
492
493        for s in ["1997-01-01", "19970101", "01/01/1997", "01/01/97"] {
494            let actual = crate::parse_datetime(s).unwrap();
495            assert_eq!(actual, expected);
496        }
497    }
498
499    #[test]
500    fn test_parse_datetime_tz_delta() {
501        // 1998-01-01
502        let expected = "1998-01-01 00:00:00[UTC]".parse::<Zoned>().unwrap();
503
504        for s in [
505            "1997-01-01 00:00:00 +0000 +1 year",
506            "1997-01-01 00:00:00 +00 +1 year",
507            "1997-01-01T00:00:00Z +1 year",
508            "1997-01-01 00:00 +0000 +1 year",
509            "1997-01-01 00:00:00 +0000 +1 year",
510            "1997-01-01T00:00:00+0000 +1 year",
511            "1997-01-01T00:00:00+00 +1 year",
512        ] {
513            let actual = crate::parse_datetime(s).unwrap();
514            assert_eq!(actual, expected);
515        }
516    }
517
518    #[test]
519    fn test_parse_datetime_notz_delta() {
520        let expected = Zoned::now()
521            .with()
522            .date(date(1998, 1, 1))
523            .time(time(0, 0, 0, 0))
524            .build()
525            .unwrap();
526
527        for s in [
528            "1997-01-01 00:00:00.000000000 1 year",
529            "Wed Jan  1 00:00:00 1997 1 year",
530            "1997-01-01T00:00:00 1 year",
531            "1997-01-01 00:00:00 1 year",
532            "1997-01-01 00:00 1 year",
533        ] {
534            let actual = crate::parse_datetime(s).unwrap();
535            assert_eq!(actual, expected);
536        }
537    }
538
539    #[test]
540    fn test_parse_invalid_datetime_notz_delta() {
541        // GNU date does not accept the following formats.
542        for s in ["199701010000.00 +1 year", "199701010000 +1 year"] {
543            assert!(crate::parse_datetime(s).is_err());
544        }
545    }
546
547    #[test]
548    fn test_parse_date_notz_delta() {
549        let expected = Zoned::now()
550            .with()
551            .date(date(1998, 1, 1))
552            .time(time(0, 0, 0, 0))
553            .build()
554            .unwrap();
555
556        for s in [
557            "1997-01-01 +1 year",
558            "19970101 +1 year",
559            "01/01/1997 +1 year",
560            "01/01/97 +1 year",
561        ] {
562            let actual = crate::parse_datetime(s).unwrap();
563            assert_eq!(actual, expected);
564        }
565    }
566
567    #[test]
568    fn test_weekday_only() {
569        let now = Zoned::now();
570        let midnight = Time::new(0, 0, 0, 0).unwrap();
571        let today = now.weekday();
572        let midnight_today = now.with().time(midnight).build().unwrap();
573
574        for (s, day) in [
575            ("sunday", Weekday::Sunday),
576            ("monday", Weekday::Monday),
577            ("tuesday", Weekday::Tuesday),
578            ("wednesday", Weekday::Wednesday),
579            ("thursday", Weekday::Thursday),
580            ("friday", Weekday::Friday),
581            ("saturday", Weekday::Saturday),
582        ] {
583            let actual = parse_datetime(s).unwrap();
584            let delta = day.since(today);
585            let expected = midnight_today.checked_add(delta.days()).unwrap();
586            assert_eq!(actual, expected);
587        }
588    }
589
590    mod test_relative {
591        use crate::parse_datetime;
592
593        #[test]
594        fn test_month() {
595            assert_eq!(
596                parse_datetime("28 feb + 1 month")
597                    .expect("parse_datetime")
598                    .strftime("%m%d")
599                    .to_string(),
600                "0328"
601            );
602
603            // 29 feb 2025 is invalid
604            assert!(parse_datetime("29 feb + 1 year").is_err());
605
606            // 29 feb 2025 is an invalid date
607            assert!(parse_datetime("29 feb 2025").is_err());
608
609            // because 29 feb 2025 is invalid, 29 feb 2025 + 1 day is invalid
610            // arithmetic does not operate on invalid dates
611            assert!(parse_datetime("29 feb 2025 + 1 day").is_err());
612
613            // 28 feb 2023 + 1 day = 1 mar
614            assert_eq!(
615                parse_datetime("28 feb 2023 + 1 day")
616                    .unwrap()
617                    .strftime("%m%d")
618                    .to_string(),
619                "0301"
620            );
621        }
622
623        #[test]
624        fn month_overflow() {
625            assert_eq!(
626                parse_datetime("2024-01-31 + 1 month")
627                    .unwrap()
628                    .strftime("%Y-%m-%dT%H:%M:%S")
629                    .to_string(),
630                "2024-03-02T00:00:00",
631            );
632
633            assert_eq!(
634                parse_datetime("2024-02-29 + 1 month")
635                    .unwrap()
636                    .strftime("%Y-%m-%dT%H:%M:%S")
637                    .to_string(),
638                "2024-03-29T00:00:00",
639            );
640        }
641    }
642
643    mod test_gnu {
644        use crate::parse_datetime;
645
646        #[test]
647        fn gnu_compat() {
648            const FMT: &str = "%Y-%m-%d %H:%M:%S";
649            let input = "0000-03-02 00:00:00";
650            assert_eq!(
651                input,
652                parse_datetime(input).unwrap().strftime(FMT).to_string()
653            );
654
655            let input = "2621-03-10 00:00:00";
656            assert_eq!(
657                input,
658                parse_datetime(input).unwrap().strftime(FMT).to_string()
659            );
660
661            let input = "1038-03-10 00:00:00";
662            assert_eq!(
663                input,
664                parse_datetime(input).unwrap().strftime(FMT).to_string()
665            );
666        }
667    }
668}