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}