diff --git a/Cargo.lock b/Cargo.lock index 8a3852b..246810d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "aho-corasick" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" -dependencies = [ - "memchr", -] - [[package]] name = "android-tzdata" version = "0.1.1" @@ -26,12 +17,73 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c677ab05e09154296dd37acecd46420c17b9713e8366facafa8fc0885167cf4c" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bumpalo" version = "3.13.0" @@ -52,34 +104,62 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "winapi", ] +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "core-foundation-sys" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows", ] [[package]] @@ -91,11 +171,33 @@ dependencies = [ "cc", ] +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "js-sys" -version = "0.3.70" +version = "0.3.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" dependencies = [ "wasm-bindgen", ] @@ -106,6 +208,12 @@ version = "0.2.144" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + [[package]] name = "log" version = "0.4.18" @@ -114,31 +222,15 @@ checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" [[package]] name = "memchr" -version = "2.6.4" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -151,11 +243,12 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.6.0" +version = "0.7.0" dependencies = [ + "anyhow", "chrono", - "nom", - "regex", + "num-traits", + "winnow", ] [[package]] @@ -177,34 +270,19 @@ dependencies = [ ] [[package]] -name = "regex" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.8" +name = "rustix" +version = "0.37.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", ] -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - [[package]] name = "syn" version = "2.0.18" @@ -216,28 +294,43 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "terminal_size" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" +dependencies = [ + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "unicode-ident" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" dependencies = [ "cfg-if", - "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" dependencies = [ "bumpalo", "log", @@ -250,9 +343,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -260,9 +353,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" dependencies = [ "proc-macro2", "quote", @@ -273,79 +366,189 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" [[package]] -name = "windows-core" +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" dependencies = [ - "windows-targets", + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", ] [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cf47b659b318dccbd69cc4797a39ae128f533dce7902a1096044d1967b9c16" +dependencies = [ + "anstream", + "anstyle", + "is-terminal", + "memchr", + "terminal_size", +] diff --git a/Cargo.toml b/Cargo.toml index 7f2c4d9..157d5cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,20 @@ [package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" -version = "0.6.0" +version = "0.7.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" readme = "README.md" [dependencies] -regex = "1.10.4" -chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] } -nom = "7.1.3" +chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } +num-traits = "0.2.19" +winnow = { version="0.5.34"} + +[dev-dependencies] +anyhow = "1.0.86" +#winnow = { version="0.5.34", features = ["debug"] } + +[features] +debug = ["winnow/debug"] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 6e6a361..d19632d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -26,6 +26,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c677ab05e09154296dd37acecd46420c17b9713e8366facafa8fc0885167cf4c" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + [[package]] name = "arbitrary" version = "1.3.0" @@ -38,6 +87,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bumpalo" version = "3.13.0" @@ -61,22 +116,38 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets 0.52.6", + "winapi", ] +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "core-foundation-sys" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "fuzz_parse_datetime" version = "0.2.0" @@ -99,6 +170,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "iana-time-zone" version = "0.1.56" @@ -122,6 +205,28 @@ dependencies = [ "cc", ] +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "jobserver" version = "0.1.26" @@ -157,6 +262,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + [[package]] name = "log" version = "0.4.18" @@ -169,27 +280,11 @@ version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -202,11 +297,11 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.6.0" +version = "0.7.0" dependencies = [ "chrono", - "nom", - "regex", + "num-traits", + "winnow", ] [[package]] @@ -292,6 +387,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustix" +version = "0.37.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + [[package]] name = "syn" version = "2.0.18" @@ -303,12 +412,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "terminal_size" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" +dependencies = [ + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "unicode-ident" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -369,6 +494,28 @@ version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.48.0" @@ -378,6 +525,24 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.0" @@ -498,3 +663,16 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "anstream", + "anstyle", + "is-terminal", + "memchr", + "terminal_size", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index d8d56b8..4ff97ba 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -15,6 +15,9 @@ chrono = { version="0.4", default-features=false, features=["std", "alloc", "clo [dependencies.parse_datetime] path = "../" +[features] +debug = ["parse_datetime/debug"] + [[bin]] name = "fuzz_parse_datetime" path = "fuzz_targets/parse_datetime.rs" diff --git a/fuzz/fuzz_targets/parse_datetime.rs b/fuzz/fuzz_targets/parse_datetime.rs index 289bbb3..43a693f 100644 --- a/fuzz/fuzz_targets/parse_datetime.rs +++ b/fuzz/fuzz_targets/parse_datetime.rs @@ -1,8 +1,133 @@ #![no_main] +#![allow(dead_code)] -use libfuzzer_sys::fuzz_target; +use std::fmt::{Debug, Display}; -fuzz_target!(|data: &[u8]| { - let s = std::str::from_utf8(data).unwrap_or(""); - let _ = parse_datetime::parse_datetime(s); +use libfuzzer_sys::arbitrary::{self, Arbitrary}; + +#[macro_use] +extern crate libfuzzer_sys; + +#[derive(Debug)] +struct Format(&'static str); + +// These are formats to test the compatibility with GNU +const FORMATS: &[&str] = &["%G-%m-%d %H:%M:%S", "%b %d %Y %H:%M:%S"]; + +impl<'a> Arbitrary<'a> for Format { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Format(u.choose(FORMATS)?)) + } +} + +struct Input { + year: u32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, + format: Format, +} + +impl<'a> Arbitrary<'a> for Input { + fn arbitrary( + u: &mut libfuzzer_sys::arbitrary::Unstructured<'a>, + ) -> libfuzzer_sys::arbitrary::Result { + let year = u.arbitrary::()?; + let month = u.arbitrary::()?; + let day = u.arbitrary::()?; + let hour = u.arbitrary::()?; + let minute = u.arbitrary::()?; + let second = u.arbitrary::()?; + let format = u.arbitrary::()?; + + // GNU max 2147485547 + // chrono max 262143 + // chrono outputs + before the year if it is >9999 + if !(1..=9999).contains(&year) + || !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..24).contains(&hour) + || !(0..60).contains(&minute) + || !(0..60).contains(&second) + { + return Err(crate::arbitrary::Error::IncorrectFormat); + } + + Ok(Input { + year, + month, + day, + hour, + minute, + second, + format, + }) + } +} + +impl Input { + fn format(&self) -> String { + let Input { + year, + month, + day, + hour, + minute, + second, + format, + } = self; + let as_string = format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}"); + std::process::Command::new("date") + .arg("-d") + .arg(as_string) + .arg(format!("+{}", &format.0)) + .output() + .map(|mut output| { + output.stdout.pop(); // remove trailing \n + String::from_utf8(output.stdout).expect("from_utf8") + }) + .expect("gnu date") + } +} + +impl Display for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.format()) + } +} + +impl Debug for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.format()) + } +} + +fuzz_target!(|input: Input| { + let fmt = input.format.0; + let gnu = std::process::Command::new("date") + .arg("-d") + .arg(input.format()) + .arg(format!("+{fmt}")) + .output() + .map(|mut output| { + output.stdout.pop(); // remove trailing \n + String::from_utf8(output.stdout).expect("from_utf8") + }); + let us = parse_datetime::parse_datetime(&input.format()).map(|d| d.format(fmt).to_string()); + + match (us, gnu) { + (Ok(us), Ok(gnu)) => assert_eq!( + us, gnu, + "\n\nGNU Incompatibility found for the input: {input}\nExpected: {gnu}\nFound: {us}\n\n" + ), + (Err(_), Err(_)) => (), + (Ok(us), Err(e)) => { + panic!("Expecting to fail, but succeeded for input `{input}`, gnu error: {e}, parsed date: {us}") + } + (Err(_), Ok(gnu)) => { + panic!("Expecting to succeed, but failed for input `{input}`, gnu output: {gnu}") + } + }; }); diff --git a/src/items/combined.rs b/src/items/combined.rs new file mode 100644 index 0000000..3d02eac --- /dev/null +++ b/src/items/combined.rs @@ -0,0 +1,109 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse an ISO 8601 date and time item +//! +//! The GNU docs state: +//! +//! > The ISO 8601 date and time of day extended format consists of an ISO 8601 +//! > date, a ‘T’ character separator, and an ISO 8601 time of day. This format +//! > is also recognized if the ‘T’ is replaced by a space. +//! > +//! > In this format, the time of day should use 24-hour notation. Fractional +//! > seconds are allowed, with either comma or period preceding the fraction. +//! > ISO 8601 fractional minutes and hours are not supported. Typically, hosts +//! > support nanosecond timestamp resolution; excess precision is silently discarded. +#![allow(deprecated)] + +use winnow::ascii::dec_uint; +use winnow::token::take; +use winnow::{combinator::alt, seq, trace::trace, PResult, Parser}; + +use crate::items::combined; +use crate::items::space; + +use super::{ + date::{self, Date}, + s, + time::{self, Time}, +}; + +#[derive(PartialEq, Debug, Clone, Default)] +pub struct DateTime { + pub(crate) date: Date, + pub(crate) time: Time, +} + +pub fn parse(input: &mut &str) -> PResult { + alt((parse_basic, parse_8digits)).parse_next(input) +} + +fn parse_basic(input: &mut &str) -> PResult { + seq!(DateTime { + date: trace("date iso", date::iso), + // Note: the `T` is lowercased by the main parse function + _: alt((s('t').void(), (' ', space).void())), + time: trace("time iso", time::iso), + }) + .parse_next(input) +} + +fn parse_8digits(input: &mut &str) -> PResult { + s(( + take(2usize).and_then(dec_uint), + take(2usize).and_then(dec_uint), + take(2usize).and_then(dec_uint), + take(2usize).and_then(dec_uint), + )) + .map( + |(hour, minute, day, month): (u32, u32, u32, u32)| combined::DateTime { + date: date::Date { + day, + month, + year: None, + }, + time: time::Time { + hour, + minute, + second: 0.0, + offset: None, + }, + }, + ) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::{parse, DateTime}; + use crate::items::{date::Date, time::Time}; + + #[test] + fn some_date() { + let reference = Some(DateTime { + date: Date { + day: 10, + month: 10, + year: Some(2022), + }, + time: Time { + hour: 10, + minute: 10, + second: 55.0, + offset: None, + }, + }); + + for mut s in [ + "2022-10-10t10:10:55", + "2022-10-10 10:10:55", + "2022-10-10 t 10:10:55", + "2022-10-10 10:10:55", + "2022-10-10 (A comment!) t 10:10:55", + "2022-10-10 (A comment!) 10:10:55", + ] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).ok(), reference, "Failed string: {old_s}") + } + } +} diff --git a/src/items/date.rs b/src/items/date.rs new file mode 100644 index 0000000..23a2488 --- /dev/null +++ b/src/items/date.rs @@ -0,0 +1,268 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse a date item (without time component) +//! +//! The GNU docs say: +//! +//! > A calendar date item specifies a day of the year. It is specified +//! > differently, depending on whether the month is specified numerically +//! > or literally. +//! > +//! > ... +//! > +//! > For numeric months, the ISO 8601 format ‘year-month-day’ is allowed, +//! > where year is any positive number, month is a number between 01 and +//! > 12, and day is a number between 01 and 31. A leading zero must be +//! > present if a number is less than ten. If year is 68 or smaller, then +//! > 2000 is added to it; otherwise, if year is less than 100, then 1900 +//! > is added to it. The construct ‘month/day/year’, popular in the United +//! > States, is accepted. Also ‘month/day’, omitting the year. +//! > +//! > Literal months may be spelled out in full: ‘January’, ‘February’, +//! > ‘March’, ‘April’, ‘May’, ‘June’, ‘July’, ‘August’, ‘September’, +//! > ‘October’, ‘November’ or ‘December’. Literal months may be +//! > abbreviated to their first three letters, possibly followed by an +//! > abbreviating dot. It is also permitted to write ‘Sept’ instead of +//! > ‘September’. + +use winnow::{ + ascii::{alpha1, dec_uint}, + combinator::{alt, opt, preceded}, + seq, + stream::AsChar, + token::take_while, + trace::trace, + PResult, Parser, +}; + +use super::s; +use crate::ParseDateTimeError; + +#[derive(PartialEq, Eq, Clone, Debug, Default)] +pub struct Date { + pub day: u32, + pub month: u32, + pub year: Option, +} + +pub fn parse(input: &mut &str) -> PResult { + alt((iso, us, literal1, literal2)).parse_next(input) +} + +/// Parse `YYYY-MM-DD` or `YY-MM-DD` +/// +/// This is also used by [`combined`](super::combined). +pub fn iso(input: &mut &str) -> PResult { + seq!(Date { + year: year.map(Some), + _: s('-'), + month: month, + _: s('-'), + day: day, + }) + .parse_next(input) +} + +/// Parse `MM/DD/YYYY`, `MM/DD/YY` or `MM/DD` +fn us(input: &mut &str) -> PResult { + seq!(Date { + month: month, + _: s('/'), + day: day, + year: opt(preceded(s('/'), year)), + }) + .parse_next(input) +} + +/// Parse `14 November 2022`, `14 Nov 2022`, "14nov2022", "14-nov-2022", "14-nov2022", "14nov-2022" +fn literal1(input: &mut &str) -> PResult { + seq!(Date { + day: day, + _: opt(s('-')), + month: literal_month, + year: opt(preceded(opt(s('-')), year)), + }) + .parse_next(input) +} + +/// Parse `November 14, 2022` and `Nov 14, 2022` +fn literal2(input: &mut &str) -> PResult { + seq!(Date { + month: literal_month, + day: day, + // FIXME: GNU requires _some_ space between the day and the year, + // probably to distinguish with floats. + year: opt(preceded(s(","), year)), + }) + .parse_next(input) +} + +pub fn year(input: &mut &str) -> PResult { + // 2147485547 is the maximum value accepted + // by GNU, but chrono only behave like GNU + // for years in the range: [0, 9999], so we + // keep in the range [0, 9999] + trace( + "year", + s( + take_while(1..=4, AsChar::is_dec_digit).map(|number_str: &str| { + let year = number_str.parse::().unwrap(); + if number_str.len() == 2 { + if year <= 68 { + year + 2000 + } else { + year + 1900 + } + } else { + year + } + }), + ), + ) + .parse_next(input) +} + +fn month(input: &mut &str) -> PResult { + s(dec_uint) + .try_map(|x| { + (1..=12) + .contains(&x) + .then_some(x) + .ok_or(ParseDateTimeError::InvalidInput) + }) + .parse_next(input) +} + +fn day(input: &mut &str) -> PResult { + s(dec_uint) + .try_map(|x| { + (1..=31) + .contains(&x) + .then_some(x) + .ok_or(ParseDateTimeError::InvalidInput) + }) + .parse_next(input) +} + +/// Parse the name of a month (case-insensitive) +fn literal_month(input: &mut &str) -> PResult { + s(alpha1) + .verify_map(|s: &str| { + Some(match s { + "january" | "jan" => 1, + "february" | "feb" => 2, + "march" | "mar" => 3, + "april" | "apr" => 4, + "may" => 5, + "june" | "jun" => 6, + "july" | "jul" => 7, + "august" | "aug" => 8, + "september" | "sep" | "sept" => 9, + "october" | "oct" => 10, + "november" | "nov" => 11, + "december" | "dec" => 12, + _ => return None, + }) + }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::{parse, Date}; + + // Test cases from the GNU docs: + // + // ``` + // 2022-11-14 # ISO 8601. + // 22-11-14 # Assume 19xx for 69 through 99, + // # 20xx for 00 through 68 (not recommended). + // 11/14/2022 # Common U.S. writing. + // 14 November 2022 + // 14 Nov 2022 # Three-letter abbreviations always allowed. + // November 14, 2022 + // 14-nov-2022 + // 14nov2022 + // ``` + + #[test] + fn with_year() { + let reference = Date { + year: Some(2022), + month: 11, + day: 14, + }; + + for mut s in [ + "2022-11-14", + "2022 - 11 - 14", + "22-11-14", + "2022---11----14", + "22(comment 1)-11(comment 2)-14", + "11/14/2022", + "11--/14--/2022", + "11(comment 1)/(comment 2)14(comment 3)/(comment 4)2022", + "11 / 14 / 2022", + "11/14/22", + "14 november 2022", + "14 nov 2022", + "november 14, 2022", + "november 14 , 2022", + "nov 14, 2022", + "14-nov-2022", + "14nov2022", + "14nov 2022", + ] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + } + + #[test] + fn no_year() { + let reference = Date { + year: None, + month: 11, + day: 14, + }; + for mut s in [ + "11/14", + "14 november", + "14 nov", + "14(comment!)nov", + "november 14", + "november(comment!)14", + "nov 14", + "14-nov", + "14nov", + "14(comment????)nov", + ] { + assert_eq!(parse(&mut s).unwrap(), reference); + } + } + + #[test] + fn test_year() { + use super::year; + + // the minimun input length is 2 + // assert!(year(&mut "0").is_err()); + // -> GNU accepts year 0 + // test $(date -d '1-1-1' '+%Y') -eq '0001' + + // test $(date -d '68-1-1' '+%Y') -eq '2068' + // 2-characters are converted to 19XX/20XX + assert_eq!(year(&mut "10").unwrap(), 2010u32); + assert_eq!(year(&mut "68").unwrap(), 2068u32); + assert_eq!(year(&mut "69").unwrap(), 1969u32); + assert_eq!(year(&mut "99").unwrap(), 1999u32); + // 3,4-characters are converted verbatim + assert_eq!(year(&mut "468").unwrap(), 468u32); + assert_eq!(year(&mut "469").unwrap(), 469u32); + assert_eq!(year(&mut "1568").unwrap(), 1568u32); + assert_eq!(year(&mut "1569").unwrap(), 1569u32); + // consumes at most 4 characters from the input + //assert_eq!(year(&mut "1234567").unwrap(), 1234u32); + } +} diff --git a/src/items/mod.rs b/src/items/mod.rs new file mode 100644 index 0000000..b35142e --- /dev/null +++ b/src/items/mod.rs @@ -0,0 +1,426 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore multispace0 + +//! From the GNU docs: +//! +//! > A date is a string, possibly empty, containing many items separated by +//! > whitespace. The whitespace may be omitted when no ambiguity arises. The +//! > empty string means the beginning of today (i.e., midnight). Order of the +//! > items is immaterial. A date string may contain many flavors of items: +//! > - calendar date items +//! > - time of day items +//! > - time zone items +//! > - combined date and time of day items +//! > - day of the week items +//! > - relative items +//! > - pure numbers. +//! +//! We put all of those in separate modules: +//! - [`date`] +//! - [`time`] +//! - [`time_zone`] +//! - [`combined`] +//! - [`weekday`] +//! - [`relative`] +//! - [`number] + +#![allow(deprecated)] +mod combined; +mod date; +mod ordinal; +mod relative; +mod time; +mod weekday; +mod epoch { + use winnow::{ascii::dec_int, combinator::preceded, PResult, Parser}; + + use super::s; + pub fn parse(input: &mut &str) -> PResult { + s(preceded("@", dec_int)).parse_next(input) + } +} +mod timezone { + use super::time; + use winnow::PResult; + + pub(crate) fn parse(input: &mut &str) -> PResult { + time::timezone(input) + } +} + +use chrono::NaiveDate; +use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike}; + +use winnow::error::ParserError; +use winnow::error::{ContextError, ErrMode, ParseError}; +use winnow::trace::trace; +use winnow::{ + ascii::multispace0, + combinator::{alt, delimited, not, peek, preceded, repeat, separated, terminated}, + stream::AsChar, + token::{none_of, take_while}, + PResult, Parser, +}; + +use crate::ParseDateTimeError; + +#[derive(PartialEq, Debug)] +pub enum Item { + Timestamp(i32), + Year(u32), + DateTime(combined::DateTime), + Date(date::Date), + Time(time::Time), + Weekday(weekday::Weekday), + Relative(relative::Relative), + TimeZone(time::Offset), +} + +/// Allow spaces and comments before a parser +/// +/// Every token parser should be wrapped in this to allow spaces and comments. +/// It is only preceding, because that allows us to check mandatory whitespace +/// after running the parser. +fn s<'a, O, E>(p: impl Parser<&'a str, O, E>) -> impl Parser<&'a str, O, E> +where + E: ParserError<&'a str>, +{ + preceded(space, p) +} + +/// Parse the space in-between tokens +/// +/// You probably want to use the [`s`] combinator instead. +fn space<'a, E>(input: &mut &'a str) -> PResult<(), E> +where + E: ParserError<&'a str>, +{ + separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) +} + +/// Check for the end of a token, without consuming the input +/// succeedes if the next character in the input is a space or +/// if the input is empty +pub(crate) fn eotoken(input: &mut &str) -> PResult<()> { + if input.is_empty() || input.chars().next().unwrap().is_space() { + return Ok(()); + } + + Err(ErrMode::Backtrack(ContextError::new())) +} + +/// A hyphen or plus is ignored when it is not followed by a digit +/// +/// This includes being followed by a comment! Compare these inputs: +/// ```txt +/// - 12 weeks +/// - (comment) 12 weeks +/// ``` +/// The last comment should be ignored. +/// +/// The plus is undocumented, but it seems to be ignored. +fn ignored_hyphen_or_plus<'a, E>(input: &mut &'a str) -> PResult<(), E> +where + E: ParserError<&'a str>, +{ + ( + alt(('-', '+')), + multispace0, + peek(not(take_while(1, AsChar::is_dec_digit))), + ) + .void() + .parse_next(input) +} + +/// Parse a comment +/// +/// A comment is given between parentheses, which must be balanced. Any other +/// tokens can be within the comment. +fn comment<'a, E>(input: &mut &'a str) -> PResult<(), E> +where + E: ParserError<&'a str>, +{ + delimited( + '(', + repeat(0.., alt((none_of(['(', ')']).void(), comment))), + ')', + ) + .parse_next(input) +} + +// Parse an item +pub fn parse_one(input: &mut &str) -> PResult { + // eprintln!("parsing_one -> {input}"); + let result = trace( + "parse_one", + alt(( + combined::parse.map(Item::DateTime), + date::parse.map(Item::Date), + time::parse.map(Item::Time), + relative::parse.map(Item::Relative), + weekday::parse.map(Item::Weekday), + epoch::parse.map(Item::Timestamp), + timezone::parse.map(Item::TimeZone), + date::year.map(Item::Year), + )), + ) + .parse_next(input)?; + // eprintln!("parsing_one <- {input} {result:?}"); + + Ok(result) +} + +pub fn parse<'a>( + input: &'a mut &str, +) -> Result, ParseError<&'a str, winnow::error::ContextError>> { + terminated(repeat(0.., parse_one), space).parse(input) +} + +fn new_date( + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, + offset: FixedOffset, +) -> Option> { + let newdate = NaiveDate::from_ymd_opt(year, month, day) + .and_then(|naive| naive.and_hms_opt(hour, minute, second))?; + + Some(DateTime::::from_local(newdate, offset)) +} + +/// Restores year, month, day, etc after applying the timezone +/// returns None if timezone overflows the date +fn with_timezone_restore( + offset: time::Offset, + at: DateTime, +) -> Option> { + let offset: FixedOffset = chrono::FixedOffset::from(offset); + let copy = at; + let x = at + .with_timezone(&offset) + .with_day(copy.day())? + .with_month(copy.month())? + .with_year(copy.year())? + .with_hour(copy.hour())? + .with_minute(copy.minute())? + .with_second(copy.second())?; + Some(x) +} + +fn last_day_of_month(year: i32, month: u32) -> u32 { + NaiveDate::from_ymd_opt(year, month + 1, 1) + .unwrap_or(NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()) + .pred_opt() + .unwrap() + .day() +} + +fn at_date_inner(date: Vec, mut d: DateTime) -> Option> { + d = d.with_hour(0).unwrap(); + d = d.with_minute(0).unwrap(); + d = d.with_second(0).unwrap(); + d = d.with_nanosecond(0).unwrap(); + + for item in date { + match item { + Item::Timestamp(ts) => { + d = chrono::Utc + .timestamp_opt(ts.into(), 0) + .unwrap() + .with_timezone(&d.timezone()) + } + Item::Date(date::Date { day, month, year }) => { + d = new_date( + year.map(|x| x as i32).unwrap_or(d.year()), + month, + day, + d.hour(), + d.minute(), + d.second(), + *d.offset(), + )?; + } + Item::DateTime(combined::DateTime { + date: date::Date { day, month, year }, + time: + time::Time { + hour, + minute, + second, + offset, + }, + .. + }) => { + let offset = offset.map(chrono::FixedOffset::from).unwrap_or(*d.offset()); + + d = new_date( + year.map(|x| x as i32).unwrap_or(d.year()), + month, + day, + hour, + minute, + second as u32, + offset, + )?; + } + Item::Year(year) => d = d.with_year(year as i32).unwrap_or(d), + Item::Time(time::Time { + hour, + minute, + second, + offset, + }) => { + let offset = offset.map(chrono::FixedOffset::from).unwrap_or(*d.offset()); + d = new_date( + d.year(), + d.month(), + d.day(), + hour, + minute, + second as u32, + offset, + )?; + } + Item::Weekday(weekday::Weekday { + offset: _, // TODO: use the offset + day, + }) => { + let mut beginning_of_day = d + .with_hour(0) + .unwrap() + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(); + let day = day.into(); + + while beginning_of_day.weekday() != day { + beginning_of_day += chrono::Duration::days(1); + } + + d = beginning_of_day + } + Item::Relative(relative::Relative::Years(x)) => { + d = d.with_year(d.year() + x)?; + } + Item::Relative(relative::Relative::Months(x)) => { + // *NOTE* This is done in this way to conform to + // GNU behavior. + let days = last_day_of_month(d.year(), d.month()); + d += d + .date_naive() + .checked_add_days(chrono::Days::new((days * x as u32) as u64))? + .signed_duration_since(d.date_naive()); + } + Item::Relative(relative::Relative::Days(x)) => d += chrono::Duration::days(x.into()), + Item::Relative(relative::Relative::Hours(x)) => d += chrono::Duration::hours(x.into()), + Item::Relative(relative::Relative::Minutes(x)) => { + d += chrono::Duration::minutes(x.into()); + } + // Seconds are special because they can be given as a float + Item::Relative(relative::Relative::Seconds(x)) => { + d += chrono::Duration::seconds(x as i64); + } + Item::TimeZone(offset) => { + d = with_timezone_restore(offset, d)?; + } + } + } + + Some(d) +} + +pub(crate) fn at_date( + date: Vec, + d: DateTime, +) -> Result, ParseDateTimeError> { + at_date_inner(date, d).ok_or(ParseDateTimeError::InvalidInput) +} + +pub(crate) fn at_local(date: Vec) -> Result, ParseDateTimeError> { + at_date(date, chrono::Local::now().into()) +} + +#[cfg(test)] +mod tests { + use super::{at_date, date::Date, parse, time::Time, Item}; + use chrono::{DateTime, FixedOffset}; + + fn at_utc(date: Vec) -> DateTime { + at_date(date, chrono::Utc::now().fixed_offset()).unwrap() + } + + fn test_eq_fmt(fmt: &str, input: &str) -> String { + let input = input.to_ascii_lowercase(); + parse(&mut input.as_str()) + .map(at_utc) + .map_err(|e| eprintln!("TEST FAILED AT:\n{}", anyhow::format_err!("{e}"))) + .expect("parsing failed during tests") + .format(fmt) + .to_string() + } + + #[test] + fn date_and_time() { + assert_eq!( + parse(&mut " 10:10 2022-12-12 "), + Ok(vec![ + Item::Time(Time { + hour: 10, + minute: 10, + second: 0.0, + offset: None, + }), + Item::Date(Date { + day: 12, + month: 12, + year: Some(2022) + }) + ]) + ); + + // format, expected output, input + assert_eq!("2024-01-02", test_eq_fmt("%Y-%m-%d", "2024-01-02")); + + // https://github.com/uutils/coreutils/issues/6662 + assert_eq!("2005-01-02", test_eq_fmt("%Y-%m-%d", "2005-01-01 +1 day")); + + // https://github.com/uutils/coreutils/issues/6644 + assert_eq!("Jul 16", test_eq_fmt("%b %d", "Jul 16")); + assert_eq!("0718061449", test_eq_fmt("%m%d%H%M%S", "Jul 18 06:14:49")); + assert_eq!( + "07182024061449", + test_eq_fmt("%m%d%Y%H%M%S", "Jul 18, 2024 06:14:49") + ); + assert_eq!( + "07182024061449", + test_eq_fmt("%m%d%Y%H%M%S", "Jul 18 06:14:49 2024") + ); + + // https://github.com/uutils/coreutils/issues/5177 + assert_eq!( + "2023-07-27T13:53:54+00:00", + test_eq_fmt("%+", "@1690466034") + ); + + // https://github.com/uutils/coreutils/issues/6398 + assert_eq!("1111 1111 00", test_eq_fmt("%m%d %H%M %S", "11111111")); + + assert_eq!( + "2024-07-17 06:14:49 +00:00", + test_eq_fmt("%Y-%m-%d %H:%M:%S %Z", "Jul 17 06:14:49 2024 GMT"), + ); + + assert_eq!( + "2024-07-17 06:14:49 -03:00", + test_eq_fmt("%Y-%m-%d %H:%M:%S %Z", "Jul 17 06:14:49 2024 BRT"), + ); + } +} diff --git a/src/items/ordinal.rs b/src/items/ordinal.rs new file mode 100644 index 0000000..8bf65f4 --- /dev/null +++ b/src/items/ordinal.rs @@ -0,0 +1,46 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use super::s; +use winnow::{ + ascii::{alpha1, dec_uint}, + combinator::{alt, opt}, + PResult, Parser, +}; + +pub fn ordinal(input: &mut &str) -> PResult { + alt((text_ordinal, number_ordinal)).parse_next(input) +} + +fn number_ordinal(input: &mut &str) -> PResult { + let sign = opt(alt(('+'.value(1), '-'.value(-1)))).map(|s| s.unwrap_or(1)); + (s(sign), s(dec_uint)) + .verify_map(|(s, u): (i32, u32)| { + let i: i32 = u.try_into().ok()?; + Some(s * i) + }) + .parse_next(input) +} + +fn text_ordinal(input: &mut &str) -> PResult { + s(alpha1) + .verify_map(|s: &str| { + Some(match s { + "last" => -1, + "this" => 0, + "next" | "first" => 1, + "third" => 3, + "fourth" => 4, + "fifth" => 5, + "sixth" => 6, + "seventh" => 7, + "eight" => 8, + "ninth" => 9, + "tenth" => 10, + "eleventh" => 11, + "twelfth" => 12, + _ => return None, + }) + }) + .parse_next(input) +} diff --git a/src/items/relative.rs b/src/items/relative.rs new file mode 100644 index 0000000..b825c58 --- /dev/null +++ b/src/items/relative.rs @@ -0,0 +1,190 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse a relative datetime item +//! +//! The GNU docs state: +//! +//! > The unit of time displacement may be selected by the string ‘year’ or +//! > ‘month’ for moving by whole years or months. These are fuzzy units, as +//! > years and months are not all of equal duration. More precise units are +//! > ‘fortnight’ which is worth 14 days, ‘week’ worth 7 days, ‘day’ worth 24 +//! > hours, ‘hour’ worth 60 minutes, ‘minute’ or ‘min’ worth 60 seconds, and +//! > ‘second’ or ‘sec’ worth one second. An ‘s’ suffix on these units is +//! > accepted and ignored. +//! > +//! > The unit of time may be preceded by a multiplier, given as an optionally +//! > signed number. Unsigned numbers are taken as positively signed. No number +//! > at all implies 1 for a multiplier. Following a relative item by the +//! > string ‘ago’ is equivalent to preceding the unit by a multiplier with +//! > value -1. +//! > +//! > The string ‘tomorrow’ is worth one day in the future (equivalent to +//! > ‘day’), the string ‘yesterday’ is worth one day in the past (equivalent +//! > to ‘day ago’). +//! > +//! > The strings ‘now’ or ‘today’ are relative items corresponding to +//! > zero-valued time displacement, these strings come from the fact a +//! > zero-valued time displacement represents the current time when not +//! > otherwise changed by previous items. They may be used to stress other +//! > items, like in ‘12:00 today’. The string ‘this’ also has the meaning of a +//! > zero-valued time displacement, but is preferred in date strings like +//! > ‘this thursday’. + +use winnow::{ + ascii::{alpha1, float}, + combinator::{alt, opt}, + PResult, Parser, +}; + +use super::{ordinal::ordinal, s}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Relative { + Years(i32), + Months(i32), + Days(i32), + Hours(i32), + Minutes(i32), + // Seconds are special because they can be given as a float + Seconds(f64), +} + +impl Relative { + fn mul(self, n: i32) -> Self { + match self { + Self::Years(x) => Self::Years(n * x), + Self::Months(x) => Self::Months(n * x), + Self::Days(x) => Self::Days(n * x), + Self::Hours(x) => Self::Hours(n * x), + Self::Minutes(x) => Self::Minutes(n * x), + Self::Seconds(x) => Self::Seconds(f64::from(n) * x), + } + } +} + +pub fn parse(input: &mut &str) -> PResult { + alt(( + s("tomorrow").value(Relative::Days(1)), + s("yesterday").value(Relative::Days(-1)), + // For "today" and "now", the unit is arbitrary + s("today").value(Relative::Days(0)), + s("now").value(Relative::Days(0)), + seconds, + other, + )) + .parse_next(input) +} + +fn seconds(input: &mut &str) -> PResult { + ( + opt(alt((s(float), ordinal.map(|x| x as f64)))), + s(alpha1).verify(|s: &str| matches!(s, "seconds" | "second" | "sec" | "secs")), + ago, + ) + .map(|(n, _, ago)| Relative::Seconds(n.unwrap_or(1.0) * if ago { -1.0 } else { 1.0 })) + .parse_next(input) +} + +fn other(input: &mut &str) -> PResult { + (opt(ordinal), integer_unit, ago) + .map(|(n, unit, ago)| unit.mul(n.unwrap_or(1) * if ago { -1 } else { 1 })) + .parse_next(input) +} + +fn ago(input: &mut &str) -> PResult { + opt(s("ago")).map(|o| o.is_some()).parse_next(input) +} + +fn integer_unit(input: &mut &str) -> PResult { + s(alpha1) + .verify_map(|s: &str| { + Some(match s.strip_suffix('s').unwrap_or(s) { + "year" => Relative::Years(1), + "month" => Relative::Months(1), + "fortnight" => Relative::Days(14), + "week" => Relative::Days(7), + "day" => Relative::Days(1), + "hour" => Relative::Hours(1), + "minute" | "min" => Relative::Minutes(1), + _ => return None, + }) + }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::{parse, Relative}; + + #[test] + fn all() { + for (s, rel) in [ + // Seconds + ("second", Relative::Seconds(1.0)), + ("sec", Relative::Seconds(1.0)), + ("seconds", Relative::Seconds(1.0)), + ("secs", Relative::Seconds(1.0)), + ("second ago", Relative::Seconds(-1.0)), + ("3 seconds", Relative::Seconds(3.0)), + ("3.5 seconds", Relative::Seconds(3.5)), + // ("+3.5 seconds", Relative::Seconds(3.5)), + ("3.5 seconds ago", Relative::Seconds(-3.5)), + ("-3.5 seconds ago", Relative::Seconds(3.5)), + // Minutes + ("minute", Relative::Minutes(1)), + ("minutes", Relative::Minutes(1)), + ("min", Relative::Minutes(1)), + ("mins", Relative::Minutes(1)), + ("10 minutes", Relative::Minutes(10)), + ("-10 minutes", Relative::Minutes(-10)), + ("10 minutes ago", Relative::Minutes(-10)), + ("-10 minutes ago", Relative::Minutes(10)), + // Hours + ("hour", Relative::Hours(1)), + ("hours", Relative::Hours(1)), + ("10 hours", Relative::Hours(10)), + ("+10 hours", Relative::Hours(10)), + ("-10 hours", Relative::Hours(-10)), + ("10 hours ago", Relative::Hours(-10)), + ("-10 hours ago", Relative::Hours(10)), + // Days + ("day", Relative::Days(1)), + ("days", Relative::Days(1)), + ("10 days", Relative::Days(10)), + ("+10 days", Relative::Days(10)), + ("-10 days", Relative::Days(-10)), + ("10 days ago", Relative::Days(-10)), + ("-10 days ago", Relative::Days(10)), + // Multiple days + ("fortnight", Relative::Days(14)), + ("fortnights", Relative::Days(14)), + ("2 fortnights ago", Relative::Days(-28)), + ("+2 fortnights ago", Relative::Days(-28)), + ("week", Relative::Days(7)), + ("weeks", Relative::Days(7)), + ("2 weeks ago", Relative::Days(-14)), + // Other + ("year", Relative::Years(1)), + ("years", Relative::Years(1)), + ("month", Relative::Months(1)), + ("months", Relative::Months(1)), + // Special + ("yesterday", Relative::Days(-1)), + ("tomorrow", Relative::Days(1)), + ("today", Relative::Days(0)), + ("now", Relative::Days(0)), + // This something + ("this day", Relative::Days(0)), + ("this second", Relative::Seconds(0.0)), + ("this year", Relative::Years(0)), + // Weird stuff + ("next week ago", Relative::Days(-7)), + ("last week ago", Relative::Days(7)), + ("this week ago", Relative::Days(0)), + ] { + let mut t = s; + assert_eq!(parse(&mut t).ok(), Some(rel), "Failed string: {s}") + } + } +} diff --git a/src/items/time.rs b/src/items/time.rs new file mode 100644 index 0000000..e9f1a5c --- /dev/null +++ b/src/items/time.rs @@ -0,0 +1,809 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore shhmm colonless + +//! Parse a time item (without a date) +//! +//! The GNU docs state: +//! +//! > More generally, the time of day may be given as ‘hour:minute:second’, +//! > where hour is a number between 0 and 23, minute is a number between 0 and +//! > 59, and second is a number between 0 and 59 possibly followed by ‘.’ or +//! > ‘,’ and a fraction containing one or more digits. Alternatively, +//! > ‘:second’ can be omitted, in which case it is taken to be zero. On the +//! > rare hosts that support leap seconds, second may be 60. +//! > +//! > If the time is followed by ‘am’ or ‘pm’ (or ‘a.m.’ or ‘p.m.’), hour is +//! > restricted to run from 1 to 12, and ‘:minute’ may be omitted (taken to be +//! > zero). ‘am’ indicates the first half of the day, ‘pm’ indicates the +//! > second half of the day. In this notation, 12 is the predecessor of 1: +//! > midnight is ‘12am’ while noon is ‘12pm’. (This is the zero-oriented +//! > interpretation of ‘12am’ and ‘12pm’, as opposed to the old tradition +//! > derived from Latin which uses ‘12m’ for noon and ‘12pm’ for midnight.) +//! > +//! > The time may alternatively be followed by a time zone correction, +//! > expressed as ‘shhmm’, where s is ‘+’ or ‘-’, hh is a number of zone hours +//! > and mm is a number of zone minutes. The zone minutes term, mm, may be +//! > omitted, in which case the one- or two-digit correction is interpreted as +//! > a number of hours. You can also separate hh from mm with a colon. When a +//! > time zone correction is given this way, it forces interpretation of the +//! > time relative to Coordinated Universal Time (UTC), overriding any +//! > previous specification for the time zone or the local time zone. For +//! > example, ‘+0530’ and ‘+05:30’ both stand for the time zone 5.5 hours +//! > ahead of UTC (e.g., India). This is the best way to specify a time zone +//! > correction by fractional parts of an hour. The maximum zone correction is +//! > 24 hours. +//! > +//! > Either ‘am’/‘pm’ or a time zone correction may be specified, but not both. + +use std::fmt::Display; + +use chrono::FixedOffset; +use winnow::{ + ascii::{dec_uint, float}, + combinator::{alt, opt, preceded, terminated}, + error::{AddContext, ContextError, ErrMode, StrContext}, + seq, + stream::AsChar, + token::take_while, + PResult, Parser, +}; + +use super::{eotoken, s}; + +#[derive(PartialEq, Debug, Clone, Default)] +pub struct Offset { + pub(crate) negative: bool, + pub(crate) hours: u32, + pub(crate) minutes: u32, +} + +#[derive(PartialEq, Clone, Debug, Default)] +pub struct Time { + pub hour: u32, + pub minute: u32, + pub second: f64, + pub offset: Option, +} + +impl Offset { + fn merge(self, offset: Offset) -> Option { + let Offset { negative, .. } = offset; + fn combine(a: u32, b: u32, negative: bool) -> Option { + if negative { + a.checked_sub(b) + } else { + a.checked_add(b) + } + } + let hours = combine(self.hours, offset.hours, negative)?; + let minutes = combine(self.minutes, offset.minutes, negative)?; + Some(Offset { + negative, + hours, + minutes, + }) + } +} + +impl From for chrono::FixedOffset { + fn from( + Offset { + negative, + hours, + minutes, + }: Offset, + ) -> Self { + let secs = hours * 3600 + minutes * 60; + + if negative { + FixedOffset::west_opt(secs.try_into().expect("secs overflow")) + .expect("timezone overflow") + } else { + FixedOffset::east_opt(secs.try_into().unwrap()).unwrap() + } + } +} + +impl Display for Offset { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + write!( + fmt, + "{}{:02}:{:02}", + if self.negative { "-" } else { "+" }, + self.hours, + self.minutes + ) + } +} + +#[derive(Clone)] +enum Suffix { + Am, + Pm, +} + +pub fn parse(input: &mut &str) -> PResult