diff --git a/Cargo.lock b/Cargo.lock index 3086e36..1283eff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -31,19 +31,13 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.20" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -104,6 +98,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "1.3.3" @@ -128,11 +128,11 @@ dependencies = [ "peeking_take_while", "prettyplease", "proc-macro2", - "quote 1.0.31", + "quote 1.0.45", "regex", "rustc-hash", "shlex", - "syn 2.0.26", + "syn 2.0.117", "which", ] @@ -207,42 +207,29 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.35" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits 0.2.17", "serde", "wasm-bindgen", - "windows-targets 0.52.4", + "windows-link", ] [[package]] name = "chrono-tz" -version = "0.6.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c39203181991a7dd4343b8005bd804e7a9a37afb8ac070e43771e8c820bbde" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "chrono-tz-build", "phf", "serde", ] -[[package]] -name = "chrono-tz-build" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f509c3a87b33437b05e2458750a0700e5bdd6956176773e6c7d6dd15a283a0c" -dependencies = [ - "parse-zoneinfo", - "phf", - "phf_codegen", -] - [[package]] name = "clang-sys" version = "1.6.1" @@ -303,9 +290,9 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "darling" -version = "0.13.4" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -313,27 +300,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.13.4" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", - "quote 1.0.31", + "quote 1.0.45", "strsim", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.13.4" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", - "quote 1.0.31", - "syn 1.0.109", + "quote 1.0.45", + "syn 2.0.117", ] [[package]] @@ -348,12 +334,28 @@ dependencies = [ "ordered-float", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "earcutr" version = "0.4.2" @@ -382,16 +384,16 @@ dependencies = [ ] [[package]] -name = "float_next_after" -version = "1.0.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "fnv" -version = "1.0.7" +name = "float_next_after" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "form_urlencoded" @@ -477,6 +479,18 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heapless" version = "0.7.16" @@ -496,6 +510,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.9" @@ -553,6 +573,29 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + [[package]] name = "itertools" version = "0.10.5" @@ -649,8 +692,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90bfc158b48a4b1b943dd736b42e902a96b567fe6bbbdd20144f85eb56fc66db" dependencies = [ "proc-macro2", - "quote 1.0.31", - "syn 2.0.26", + "quote 1.0.45", + "syn 2.0.117", ] [[package]] @@ -665,18 +708,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.5.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -784,6 +824,12 @@ dependencies = [ "num-traits 0.2.17", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-integer" version = "0.1.45" @@ -860,15 +906,6 @@ dependencies = [ "num-traits 0.2.17", ] -[[package]] -name = "parse-zoneinfo" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" -dependencies = [ - "regex", -] - [[package]] name = "peeking_take_while" version = "0.1.2" @@ -883,41 +920,20 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "phf" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56ac890c5e3ca598bbdeaa99964edb5b0258a583a9eb6ef4e89fc85d9224770" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1181c94580fa345f50f19d738aaa39c0ed30a600d95cb2d3e23f94266f14fbf" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ "phf_shared", - "rand", ] [[package]] name = "phf_shared" -version = "0.11.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ "siphasher", - "uncased", ] [[package]] @@ -926,6 +942,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -953,14 +975,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92139198957b410250d43fad93e630d956499a625c527eda65175c8680f83387" dependencies = [ "proc-macro2", - "syn 2.0.26", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.66" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -973,9 +995,9 @@ checksum = "7a6e920b65c65f10b2ae65c831a81a073a89edd28c7cce89475bff467ab4167a" [[package]] name = "quote" -version = "1.0.31" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -986,21 +1008,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rayon" version = "1.10.0" @@ -1063,6 +1070,7 @@ dependencies = [ "nom 7.1.3", "nom_locate", "pretty_assertions_sorted", + "serde", "unicode-segmentation", ] @@ -1137,7 +1145,7 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3adb7ecc970f8d748d1ca8bea012c4ff2e07ecbd057e68135c4ad014c9f7579" dependencies = [ - "quote 1.0.31", + "quote 1.0.45", "syn 1.0.109", ] @@ -1149,15 +1157,47 @@ checksum = "f0396c62561c0eda86e7b6992bb89f20d283818c5570cbdf7e0f67c77848932e" dependencies = [ "lazy_static", "proc-macro2", - "quote 1.0.31", + "quote 1.0.45", "syn 1.0.109", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote 1.0.45", + "syn 2.0.117", +] + [[package]] name = "regex" -version = "1.7.3" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1166,9 +1206,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "robust" @@ -1178,13 +1218,12 @@ checksum = "cbf4a6aa5f6d6888f39e980649f3ad6b666acdce1d78e95b8a2cb076e687ae30" [[package]] name = "rrule" -version = "0.10.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822efdcd86c668b92c5ddc4c08906b731d184feb3e595575924737495ae928a7" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" dependencies = [ "chrono", "chrono-tz", - "lazy_static", "log", "regex", "serde_with", @@ -1236,6 +1275,30 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1250,44 +1313,76 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" -version = "1.0.162" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b2f6e1ab5c2b98c05f0f35b236b22e8df7ead6ffbf51d7808da7f8817e7ab6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.162" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2a0814352fd64b58489904a44ea8d90cb1a91dcb6b4f5ebabc32c8318e93cb6" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", - "quote 1.0.31", - "syn 2.0.26", + "quote 1.0.45", + "syn 2.0.117", ] [[package]] -name = "serde_with" -version = "1.14.0" +name = "serde_json" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "itoa", + "memchr", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.1", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", "serde_with_macros", + "time", ] [[package]] name = "serde_with_macros" -version = "1.5.2" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ "darling", "proc-macro2", - "quote 1.0.31", - "syn 1.0.109", + "quote 1.0.45", + "syn 2.0.117", ] [[package]] @@ -1304,9 +1399,9 @@ checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" [[package]] name = "siphasher" -version = "0.3.10" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "smallvec" @@ -1347,9 +1442,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum_macros" @@ -1359,7 +1454,7 @@ checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ "heck", "proc-macro2", - "quote 1.0.31", + "quote 1.0.45", "rustversion", "syn 1.0.109", ] @@ -1382,18 +1477,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote 1.0.31", + "quote 1.0.45", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.26" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", - "quote 1.0.31", + "quote 1.0.45", "unicode-ident", ] @@ -1414,22 +1509,53 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "thiserror" -version = "1.0.40" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", - "quote 1.0.31", - "syn 2.0.26", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -1447,15 +1573,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "uncased" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9bc53168a4be7402ab86c3aad243a84dd7381d09be0eddc81280c1da95ca68" -dependencies = [ - "version_check", -] - [[package]] name = "unicode-bidi" version = "0.3.13" @@ -1532,8 +1649,8 @@ dependencies = [ "log", "once_cell", "proc-macro2", - "quote 1.0.31", - "syn 2.0.26", + "quote 1.0.45", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1543,7 +1660,7 @@ version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" dependencies = [ - "quote 1.0.31", + "quote 1.0.45", "wasm-bindgen-macro-support", ] @@ -1554,8 +1671,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" dependencies = [ "proc-macro2", - "quote 1.0.31", - "syn 2.0.26", + "quote 1.0.45", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1608,6 +1725,12 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -1748,3 +1871,9 @@ name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bf81b4c..09d66c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,8 @@ edition = "2021" serde = { version = "1.0.162", features = ["derive"] } libc = "0.2" nom = "6.0" -rrule = { version = "0.10", features = ["serde", "exrule"] } -chrono = { version = "0.4.19", features = ["serde"] } -chrono-tz = "0.6.1" +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" regex = { version = "1.5.5", default-features = false, features = ["perf", "std"] } rstar = { version = "0.11.0", features = ["serde"] } geo = { version = "0.26.0", features = ["use-serde"] } diff --git a/redical_core/Cargo.toml b/redical_core/Cargo.toml index 148e48c..a89c7ce 100644 --- a/redical_core/Cargo.toml +++ b/redical_core/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" [dependencies] serde = { workspace = true } nom = "6.0" -rrule = { version = "0.10", features = ["serde", "exrule"] } +rrule = { version = "0.14", features = ["serde", "exrule"] } chrono = { workspace = true } chrono-tz = { workspace = true } lazy_static = { workspace = true } diff --git a/redical_core/src/event_instance.rs b/redical_core/src/event_instance.rs index db72099..4378259 100644 --- a/redical_core/src/event_instance.rs +++ b/redical_core/src/event_instance.rs @@ -305,7 +305,7 @@ impl Ord for EventInstance { #[derive(Debug)] pub struct EventInstanceIterator<'a> { event: &'a Event, - internal_iter: EventOccurrenceIterator<'a>, + internal_iter: EventOccurrenceIterator, } impl<'a> EventInstanceIterator<'a> { diff --git a/redical_core/src/event_occurrence_iterator.rs b/redical_core/src/event_occurrence_iterator.rs index 6e731a4..0f7d442 100644 --- a/redical_core/src/event_occurrence_iterator.rs +++ b/redical_core/src/event_occurrence_iterator.rs @@ -56,9 +56,9 @@ impl UpperBoundFilterCondition { } #[derive(Debug)] -pub struct EventOccurrenceIterator<'a> { +pub struct EventOccurrenceIterator { event_occurrence_overrides: BTreeMap, - rrule_set_iter: Option>, + rrule_set_iter: Option, base_duration: i64, limit: Option, count: usize, @@ -69,15 +69,15 @@ pub struct EventOccurrenceIterator<'a> { internal_min_max_bounds: Option<(i64, i64)>, } -impl<'a> EventOccurrenceIterator<'a> { +impl EventOccurrenceIterator { pub fn new( - schedule_properties: &'a ScheduleProperties, - event_occurrence_overrides: &'a BTreeMap, + schedule_properties: &ScheduleProperties, + event_occurrence_overrides: &BTreeMap, limit: Option, filter_from: Option, filter_until: Option, filtering_indexed_conclusion: Option, - ) -> Result, String> { + ) -> Result { let rrule_set_iter = schedule_properties.parsed_rrule_set .as_ref() @@ -287,13 +287,16 @@ impl<'a> EventOccurrenceIterator<'a> { fn rrule_set_iter_next(&mut self) -> Option> { match &mut self.rrule_set_iter { - Some(rrule_set_iter) => rrule_set_iter.next(), + Some(rrule_set_iter) => { + Iterator::next(rrule_set_iter) + }, + None => None, } } } -impl Iterator for EventOccurrenceIterator<'_> { +impl Iterator for EventOccurrenceIterator { type Item = (i64, i64, Option); fn next(&mut self) -> Option { if self.is_ended { diff --git a/redical_ical/src/properties/event/dtend.rs b/redical_ical/src/properties/event/dtend.rs index d2538d5..38c7b59 100644 --- a/redical_ical/src/properties/event/dtend.rs +++ b/redical_ical/src/properties/event/dtend.rs @@ -175,6 +175,21 @@ impl ICalendarDateTimeProperty for DTEndProperty { } } +impl DTEndProperty { + /// Resolves `self.date_time` against the TZID param (if present) to handle DST + /// transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that the stored datetime is always a valid wall-clock time in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + self.date_time = tzid.resolve_dst_transition(&self.date_time)?; + } + + Ok(()) + } +} + impl ICalendarEntity for DTEndProperty { fn parse_ical(input: ParserInput) -> ParserResult { context( @@ -188,19 +203,23 @@ impl ICalendarEntity for DTEndProperty { preceded(colon, DateTime::parse_ical), ), |(params, date_time)| { - let dtstart_property = + let mut dtend_property = DTEndProperty { params: params.unwrap_or(DTEndPropertyParams::default()), date_time, }; - if let Err(error) = ICalendarEntity::validate(&dtstart_property) { + // Resolve DST transition gaps/ambiguities before validation. + dtend_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + + if let Err(error) = ICalendarEntity::validate(&dtend_property) { return Err( ParserError::new(error, input) ); } - Ok(dtstart_property) + Ok(dtend_property) } ) ) @@ -217,8 +236,6 @@ impl ICalendarEntity for DTEndProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - tzid.validate_with_datetime_value(&self.date_time)?; }; if let Some(value_type) = self.params.value_type.as_ref() { @@ -270,7 +287,7 @@ mod tests { use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; use chrono_tz::Tz; - use crate::tests::{assert_parser_output, assert_parser_error}; + use crate::tests::assert_parser_output; #[test] fn parse_ical() { @@ -334,18 +351,71 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( DTEndProperty::parse_ical("DTEND;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"DTEND;TZID=Pacific/Auckland:20240929T020000\"", - context: ["DTEND"], + ( + "", + DTEndProperty { + params: DTEndPropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + DTEndProperty::parse_ical("DTEND;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + DTEndProperty { + params: DTEndPropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + DTEndProperty::parse_ical("DTEND;TZID=Europe/London:20261025T011500".into()), + ( + "", + DTEndProperty { + params: DTEndPropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Europe__London)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( DTEndProperty::parse_ical("DTEND;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/properties/event/dtstart.rs b/redical_ical/src/properties/event/dtstart.rs index 9b36a93..0230a18 100644 --- a/redical_ical/src/properties/event/dtstart.rs +++ b/redical_ical/src/properties/event/dtstart.rs @@ -178,6 +178,21 @@ impl ICalendarDateTimeProperty for DTStartProperty { } } +impl DTStartProperty { + /// Resolves `self.date_time` against the TZID param (if present) to handle DST + /// transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that the stored datetime is always a valid wall-clock time in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + self.date_time = tzid.resolve_dst_transition(&self.date_time)?; + } + + Ok(()) + } +} + impl ICalendarEntity for DTStartProperty { fn parse_ical(input: ParserInput) -> ParserResult { context( @@ -191,12 +206,16 @@ impl ICalendarEntity for DTStartProperty { preceded(colon, DateTime::parse_ical), ), |(params, date_time)| { - let dtstart_property = + let mut dtstart_property = DTStartProperty { params: params.unwrap_or(DTStartPropertyParams::default()), date_time, }; + // Resolve DST transition gaps/ambiguities before validation. + dtstart_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + if let Err(error) = ICalendarEntity::validate(&dtstart_property) { return Err( ParserError::new(error, input) @@ -220,8 +239,6 @@ impl ICalendarEntity for DTStartProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - tzid.validate_with_datetime_value(&self.date_time)?; }; if let Some(value_type) = self.params.value_type.as_ref() { @@ -337,18 +354,71 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( DTStartProperty::parse_ical("DTSTART;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"DTSTART;TZID=Pacific/Auckland:20240929T020000\"", - context: ["DTSTART"], + ( + "", + DTStartProperty { + params: DTStartPropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + DTStartProperty::parse_ical("DTSTART;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + DTStartProperty { + params: DTStartPropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + DTStartProperty::parse_ical("DTSTART;TZID=Europe/London:20261025T011500".into()), + ( + "", + DTStartProperty { + params: DTStartPropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Europe__London)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( DTStartProperty::parse_ical("DTSTART;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/properties/event/exdate.rs b/redical_ical/src/properties/event/exdate.rs index 1494713..ab54ed4 100644 --- a/redical_ical/src/properties/event/exdate.rs +++ b/redical_ical/src/properties/event/exdate.rs @@ -182,6 +182,25 @@ impl ExDateProperty { pub fn get_date_times(&self) -> Vec { self.date_times.to_vec() } + + /// Resolves each datetime in `self.date_times` against the TZID param (if present) to + /// handle DST transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that stored datetimes are always valid wall-clock times in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + let resolved: Result, String> = + self.date_times + .iter() + .map(|dt| tzid.resolve_dst_transition(dt)) + .collect(); + + self.date_times = List(resolved?); + } + + Ok(()) + } } impl ICalendarEntity for ExDateProperty { @@ -203,12 +222,16 @@ impl ICalendarEntity for ExDateProperty { ), ), |(params, date_times)| { - let ex_date_property = + let mut ex_date_property = ExDateProperty { params: params.unwrap_or(ExDatePropertyParams::default()), date_times, }; + // Resolve DST transition gaps/ambiguities before validation. + ex_date_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + if let Err(error) = ICalendarEntity::validate(&ex_date_property) { return Err( ParserError::new(error, input) @@ -234,10 +257,6 @@ impl ICalendarEntity for ExDateProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - for date_time in self.date_times.iter() { - tzid.validate_with_datetime_value(date_time)?; - } }; if let Some(value_type) = self.params.value_type.as_ref() { @@ -399,18 +418,77 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( ExDateProperty::parse_ical("EXDATE;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"EXDATE;TZID=Pacific/Auckland:20240929T020000\"", - context: ["EXDATE"], + ( + "", + ExDateProperty { + params: ExDatePropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_times: vec![ + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + ].into(), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + ExDateProperty::parse_ical("EXDATE;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + ExDateProperty { + params: ExDatePropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_times: vec![ + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + ].into(), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + ExDateProperty::parse_ical("EXDATE;TZID=Europe/London:20261025T011500".into()), + ( + "", + ExDateProperty { + params: ExDatePropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Europe__London)), + other: HashMap::new(), + }, + date_times: vec![ + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + ].into(), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( ExDateProperty::parse_ical("EXDATE;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/properties/event/rdate.rs b/redical_ical/src/properties/event/rdate.rs index b160299..8dd6c01 100644 --- a/redical_ical/src/properties/event/rdate.rs +++ b/redical_ical/src/properties/event/rdate.rs @@ -190,6 +190,25 @@ impl RDateProperty { pub fn get_date_times(&self) -> Vec { self.date_times.to_vec() } + + /// Resolves each datetime in `self.date_times` against the TZID param (if present) to + /// handle DST transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that stored datetimes are always valid wall-clock times in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + let resolved: Result, String> = + self.date_times + .iter() + .map(|dt| tzid.resolve_dst_transition(dt)) + .collect(); + + self.date_times = List(resolved?); + } + + Ok(()) + } } impl ICalendarEntity for RDateProperty { @@ -211,12 +230,16 @@ impl ICalendarEntity for RDateProperty { ), ), |(params, date_times)| { - let r_date_property = + let mut r_date_property = RDateProperty { params: params.unwrap_or(RDatePropertyParams::default()), date_times, }; + // Resolve DST transition gaps/ambiguities before validation. + r_date_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + if let Err(error) = ICalendarEntity::validate(&r_date_property) { return Err( ParserError::new(error, input) @@ -242,10 +265,6 @@ impl ICalendarEntity for RDateProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - for date_time in self.date_times.iter() { - tzid.validate_with_datetime_value(date_time)?; - } }; if let Some(value_type) = self.params.value_type.as_ref() { @@ -407,18 +426,77 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( RDateProperty::parse_ical("RDATE;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"RDATE;TZID=Pacific/Auckland:20240929T020000\"", - context: ["RDATE"], + ( + "", + RDateProperty { + params: RDatePropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_times: vec![ + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + ].into(), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + RDateProperty::parse_ical("RDATE;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + RDateProperty { + params: RDatePropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_times: vec![ + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + ].into(), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + RDateProperty::parse_ical("RDATE;TZID=Europe/London:20261025T011500".into()), + ( + "", + RDateProperty { + params: RDatePropertyParams { + value_type: None, + tzid: Some(Tzid(Tz::Europe__London)), + other: HashMap::new(), + }, + date_times: vec![ + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + ].into(), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( RDateProperty::parse_ical("RDATE;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/properties/query/x_from.rs b/redical_ical/src/properties/query/x_from.rs index ca3a502..cae5bd7 100644 --- a/redical_ical/src/properties/query/x_from.rs +++ b/redical_ical/src/properties/query/x_from.rs @@ -139,6 +139,21 @@ impl ICalendarDateTimeProperty for XFromProperty { } } +impl XFromProperty { + /// Resolves `self.date_time` against the TZID param (if present) to handle DST + /// transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that the stored datetime is always a valid wall-clock time in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + self.date_time = tzid.resolve_dst_transition(&self.date_time)?; + } + + Ok(()) + } +} + impl ICalendarEntity for XFromProperty { fn parse_ical(input: ParserInput) -> ParserResult { context( @@ -152,21 +167,23 @@ impl ICalendarEntity for XFromProperty { preceded(colon, DateTime::parse_ical), ), |(params, date_time)| { - let x_from_property = + let mut x_from_property = XFromProperty { params: params.unwrap_or(XFromPropertyParams::default()), date_time, }; + // Resolve DST transition gaps/ambiguities before validation. + x_from_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + if let Err(error) = ICalendarEntity::validate(&x_from_property) { return Err( ParserError::new(error, input) ); } - Ok( - x_from_property - ) + Ok(x_from_property) } ) ) @@ -183,8 +200,6 @@ impl ICalendarEntity for XFromProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - tzid.validate_with_datetime_value(&self.date_time)?; }; Ok(()) @@ -232,7 +247,7 @@ mod tests { use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; use chrono_tz::Tz; - use crate::tests::{assert_parser_output, assert_parser_error}; + use crate::tests::assert_parser_output; #[test] fn parse_ical() { @@ -293,18 +308,71 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( XFromProperty::parse_ical("X-FROM;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"X-FROM;TZID=Pacific/Auckland:20240929T020000\"", - context: ["X-FROM"], + ( + "", + XFromProperty { + params: XFromPropertyParams { + prop: WhereRangeProperty::DTStart, + op: WhereFromRangeOperator::GreaterThan, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + XFromProperty::parse_ical("X-FROM;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + XFromProperty { + params: XFromPropertyParams { + prop: WhereRangeProperty::DTStart, + op: WhereFromRangeOperator::GreaterThan, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + XFromProperty::parse_ical("X-FROM;TZID=Europe/London:20261025T011500".into()), + ( + "", + XFromProperty { + params: XFromPropertyParams { + prop: WhereRangeProperty::DTStart, + op: WhereFromRangeOperator::GreaterThan, + tzid: Some(Tzid(Tz::Europe__London)), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( XFromProperty::parse_ical("X-FROM;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/properties/query/x_until.rs b/redical_ical/src/properties/query/x_until.rs index c82285d..dd6b4dd 100644 --- a/redical_ical/src/properties/query/x_until.rs +++ b/redical_ical/src/properties/query/x_until.rs @@ -139,6 +139,21 @@ impl ICalendarDateTimeProperty for XUntilProperty { } } +impl XUntilProperty { + /// Resolves `self.date_time` against the TZID param (if present) to handle DST + /// transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that the stored datetime is always a valid wall-clock time in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + self.date_time = tzid.resolve_dst_transition(&self.date_time)?; + } + + Ok(()) + } +} + impl ICalendarEntity for XUntilProperty { fn parse_ical(input: ParserInput) -> ParserResult { context( @@ -152,21 +167,23 @@ impl ICalendarEntity for XUntilProperty { preceded(colon, DateTime::parse_ical), ), |(params, date_time)| { - let x_until_property = + let mut x_until_property = XUntilProperty { params: params.unwrap_or(XUntilPropertyParams::default()), date_time, }; + // Resolve DST transition gaps/ambiguities before validation. + x_until_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + if let Err(error) = ICalendarEntity::validate(&x_until_property) { return Err( ParserError::new(error, input) ); } - Ok( - x_until_property - ) + Ok(x_until_property) } ) ) @@ -183,8 +200,6 @@ impl ICalendarEntity for XUntilProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - tzid.validate_with_datetime_value(&self.date_time)?; }; Ok(()) @@ -232,7 +247,7 @@ mod tests { use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; use chrono_tz::Tz; - use crate::tests::{assert_parser_output, assert_parser_error}; + use crate::tests::assert_parser_output; #[test] fn parse_ical() { @@ -293,18 +308,71 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( XUntilProperty::parse_ical("X-UNTIL;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"X-UNTIL;TZID=Pacific/Auckland:20240929T020000\"", - context: ["X-UNTIL"], + ( + "", + XUntilProperty { + params: XUntilPropertyParams { + prop: WhereRangeProperty::DTStart, + op: WhereUntilRangeOperator::LessThan, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + XUntilProperty::parse_ical("X-UNTIL;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + XUntilProperty { + params: XUntilPropertyParams { + prop: WhereRangeProperty::DTStart, + op: WhereUntilRangeOperator::LessThan, + tzid: Some(Tzid(Tz::Pacific__Auckland)), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + XUntilProperty::parse_ical("X-UNTIL;TZID=Europe/London:20261025T011500".into()), + ( + "", + XUntilProperty { + params: XUntilPropertyParams { + prop: WhereRangeProperty::DTStart, + op: WhereUntilRangeOperator::LessThan, + tzid: Some(Tzid(Tz::Europe__London)), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( XUntilProperty::parse_ical("X-UNTIL;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/properties/recurrence_id.rs b/redical_ical/src/properties/recurrence_id.rs index 07fc1d4..7ecd7ff 100644 --- a/redical_ical/src/properties/recurrence_id.rs +++ b/redical_ical/src/properties/recurrence_id.rs @@ -195,6 +195,21 @@ impl ICalendarDateTimeProperty for RecurrenceIDProperty { } } +impl RecurrenceIDProperty { + /// Resolves `self.date_time` against the TZID param (if present) to handle DST + /// transition gaps and ambiguities per industry convention. + /// + /// Must be called during parsing (within `map_res` in `parse_ical`) before validation, + /// so that the stored datetime is always a valid wall-clock time in the target timezone. + pub fn resolve_dst_transitions(&mut self) -> Result<(), String> { + if let Some(tzid) = self.params.tzid.as_ref() { + self.date_time = tzid.resolve_dst_transition(&self.date_time)?; + } + + Ok(()) + } +} + impl ICalendarEntity for RecurrenceIDProperty { fn parse_ical(input: ParserInput) -> ParserResult { context( @@ -214,6 +229,10 @@ impl ICalendarEntity for RecurrenceIDProperty { date_time, }; + // Resolve DST transition gaps/ambiguities before validation. + recurrence_id_property.resolve_dst_transitions() + .map_err(|error| ParserError::new(error, input))?; + // Always ensure VALUE param is defined. recurrence_id_property.enforce_value_type_param(); @@ -242,8 +261,6 @@ impl ICalendarEntity for RecurrenceIDProperty { if let Some(tzid) = self.params.tzid.as_ref() { tzid.validate()?; - - tzid.validate_with_datetime_value(&self.date_time)?; }; if let Some(value_type) = self.params.value_type.as_ref() { @@ -368,18 +385,71 @@ mod tests { } #[test] - fn parse_ical_wth_tz_dst_gap_date_time() { - // Assert impossible date/time fails validation. - assert_parser_error!( + fn parse_ical_with_tz_dst_transition() { + // Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00 + assert_parser_output!( RecurrenceIDProperty::parse_ical("RECURRENCE-ID;TZID=Pacific/Auckland:20240929T020000".into()), - nom::Err::Failure( - span: ";TZID=Pacific/Auckland:20240929T020000", - message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"RECURRENCE-ID;TZID=Pacific/Auckland:20240929T\"", - context: ["RECURRENCE-ID"], + ( + "", + RecurrenceIDProperty { + params: RecurrenceIDPropertyParams { + value_type: Some(ValueType::DateTime), + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Gap with offset: 02:30 Auckland -> adjusted to 03:30 + assert_parser_output!( + RecurrenceIDProperty::parse_ical("RECURRENCE-ID;TZID=Pacific/Auckland:20240929T023000".into()), + ( + "", + RecurrenceIDProperty { + params: RecurrenceIDPropertyParams { + value_type: Some(ValueType::DateTime), + tzid: Some(Tzid(Tz::Pacific__Auckland)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(), + NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(), + ) + ), + }, + ), + ); + + // Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is + assert_parser_output!( + RecurrenceIDProperty::parse_ical("RECURRENCE-ID;TZID=Europe/London:20261025T011500".into()), + ( + "", + RecurrenceIDProperty { + params: RecurrenceIDPropertyParams { + value_type: Some(ValueType::DateTime), + tzid: Some(Tzid(Tz::Europe__London)), + other: HashMap::new(), + }, + date_time: DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(), + NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(), + ) + ), + }, ), ); - // Assert possible date/time does not fail validation. + // Non-transition times still work assert_parser_output!( RecurrenceIDProperty::parse_ical("RECURRENCE-ID;TZID=Pacific/Auckland:20240929T010000".into()), ( diff --git a/redical_ical/src/values/date_time.rs b/redical_ical/src/values/date_time.rs index f4af9f6..184812d 100644 --- a/redical_ical/src/values/date_time.rs +++ b/redical_ical/src/values/date_time.rs @@ -190,15 +190,15 @@ impl DateTime { match self { Self::LocalDate(date) => { let date_time: NaiveDateTime = date.to_owned().into(); - let utc_timestamp = current_tz.from_local_datetime(&date_time).unwrap().timestamp(); - let tz_adjusted_naive_date_time = new_tz.timestamp_opt(utc_timestamp, 0_u32).unwrap().naive_local(); + let utc_timestamp = current_tz.from_local_datetime(&date_time).earliest().unwrap().timestamp(); + let tz_adjusted_naive_date_time = new_tz.timestamp_opt(utc_timestamp, 0_u32).earliest().unwrap().naive_local(); Self::LocalDate(tz_adjusted_naive_date_time.into()) }, Self::LocalDateTime(date_time) => { - let utc_timestamp = current_tz.from_local_datetime(date_time).unwrap().timestamp(); - let tz_adjusted_naive_date_time = new_tz.timestamp_opt(utc_timestamp, 0_u32).unwrap().naive_local(); + let utc_timestamp = current_tz.from_local_datetime(date_time).earliest().unwrap().timestamp(); + let tz_adjusted_naive_date_time = new_tz.timestamp_opt(utc_timestamp, 0_u32).earliest().unwrap().naive_local(); Self::LocalDateTime(tz_adjusted_naive_date_time) }, @@ -207,8 +207,8 @@ impl DateTime { if new_tz == &Tz::UTC { self.clone() } else { - let utc_timestamp = Tz::UTC.from_local_datetime(date_time).unwrap().timestamp(); - let tz_adjusted_naive_date_time = new_tz.timestamp_opt(utc_timestamp, 0_u32).unwrap().naive_local(); + let utc_timestamp = Tz::UTC.from_local_datetime(date_time).earliest().unwrap().timestamp(); + let tz_adjusted_naive_date_time = new_tz.timestamp_opt(utc_timestamp, 0_u32).earliest().unwrap().naive_local(); Self::LocalDateTime(tz_adjusted_naive_date_time) } @@ -240,7 +240,7 @@ impl DateTime { }, }; - date_time_result.unwrap() + date_time_result.earliest().unwrap() .timestamp() } @@ -267,7 +267,7 @@ impl DateTime { } fn serialize_date_time(naive_date_time: &NaiveDateTime, tz: &Tz) -> String { - let local_date_time = tz.from_local_datetime(naive_date_time).unwrap(); + let local_date_time = tz.from_local_datetime(naive_date_time).earliest().unwrap(); if matches!(tz, &Tz::UTC) { local_date_time.format("%Y%m%dT%H%M%SZ").to_string() @@ -280,6 +280,7 @@ impl DateTime { let naive_date_time = NaiveDateTime::new(naive_date.to_owned(), NaiveTime::default()); tz.from_local_datetime(&naive_date_time) + .earliest() .unwrap() .format("%Y%m%d") .to_string() @@ -685,6 +686,37 @@ mod tests { ); } + #[test] + fn date_time_with_tz_ambiguous_fall_back() { + // Europe/London fall-back: Oct 25 2026 01:15 + // Ambiguous: occurs in BST (UTC+1) and GMT (UTC+0) + // .earliest() picks BST (UTC+1), so UTC timestamp = 00:15 + let date_time = + DateTime::LocalDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026, 10, 25).unwrap(), + NaiveTime::from_hms_opt(1, 15, 0).unwrap(), + ) + ); + + let utc_timestamp = + date_time.get_utc_timestamp(Some(&Tz::Europe__London)); + + // 2026-10-25T00:15:00Z (BST interpretation, pre-transition) + let expected_utc = + DateTime::UtcDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026, 10, 25).unwrap(), + NaiveTime::from_hms_opt(0, 15, 0).unwrap(), + ) + ); + + assert_eq!( + utc_timestamp, + expected_utc.get_utc_timestamp(None) + ); + } + #[test] fn value_type_parse_ical() { assert_parser_output!( diff --git a/redical_ical/src/values/tzid.rs b/redical_ical/src/values/tzid.rs index 702869f..21b65bd 100644 --- a/redical_ical/src/values/tzid.rs +++ b/redical_ical/src/values/tzid.rs @@ -1,6 +1,6 @@ use chrono::prelude::TimeZone; -use chrono::LocalResult; -use chrono_tz::Tz; +use chrono::{LocalResult, NaiveDateTime}; +use chrono_tz::{Tz, GapInfo}; use serde::{Serialize, Deserialize, Serializer, Deserializer}; @@ -52,30 +52,65 @@ impl ICalendarEntity for Tzid { } impl Tzid { - /// Validates the given `DateTime` value against the timezone represented by `Tzid`. + /// Resolves the given `DateTime` against this timezone, handling DST transitions per + /// industry convention: /// - /// This function checks if the provided `DateTime` can be represented in the timezone - /// without ambiguity, such as during daylight saving time transitions. + /// - **Unambiguous**: + /// - Returned as-is. /// - /// # Arguments + /// - **Ambiguous** (fall-back, time occurs twice): + /// - Returned as-is; downstream code uses `.earliest()` to pick + /// the pre-transition offset. /// - /// * `date_time` - A reference to a `DateTime` object to be validated. + /// - **Gap** (spring-forward, time doesn't exist): + /// - Adjusted forward by the gap amount using `GapInfo`. + /// E.g. 02:30 in a 02:00 -> 03:00 gap becomes 03:30. /// - /// # Returns + pub fn resolve_dst_transition(&self, date_time: &DateTime) -> Result { + let naive_date_time: NaiveDateTime = date_time.into(); + + match self.0.offset_from_local_datetime(&naive_date_time) { + LocalResult::Single(_) | + LocalResult::Ambiguous(_, _) => { + Ok(date_time.clone()) + }, + + LocalResult::None => { + self.resolve_dst_transition_gap(&naive_date_time) + }, + } + } + + /// Uses `GapInfo` to adjust a datetime that falls within a DST transition gap forward + /// to the equivalent post-transition time. /// - /// * `Ok(())` if the `DateTime` is valid within the timezone. - /// * `Err(String)` with an error message if the `DateTime` is invalid, possibly due to - /// being on a daylight savings threshold. - pub fn validate_with_datetime_value(&self, date_time: &DateTime) -> Result<(), String> { - // TODO: Watch chrono_tz crate for the release of the code in the following PR: - // - https://github.com/chronotope/chrono-tz/pull/188 - // - // Once released, implement better TZ DST gap handling instead of regarding as - // invalid. - match self.0.offset_from_local_datetime(&date_time.into()) { - LocalResult::Single(_) => Ok(()), - - _ => Err(String::from("detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted)")), + /// The adjustment preserves the offset into the gap: if the input is N minutes past the + /// gap start, the result is N minutes past the gap end. + fn resolve_dst_transition_gap(&self, naive: &NaiveDateTime) -> Result { + let gap_info = + GapInfo::new(naive, &self.0).ok_or_else(|| { + format!( + "datetime falls in unresolvable DST transition gap in timezone {}", + self.0 + ) + })?; + + match (gap_info.begin, gap_info.end) { + (Some((gap_start, _)), Some(gap_end)) => { + let offset_into_gap = *naive - gap_start; + let adjusted_naive = gap_end.naive_local() + offset_into_gap; + + Ok(DateTime::LocalDateTime(adjusted_naive)) + }, + + _ => { + Err( + format!( + "datetime falls in unresolvable DST transition gap in timezone {}", + self.0, + ) + ) + }, } } } @@ -318,4 +353,94 @@ mod tests { String::from("America/New_York"), ); } + + mod resolve_dst_transition_tests { + use super::*; + + use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; + + use crate::values::date_time::DateTime; + + #[test] + fn resolve_ambiguous_datetime_accepted() { + // Europe/London fall-back: Oct 25 2026, 01:15 occurs twice — accepted as-is + let tzid = Tzid(Tz::Europe__London); + + let date_time = + DateTime::LocalDateTime(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026, 10, 25).unwrap(), + NaiveTime::from_hms_opt(1, 15, 0).unwrap(), + )); + + assert_eq!( + tzid.resolve_dst_transition(&date_time), + Ok(date_time) + ); + } + + #[test] + fn resolve_gap_datetime_adjusted_forward() { + // Pacific/Auckland spring-forward: Sep 29 2024, 02:00 -> 03:00 + // Input 02:30 -> adjusted to 03:30 (preserves 30min offset into gap) + let tzid = Tzid(Tz::Pacific__Auckland); + + let date_time = + DateTime::LocalDateTime(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 9, 29).unwrap(), + NaiveTime::from_hms_opt(2, 30, 0).unwrap(), + )); + + let expected = + DateTime::LocalDateTime(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 9, 29).unwrap(), + NaiveTime::from_hms_opt(3, 30, 0).unwrap(), + )); + + assert_eq!( + tzid.resolve_dst_transition(&date_time), + Ok(expected) + ); + } + + #[test] + fn resolve_gap_datetime_at_exact_boundary() { + // Pacific/Auckland spring-forward: Sep 29 2024, 02:00 -> 03:00 + // Input 02:00 -> adjusted to 03:00 (0 offset into gap) + let tzid = Tzid(Tz::Pacific__Auckland); + + let date_time = + DateTime::LocalDateTime(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 9, 29).unwrap(), + NaiveTime::from_hms_opt(2, 0, 0).unwrap(), + )); + + let expected = + DateTime::LocalDateTime(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 9, 29).unwrap(), + NaiveTime::from_hms_opt(3, 0, 0).unwrap(), + )); + + assert_eq!( + tzid.resolve_dst_transition(&date_time), + Ok(expected) + ); + } + + #[test] + fn resolve_unambiguous_datetime_unchanged() { + // Non-transition time — passes through unchanged + let tzid = Tzid(Tz::Pacific__Auckland); + + let date_time = + DateTime::LocalDateTime(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 9, 29).unwrap(), + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + )); + + assert_eq!( + tzid.resolve_dst_transition(&date_time), + Ok(date_time) + ); + } + } }