Skip to main content

qsv_dateparser/
lib.rs

1//! A rust library for parsing date strings in commonly used formats. Parsed date will be returned
2//! as `chrono`'s `DateTime<Utc>`.
3//!
4//! # Quick Start
5//!
6//!
7//! Use `str`'s `parse` method:
8//!
9//! ```
10//! use chrono::prelude::*;
11//! use qsv_dateparser::DateTimeUtc;
12//! use std::error::Error;
13//!
14//! fn main() -> Result<(), Box<dyn Error>> {
15//!     assert_eq!(
16//!         "2021-05-14 18:51 PDT".parse::<DateTimeUtc>()?.0,
17//!         Utc.ymd(2021, 5, 15).and_hms(1, 51, 0),
18//!     );
19//!     Ok(())
20//! }
21//! ```
22//!
23//! ## Accepted date formats
24//!
25//! ```
26//! use qsv_dateparser::DateTimeUtc;
27//!
28//! let accepted = vec![
29//!     // unix timestamp
30//!     "1511648546",
31//!     "1620021848429",
32//!     "1620024872717915000",
33//!     "0",
34//!     "-770172300",
35//!     "1671673426.123456789",
36//!     // rfc3339
37//!     "2021-05-01T01:17:02.604456Z",
38//!     "2017-11-25T22:34:50Z",
39//!     // rfc2822
40//!     "Wed, 02 Jun 2021 06:31:39 GMT",
41//!     // yyyy-mm-dd hh:mm:ss
42//!     "2014-04-26 05:24:37 PM",
43//!     "2021-04-30 21:14",
44//!     "2021-04-30 21:14:10",
45//!     "2021-04-30 21:14:10.052282",
46//!     "2014-04-26 17:24:37.123",
47//!     "2014-04-26 17:24:37.3186369",
48//!     "2012-08-03 18:31:59.257000000",
49//!     "2020-01-15T08:00",
50//!     "2020-01-15T08:00:00",
51//!     "2020-01-15T08:00:00.123456",
52//!     "2012-03-19 10:11:59.318 PM",
53//!     "2012-03-19T10:11:59.318 PM",
54//!     // yyyy-mm-dd hh:mm:ss z
55//!     "2017-11-25 13:31:15 PST",
56//!     "2017-11-25 13:31 PST",
57//!     "2014-12-16 06:20:00 UTC",
58//!     "2014-12-16 06:20:00 GMT",
59//!     "2014-04-26 13:13:43 +0800",
60//!     "2014-04-26 13:13:44 +09:00",
61//!     "2012-08-03 18:31:59.257000000 +0000",
62//!     "2015-09-30 18:48:56.35272715 UTC",
63//!     "2021-05-14 18:51 PDT",
64//!     // yyyy-mm-dd
65//!     "2021-02-21",
66//!     // yyyy-mm-dd z
67//!     "2021-02-21 PST",
68//!     "2021-02-21 UTC",
69//!     "2020-07-20+08:00",
70//!     // Mon dd, yyyy, hh:mm:ss
71//!     "May 8, 2009 5:57:51 PM",
72//!     "September 17, 2012 10:09am",
73//!     "September 17, 2012, 10:10:09",
74//!     // Mon dd, yyyy hh:mm:ss z
75//!     "May 02, 2021 15:51:31 UTC",
76//!     "May 02, 2021 15:51 UTC",
77//!     "May 26, 2021, 12:49 AM PDT",
78//!     "September 17, 2012 at 10:09am PST",
79//!     // yyyy-mon-dd
80//!     "2021-Feb-21",
81//!     // Mon dd, yyyy
82//!     "May 25, 2021",
83//!     "oct 7, 1970",
84//!     "oct 7, 70",
85//!     "oct. 7, 1970",
86//!     "oct. 7, 70",
87//!     "October 7, 1970",
88//!     // dd Mon yyyy hh:mm:ss
89//!     "12 Feb 2006, 19:17",
90//!     "12 Feb 2006 19:17",
91//!     "14 May 2019 19:11:40.164",
92//!     // dd Mon yyyy
93//!     "7 oct 70",
94//!     "7 oct 1970",
95//!     "03 February 2013",
96//!     "1 July 2013",
97//!     // mm/dd/yyyy hh:mm:ss
98//!     "4/8/2014 22:05",
99//!     "04/08/2014 22:05",
100//!     "4/8/14 22:05",
101//!     "04/2/2014 03:00:51",
102//!     "8/8/1965 12:00:00 AM",
103//!     "8/8/1965 01:00:01 PM",
104//!     "8/8/1965 01:00 PM",
105//!     "8/8/1965 1:00 PM",
106//!     "8/8/1965 12:00 AM",
107//!     "4/02/2014 03:00:51",
108//!     "03/19/2012 10:11:59",
109//!     "03/19/2012 10:11:59.3186369",
110//!     "03/19/2012 10:11:59.318 PM",
111//!     // mm/dd/yyyy
112//!     "3/31/2014",
113//!     "03/31/2014",
114//!     "08/21/71",
115//!     "8/1/71",
116//!     // yyyy/mm/dd hh:mm:ss
117//!     "2014/4/8 22:05",
118//!     "2014/04/08 22:05",
119//!     "2014/04/2 03:00:51",
120//!     "2014/4/02 03:00:51",
121//!     "2012/03/19 10:11:59",
122//!     "2012/03/19 10:11:59.3186369",
123//!     "2012/03/19 10:11:59.318 PM",
124//!     // yyyy/mm/dd
125//!     "2014/3/31",
126//!     "2014/03/31",
127//! ];
128//!
129//! for date_str in accepted {
130//!     let result = date_str.parse::<DateTimeUtc>();
131//!     assert!(result.is_ok())
132//! }
133//! ```
134//!
135//! ### DMY Format
136//!
137//! It also accepts dates in DMY format with `parse_with_preference`,
138//! and the `prefer_dmy` parameter set to true.
139//!
140//! ```
141//! use qsv_dateparser::parse_with_preference;
142//!
143//! let accepted = vec![
144//!     // dd/mm/yyyy
145//!     "31/12/2020",
146//!     "12/10/2019",
147//!     "03/06/2018",
148//!     "27/06/68",
149//!     // dd/mm/yyyy hh:mm:ss
150//!     "4/8/2014 22:05",
151//!     "04/08/2014 22:05",
152//!     "4/8/14 22:05",
153//!     "04/2/2014 03:00:51",
154//!     "8/8/1965 12:00:00 AM",
155//!     "8/8/1965 01:00:01 PM",
156//!     "8/8/1965 01:00 PM",
157//!     "31/12/22 15:00",
158//!     "19/03/2012 10:11:59.318 PM"
159//! ];
160//!
161//! for date_str in accepted {
162//!     let result = parse_with_preference(date_str, true);
163//!     assert!(result.is_ok());
164//! }
165//! ```
166
167/// Datetime string parser
168///
169/// ```
170/// use chrono::prelude::*;
171/// use qsv_dateparser::datetime::Parse;
172/// use std::error::Error;
173///
174/// fn main() -> Result<(), Box<dyn Error>> {
175///     let utc_now_time = Utc::now().time();
176///     let parse_with_local = Parse::new(&Local, utc_now_time);
177///     assert_eq!(
178///         parse_with_local.parse("2021-06-05 06:19 PM")?,
179///         Local.ymd(2021, 6, 5).and_hms(18, 19, 0).with_timezone(&Utc),
180///     );
181///
182///     let parse_with_utc = Parse::new(&Utc, utc_now_time);
183///     assert_eq!(
184///         parse_with_utc.parse("2021-06-05 06:19 PM")?,
185///         Utc.ymd(2021, 6, 5).and_hms(18, 19, 0),
186///     );
187///
188///     Ok(())
189/// }
190/// ```
191pub mod datetime;
192
193/// Timezone offset string parser
194///
195/// ```
196/// use chrono::prelude::*;
197/// use qsv_dateparser::timezone::parse;
198/// use std::error::Error;
199///
200/// fn main() -> Result<(), Box<dyn Error>> {
201///     assert_eq!(parse("-0800")?, FixedOffset::west(8 * 3600));
202///     assert_eq!(parse("+10:00")?, FixedOffset::east(10 * 3600));
203///     assert_eq!(parse("PST")?, FixedOffset::west(8 * 3600));
204///     assert_eq!(parse("PDT")?, FixedOffset::west(7 * 3600));
205///     assert_eq!(parse("UTC")?, FixedOffset::west(0));
206///     assert_eq!(parse("GMT")?, FixedOffset::west(0));
207///
208///     Ok(())
209/// }
210/// ```
211pub mod timezone;
212
213use crate::datetime::Parse;
214use anyhow::{Error, Result};
215use chrono::prelude::*;
216
217/// `DateTimeUtc` is an alias for `chrono`'s `DateTime<UTC>`. It implements `std::str::FromStr`'s
218/// `from_str` method, and it makes `str`'s `parse` method to understand the accepted date formats
219/// from this crate.
220///
221/// ```
222/// use qsv_dateparser::DateTimeUtc;
223///
224/// // parsed is DateTimeUTC and parsed.0 is chrono's DateTime<Utc>
225/// match "May 02, 2021 15:51:31 UTC".parse::<DateTimeUtc>() {
226///     Ok(parsed) => println!("PARSED into UTC datetime {:?}", parsed.0),
227///     Err(err) => println!("ERROR from parsing datetime string: {}", err)
228/// }
229/// ```
230pub struct DateTimeUtc(pub DateTime<Utc>);
231
232impl std::str::FromStr for DateTimeUtc {
233    type Err = Error;
234
235    fn from_str(s: &str) -> Result<Self> {
236        parse(s).map(DateTimeUtc)
237    }
238}
239
240const MIDNIGHT: NaiveTime = NaiveTime::MIN;
241
242/// This function tries to recognize the input datetime string with a list of accepted formats.
243/// When timezone is not provided, this function assumes it's a [`chrono::Local`] datetime. For
244/// custom timezone, use [`parse_with_timezone()`] instead.If all options are exhausted,
245/// [`parse()`] will return an error to let the caller know that no formats were matched.
246#[inline]
247pub fn parse(input: &str) -> Result<DateTime<Utc>> {
248    Parse::new(&Local, Utc::now().time()).parse(input)
249}
250
251/// Similar to [`parse()`], this function takes a datetime string and a boolean `dmy_preference`.
252/// When `dmy_preference` is `true`, it will parse strings using the DMY format. Otherwise, it
253/// parses them using an MDY format.
254#[inline]
255pub fn parse_with_preference(input: &str, dmy_preference: bool) -> Result<DateTime<Utc>> {
256    Parse::new_with_preference(&Utc, MIDNIGHT, dmy_preference).parse(input)
257}
258
259/// Similar to [`parse()`], this function takes a datetime string and a custom [`chrono::TimeZone`],
260/// and tries to parse the datetime string. When timezone is not given in the string, this function
261/// will assume and parse the datetime by the custom timezone provided in this function's arguments.
262///
263#[inline]
264pub fn parse_with_timezone<Tz2: TimeZone>(input: &str, tz: &Tz2) -> Result<DateTime<Utc>> {
265    Parse::new(tz, Utc::now().time()).parse(input)
266}
267
268/// Similar to [`parse()`], this function takes a datetime string and a boolean `dmy_preference`
269/// and a timezone. When timezone is not given in the input string, this function will
270/// assume and parse the datetime by the custom timezone provided in this function's arguments.
271/// When `dmy_preference` is `true`, it will parse strings using the DMY format. Otherwise, it
272/// parses them using an MDY format.
273#[inline]
274pub fn parse_with_preference_and_timezone<Tz2: TimeZone>(
275    input: &str,
276    dmy_preference: bool,
277    tz: &Tz2,
278) -> Result<DateTime<Utc>> {
279    Parse::new_with_preference(tz, MIDNIGHT, dmy_preference).parse(input)
280}
281
282/// Similar to [`parse()`] and [`parse_with_timezone()`], this function takes a datetime string, a
283/// custom [`chrono::TimeZone`] and a default naive time. In addition to assuming timezone when
284/// it's not given in datetime string, this function also use provided default naive time in parsed
285/// [`chrono::DateTime`].
286///
287#[inline]
288pub fn parse_with<Tz2: TimeZone>(
289    input: &str,
290    tz: &Tz2,
291    default_time: NaiveTime,
292) -> Result<DateTime<Utc>> {
293    Parse::new(tz, default_time).parse(input)
294}
295
296#[cfg(test)]
297#[allow(deprecated)]
298mod tests {
299    use super::*;
300
301    /// Every date string quoted in the README's "Accepted date formats" list
302    /// must actually parse.
303    ///
304    /// The equivalent list in this file's crate docs is a doctest, so CI
305    /// catches it when an entry stops parsing. The README's copy had no such
306    /// check, which is why it was the one that drifted. Reading it back from
307    /// `include_str!` gives it the same guarantee.
308    ///
309    /// Note this catches a README that *claims* a format the crate does not
310    /// accept; it cannot catch a newly accepted format the README omits.
311    #[test]
312    fn readme_accepted_formats_all_parse() {
313        let readme = include_str!("../README.md");
314
315        // Narrow to the fenced block under the heading. Scanning the whole
316        // file would pick up any future quoted line elsewhere in the README
317        // and fail on it even though it was never meant to be a date.
318        let after_heading = readme
319            .split_once("## Accepted date formats")
320            .expect("README lost its '## Accepted date formats' heading")
321            .1;
322        let after_fence_marker = after_heading
323            .split_once("```")
324            .expect("the accepted-formats section lost its opening code fence")
325            .1;
326        // Drop the remainder of the fence line, which carries the language tag.
327        let block_start = after_fence_marker
328            .split_once('\n')
329            .expect("the opening code fence is not terminated")
330            .1;
331        let block = block_start
332            .split_once("```")
333            .expect("the accepted-formats section lost its closing code fence")
334            .0;
335
336        // Every line in the block must be recognised as a comment or a quoted
337        // entry. A bare count would let a reformat silently drop entries while
338        // the test still passed; refusing to skip anything is what makes this
339        // a completeness check rather than a sample.
340        let mut checked = 0_usize;
341        for line in block.lines() {
342            let line = line.trim();
343            if line.is_empty() || line.starts_with("//") {
344                continue;
345            }
346            let input = line
347                .strip_suffix(',')
348                .unwrap_or(line)
349                .strip_prefix('"')
350                .and_then(|rest| rest.strip_suffix('"'))
351                .unwrap_or_else(|| {
352                    panic!("unparsable line in the accepted-formats block: {line:?}")
353                });
354            // The list mixes MDY- and DMY-ordered examples, so accept either
355            // preference — the README documents both under one heading.
356            assert!(
357                parse_with_preference(input, false).is_ok()
358                    || parse_with_preference(input, true).is_ok(),
359                "README lists {input:?}, but it does not parse"
360            );
361            checked += 1;
362        }
363        // Backstop against the block being emptied or truncated entirely,
364        // which the per-line check above cannot see.
365        assert!(
366            checked >= 80,
367            "expected the full accepted-formats list, only found {checked} entries"
368        );
369    }
370
371    #[derive(Clone, Copy)]
372    enum Trunc {
373        Seconds,
374        None,
375    }
376
377    #[test]
378    fn parse_in_local() {
379        let test_cases = vec![
380            (
381                "rfc3339",
382                "2017-11-25T22:34:50Z",
383                Utc.ymd(2017, 11, 25).and_hms(22, 34, 50),
384                Trunc::None,
385            ),
386            (
387                "rfc2822",
388                "Wed, 02 Jun 2021 06:31:39 GMT",
389                Utc.ymd(2021, 6, 2).and_hms(6, 31, 39),
390                Trunc::None,
391            ),
392            (
393                "ymd_hms",
394                "2021-04-30 21:14:10",
395                Local
396                    .ymd(2021, 4, 30)
397                    .and_hms(21, 14, 10)
398                    .with_timezone(&Utc),
399                Trunc::None,
400            ),
401            (
402                "ymd_hms_z",
403                "2017-11-25 13:31:15 PST",
404                Utc.ymd(2017, 11, 25).and_hms(21, 31, 15),
405                Trunc::None,
406            ),
407            (
408                "ymd",
409                "2021-02-21",
410                Local
411                    .ymd(2021, 2, 21)
412                    .and_time(Local::now().time())
413                    .unwrap()
414                    .with_timezone(&Utc),
415                Trunc::Seconds,
416            ),
417            (
418                "ymd_z",
419                "2021-02-21 PST",
420                FixedOffset::west(8 * 3600)
421                    .ymd(2021, 2, 21)
422                    .and_time(
423                        Utc::now()
424                            .with_timezone(&FixedOffset::west(8 * 3600))
425                            .time(),
426                    )
427                    .unwrap()
428                    .with_timezone(&Utc),
429                Trunc::Seconds,
430            ),
431            (
432                "month_ymd",
433                "2021-Feb-21",
434                Local
435                    .ymd(2021, 2, 21)
436                    .and_time(Local::now().time())
437                    .unwrap()
438                    .with_timezone(&Utc),
439                Trunc::Seconds,
440            ),
441            (
442                "month_mdy_hms",
443                "May 8, 2009 5:57:51 PM",
444                Local
445                    .ymd(2009, 5, 8)
446                    .and_hms(17, 57, 51)
447                    .with_timezone(&Utc),
448                Trunc::None,
449            ),
450            (
451                "month_mdy_hms_z",
452                "May 02, 2021 15:51 UTC",
453                Utc.ymd(2021, 5, 2).and_hms(15, 51, 0),
454                Trunc::None,
455            ),
456            (
457                "month_mdy",
458                "May 25, 2021",
459                Local
460                    .ymd(2021, 5, 25)
461                    .and_time(Local::now().time())
462                    .unwrap()
463                    .with_timezone(&Utc),
464                Trunc::Seconds,
465            ),
466            (
467                "month_dmy_hms",
468                "14 May 2019 19:11:40.164",
469                Local
470                    .ymd(2019, 5, 14)
471                    .and_hms_milli(19, 11, 40, 164)
472                    .with_timezone(&Utc),
473                Trunc::None,
474            ),
475            (
476                "month_dmy",
477                "1 July 2013",
478                Local
479                    .ymd(2013, 7, 1)
480                    .and_time(Local::now().time())
481                    .unwrap()
482                    .with_timezone(&Utc),
483                Trunc::Seconds,
484            ),
485            (
486                "slash_mdy_hms",
487                "03/19/2012 10:11:59",
488                Local
489                    .ymd(2012, 3, 19)
490                    .and_hms(10, 11, 59)
491                    .with_timezone(&Utc),
492                Trunc::None,
493            ),
494            (
495                "slash_mdy",
496                "08/21/71",
497                Local
498                    .ymd(1971, 8, 21)
499                    .and_time(Local::now().time())
500                    .unwrap()
501                    .with_timezone(&Utc),
502                Trunc::Seconds,
503            ),
504            (
505                "slash_ymd_hms",
506                "2012/03/19 10:11:59",
507                Local
508                    .ymd(2012, 3, 19)
509                    .and_hms(10, 11, 59)
510                    .with_timezone(&Utc),
511                Trunc::None,
512            ),
513            (
514                "slash_ymd",
515                "2014/3/31",
516                Local
517                    .ymd(2014, 3, 31)
518                    .and_time(Local::now().time())
519                    .unwrap()
520                    .with_timezone(&Utc),
521                Trunc::Seconds,
522            ),
523        ];
524
525        for &(test, input, want, trunc) in test_cases.iter() {
526            match trunc {
527                Trunc::None => {
528                    assert_eq!(
529                        super::parse(input).unwrap(),
530                        want,
531                        "parse_in_local/{}/{}",
532                        test,
533                        input
534                    )
535                }
536                Trunc::Seconds => assert_eq!(
537                    super::parse(input)
538                        .unwrap()
539                        .trunc_subsecs(0)
540                        .with_second(0)
541                        .unwrap(),
542                    want.trunc_subsecs(0).with_second(0).unwrap(),
543                    "parse_in_local/{}/{}",
544                    test,
545                    input
546                ),
547            };
548        }
549    }
550
551    #[test]
552    fn parse_with_timezone_in_utc() {
553        let test_cases = vec![
554            (
555                "rfc3339",
556                "2017-11-25T22:34:50Z",
557                Utc.ymd(2017, 11, 25).and_hms(22, 34, 50),
558                Trunc::None,
559            ),
560            (
561                "rfc2822",
562                "Wed, 02 Jun 2021 06:31:39 GMT",
563                Utc.ymd(2021, 6, 2).and_hms(6, 31, 39),
564                Trunc::None,
565            ),
566            (
567                "ymd_hms",
568                "2021-04-30 21:14:10",
569                Utc.ymd(2021, 4, 30).and_hms(21, 14, 10),
570                Trunc::None,
571            ),
572            (
573                "ymd_hms_z",
574                "2017-11-25 13:31:15 PST",
575                Utc.ymd(2017, 11, 25).and_hms(21, 31, 15),
576                Trunc::None,
577            ),
578            (
579                "ymd",
580                "2021-02-21",
581                Utc.ymd(2021, 2, 21).and_time(Utc::now().time()).unwrap(),
582                Trunc::Seconds,
583            ),
584            (
585                "ymd_z",
586                "2021-02-21 PST",
587                FixedOffset::west(8 * 3600)
588                    .ymd(2021, 2, 21)
589                    .and_time(
590                        Utc::now()
591                            .with_timezone(&FixedOffset::west(8 * 3600))
592                            .time(),
593                    )
594                    .unwrap()
595                    .with_timezone(&Utc),
596                Trunc::Seconds,
597            ),
598            (
599                "month_ymd",
600                "2021-Feb-21",
601                Utc.ymd(2021, 2, 21).and_time(Utc::now().time()).unwrap(),
602                Trunc::Seconds,
603            ),
604            (
605                "month_mdy_hms",
606                "May 8, 2009 5:57:51 PM",
607                Utc.ymd(2009, 5, 8).and_hms(17, 57, 51),
608                Trunc::None,
609            ),
610            (
611                "month_mdy_hms_z",
612                "May 02, 2021 15:51 UTC",
613                Utc.ymd(2021, 5, 2).and_hms(15, 51, 0),
614                Trunc::None,
615            ),
616            (
617                "month_mdy",
618                "May 25, 2021",
619                Utc.ymd(2021, 5, 25).and_time(Utc::now().time()).unwrap(),
620                Trunc::Seconds,
621            ),
622            (
623                "month_dmy_hms",
624                "14 May 2019 19:11:40.164",
625                Utc.ymd(2019, 5, 14).and_hms_milli(19, 11, 40, 164),
626                Trunc::None,
627            ),
628            (
629                "month_dmy",
630                "1 July 2013",
631                Utc.ymd(2013, 7, 1).and_time(Utc::now().time()).unwrap(),
632                Trunc::Seconds,
633            ),
634            (
635                "slash_mdy_hms",
636                "03/19/2012 10:11:59",
637                Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
638                Trunc::None,
639            ),
640            (
641                "slash_mdy",
642                "08/21/71",
643                Utc.ymd(1971, 8, 21).and_time(Utc::now().time()).unwrap(),
644                Trunc::Seconds,
645            ),
646            (
647                "slash_ymd_hms",
648                "2012/03/19 10:11:59",
649                Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
650                Trunc::None,
651            ),
652            (
653                "slash_ymd",
654                "2014/3/31",
655                Utc.ymd(2014, 3, 31).and_time(Utc::now().time()).unwrap(),
656                Trunc::Seconds,
657            ),
658        ];
659
660        for &(test, input, want, trunc) in test_cases.iter() {
661            match trunc {
662                Trunc::None => {
663                    assert_eq!(
664                        super::parse_with_timezone(input, &Utc).unwrap(),
665                        want,
666                        "parse_with_timezone_in_utc/{}/{}",
667                        test,
668                        input
669                    )
670                }
671                Trunc::Seconds => assert_eq!(
672                    super::parse_with_timezone(input, &Utc)
673                        .unwrap()
674                        .trunc_subsecs(0)
675                        .with_second(0)
676                        .unwrap(),
677                    want.trunc_subsecs(0).with_second(0).unwrap(),
678                    "parse_with_timezone_in_utc/{}/{}",
679                    test,
680                    input
681                ),
682            };
683        }
684    }
685
686    #[test]
687    fn parse_with_preference_and_timezone_in_utc() {
688        let current_time = Utc::now().time();
689        let current_hour = current_time.hour();
690        let current_minute = current_time.minute();
691        // let current_second = current_time.second();
692        let test_cases = vec![
693            (
694                "rfc3339",
695                "2017-11-25T22:34:50Z",
696                Utc.ymd(2017, 11, 25).and_hms(22, 34, 50),
697                Trunc::None,
698            ),
699            (
700                "rfc2822",
701                "Wed, 02 Jun 2021 06:31:39 GMT",
702                Utc.ymd(2021, 6, 2).and_hms(6, 31, 39),
703                Trunc::None,
704            ),
705            // we currently do not parse dmy format using hyphens,
706            // so the following tests are commented out
707            // (
708            //     "dmy_hms",
709            //     "30-04-2021 21:14:10",
710            //     Utc.ymd(2021, 4, 30).and_hms(21, 14, 10),
711            //     Trunc::None,
712            // ),
713            // (
714            //     "dmy_hms_z",
715            //     "25-11-2017 13:31:15 PST",
716            //     Utc.ymd(2017, 11, 25).and_hms(21, 31, 15),
717            //     Trunc::None,
718            // ),
719            // (
720            //     "dmy",
721            //     "21-02-2021",
722            //     // Utc.ymd(2021, 2, 21).and_time(Utc::now().time()).unwrap(),
723            //     Utc.with_ymd_and_hms(2021, 2, 21, current_hour, current_minute, current_second)
724            //         .unwrap(),
725            //     Trunc::Seconds,
726            // ),
727            // (
728            //     "dmy_z",
729            //     "21-02-2021 PST",
730            //     FixedOffset::west(8 * 3600)
731            //         .ymd(2021, 2, 21)
732            //         .and_time(
733            //             Utc::now()
734            //                 .with_timezone(&FixedOffset::west(8 * 3600))
735            //                 .time(),
736            //         )
737            //         .unwrap()
738            //         .with_timezone(&Utc),
739            //     Trunc::Seconds,
740            // ),
741            // (
742            //     "month_dmy",
743            //     "21-Feb-2021",
744            //     Utc.ymd(2021, 2, 21).and_time(Utc::now().time()).unwrap(),
745            //     Trunc::Seconds,
746            // ),
747            (
748                "month_mdy_hms",
749                "May 8, 2009 5:57:51 PM",
750                Utc.ymd(2009, 5, 8).and_hms(17, 57, 51),
751                Trunc::None,
752            ),
753            (
754                "month_mdy_hms_z",
755                "May 02, 2021 15:51 UTC",
756                Utc.ymd(2021, 5, 2).and_hms(15, 51, 0),
757                Trunc::None,
758            ),
759            (
760                "month_mdy",
761                "May 25, 2021",
762                Utc.ymd(2021, 5, 25).and_time(Utc::now().time()).unwrap(),
763                Trunc::Seconds,
764            ),
765            (
766                "month_dmy_hms",
767                "14 May 2019 19:11:40.164",
768                Utc.ymd(2019, 5, 14).and_hms_milli(19, 11, 40, 164),
769                Trunc::None,
770            ),
771            (
772                "month_dmy",
773                "1 July 2013",
774                Utc.ymd(2013, 7, 1).and_time(Utc::now().time()).unwrap(),
775                Trunc::Seconds,
776            ),
777            (
778                "slash_dmy_hms",
779                "19/03/2012 10:11:59",
780                Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
781                Trunc::None,
782            ),
783            (
784                "slash_dmy",
785                "21/08/71",
786                Utc.ymd(1971, 8, 21).and_time(Utc::now().time()).unwrap(),
787                Trunc::Seconds,
788            ),
789            (
790                "slash_dmy_hms",
791                "19/03/2012 10:11:59",
792                Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
793                Trunc::None,
794            ),
795            (
796                "slash_dmy",
797                "31/3/2014",
798                Utc.ymd(2014, 3, 31).and_time(Utc::now().time()).unwrap(),
799                Trunc::Seconds,
800            ),
801        ];
802
803        for &(test, input, want, trunc) in test_cases.iter() {
804            match trunc {
805                Trunc::None => {
806                    assert_eq!(
807                        super::parse_with_preference_and_timezone(input, true, &Utc).unwrap(),
808                        want,
809                        "parse_with_preference_and_timezone_in_utc/{}/{}",
810                        test,
811                        input
812                    )
813                }
814                Trunc::Seconds => assert_eq!(
815                    super::parse_with_preference_and_timezone(input, true, &Utc)
816                        .unwrap()
817                        .trunc_subsecs(0)
818                        .with_hour(current_hour)
819                        .unwrap()
820                        .with_minute(current_minute)
821                        .unwrap()
822                        .with_second(0)
823                        .unwrap(),
824                    want.trunc_subsecs(0).with_second(0).unwrap(),
825                    "parse_with_preference_and_timezone_in_utc/{}/{}",
826                    test,
827                    input
828                ),
829            };
830        }
831    }
832
833    #[test]
834    fn parse_unambiguous_dmy() {
835        // `parse()` uses Local timezone and pads date-only inputs with the
836        // current time of day, so the resulting UTC date can roll by ±1 day
837        // depending on host TZ and the moment the test runs. Assert on the
838        // Local date — that's what `parse()` actually models for this input.
839        assert_eq!(
840            super::parse("31/3/22")
841                .unwrap()
842                .with_timezone(&Local)
843                .date(),
844            Local.ymd(2022, 3, 31)
845        );
846        assert_eq!(
847            super::parse_with_preference("3/31/22", true)
848                .unwrap()
849                .date(),
850            Utc.ymd(2022, 3, 31)
851        );
852        assert_eq!(
853            super::parse_with_preference("31/07/2021", true)
854                .unwrap()
855                .date(),
856            Utc.ymd(2021, 7, 31)
857        );
858    }
859
860    // Regression: ISO 8601 with 'T' separator and no timezone (e.g. Python's
861    // datetime.isoformat() without astimezone) must parse via the naive
862    // wall-clock path, matching the equivalent space-separated form.
863    #[test]
864    fn parse_iso_t_no_tz() {
865        // Bare T, no fractional, no tz.
866        let got = super::parse_with_preference("2020-01-15T08:00:00", false).unwrap();
867        assert_eq!(got, Utc.ymd(2020, 1, 15).and_hms(8, 0, 0));
868
869        // T, no seconds, no tz.
870        let got = super::parse_with_preference("2020-01-15T08:00", false).unwrap();
871        assert_eq!(got, Utc.ymd(2020, 1, 15).and_hms(8, 0, 0));
872
873        // T with millisecond + microsecond + nanosecond precision.
874        for (input, want) in [
875            (
876                "2020-01-15T08:00:00.123",
877                Utc.ymd(2020, 1, 15).and_hms_milli(8, 0, 0, 123),
878            ),
879            (
880                "2020-01-15T08:00:00.123456",
881                Utc.ymd(2020, 1, 15).and_hms_micro(8, 0, 0, 123456),
882            ),
883            (
884                "2020-01-15T08:00:00.123456789",
885                Utc.ymd(2020, 1, 15).and_hms_nano(8, 0, 0, 123456789),
886            ),
887        ] {
888            assert_eq!(
889                super::parse_with_preference(input, false).unwrap(),
890                want,
891                "parse_iso_t_no_tz/{input}"
892            );
893        }
894
895        // T-form and space-form must produce the same instant.
896        assert_eq!(
897            super::parse_with_preference("2020-01-15T08:00:00", false).unwrap(),
898            super::parse_with_preference("2020-01-15 08:00:00", false).unwrap(),
899        );
900
901        // Existing tz-bearing T-forms must continue to parse (no regression).
902        assert!(super::parse_with_preference("2020-01-15T08:00:00Z", false).is_ok());
903        assert!(super::parse_with_preference("2020-01-15T08:00:00+00:00", false).is_ok());
904    }
905
906    // Structural pre-filter: inputs containing a byte that cannot appear in any
907    // accepted date format (e.g. '_', '#', non-ASCII) must fail fast, while every
908    // currently-accepted input must still parse. Correctness guard for the
909    // pre-filter optimization (must not change which strings parse).
910    #[test]
911    fn prefilter_rejects_non_date_strings() {
912        // The qsv-dateparser-opt failure hot path: '_' is not a valid date byte.
913        for input in [
914            "category_value_123",
915            "first_name",
916            "value#42",
917            "a(b)c1",
918            "100%",
919            "naïve_2020", // non-ASCII byte
920        ] {
921            assert!(
922                super::parse(input).is_err(),
923                "prefilter should reject {input}"
924            );
925        }
926
927        // Pre-filter must NOT reject anything that currently parses. Spot-check
928        // every separator family.
929        for input in [
930            "2021-04-30 21:14:10",           // '-' ':' space
931            "2020-07-20+08:00",              // '+'
932            "03/19/2012 10:11:59.3186369",   // '/' '.'
933            "May 26, 2021, 12:49 AM PDT",    // ',' letters
934            "Wed, 02 Jun 2021 06:31:39 GMT", // rfc2822
935            "1671673426.123456789",          // timestamp with '.'
936            "-770172300",                    // negative timestamp
937        ] {
938            assert!(
939                super::parse(input).is_ok(),
940                "prefilter must not reject {input}"
941            );
942        }
943    }
944}