From 8362ebe44b6da6f9fe7fc204eeb6899c01edcc2e Mon Sep 17 00:00:00 2001 From: Alvaro Soliverez Date: Sat, 4 Oct 2025 11:57:44 -0300 Subject: [PATCH] Initial commit: Complete CalDAV calendar synchronizer - Rust-based CLI tool for Zoho to Nextcloud calendar sync - Selective calendar import from Zoho to single Nextcloud calendar - Timezone-aware event handling for next-week synchronization - Comprehensive configuration system with TOML support - CLI interface with debug, list, and sync operations - Complete documentation and example configurations --- .gitignore | 1 + Cargo.lock | 2588 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 74 ++ README.md | 303 +++++ config/default.toml | 54 + config/example.toml | 117 ++ src/caldav_client.rs | 304 +++++ src/calendar_filter.rs | 583 ++++++++ src/config.rs | 204 +++ src/error.rs | 164 +++ src/event.rs | 447 +++++++ src/lib.rs | 49 + src/main.rs | 174 +++ src/sync.rs | 518 ++++++++ src/timezone.rs | 327 +++++ tests/integration_tests.rs | 285 ++++ 16 files changed, 6192 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 config/default.toml create mode 100644 config/example.toml create mode 100644 src/caldav_client.rs create mode 100644 src/calendar_filter.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/event.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/sync.rs create mode 100644 src/timezone.rs create mode 100644 tests/integration_tests.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..759b5b2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2588 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "caldav-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.21.7", + "chrono", + "chrono-tz", + "clap", + "config", + "quick-xml", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-test", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "cc" +version = "1.2.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "clap" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "config" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml 0.5.11", + "yaml-rust", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.1", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[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 = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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 = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "regex" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.1", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.1", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +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 = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2 0.6.0", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] + +[[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.5", +] + +[[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-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "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-targets" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..89cf893 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "caldav-sync" +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +description = "A CalDAV calendar synchronization tool" +license = "MIT OR Apache-2.0" +repository = "https://github.com/yourusername/caldav-sync" +keywords = ["caldav", "calendar", "sync", "productivity"] +categories = ["command-line-utilities", "date-and-time"] +readme = "README.md" +rust-version = "1.70.0" + +[dependencies] +# Async runtime +tokio = { version = "1.0", features = ["full"] } + +# HTTP client +reqwest = { version = "0.11", features = ["json", "rustls-tls"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Date and time handling +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.8" + +# XML parsing for CalDAV +quick-xml = { version = "0.28", features = ["serialize"] } + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Configuration management +config = "0.13" + +# Command line argument parsing +clap = { version = "4.0", features = ["derive"] } + +# Logging and tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# UUID generation for calendar items +uuid = { version = "1.0", features = ["v4", "serde"] } + +# Base64 encoding for authentication +base64 = "0.21" + +# URL parsing +url = "2.3" + +# TOML parsing +toml = "0.8" + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3.0" + +[[bin]] +name = "caldav-sync" +path = "src/main.rs" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" + +[profile.dev] +debug = true +opt-level = 0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..df228c3 --- /dev/null +++ b/README.md @@ -0,0 +1,303 @@ +# CalDAV Calendar Synchronizer + +A Rust-based command-line tool that synchronizes calendar events between Zoho Calendar and Nextcloud via CalDAV protocol. It allows you to selectively import events from specific Zoho calendars into a single Nextcloud calendar. + +## Features + +- **Selective Calendar Import**: Choose which Zoho calendars to sync from +- **Single Target Calendar**: All events consolidated into one Nextcloud calendar +- **Timezone Support**: Handles events across different timezones correctly +- **Next Week Focus**: Optimized for importing events for the next 7 days +- **Simple Data Transfer**: Extracts only title, time, and duration as requested +- **Secure Authentication**: Uses app-specific passwords for security +- **Dry Run Mode**: Preview what would be synced before making changes + +## Quick Start + +### 1. Prerequisites + +- Rust 1.70+ (for building from source) +- Zoho account with CalDAV access +- Nextcloud instance with CalDAV enabled +- App-specific passwords for both services (recommended) + +### 2. Installation + +```bash +# Clone the repository +git clone +cd caldavpuller + +# Build the project +cargo build --release + +# The binary will be at target/release/caldav-sync +``` + +### 3. Configuration + +Copy the example configuration file: + +```bash +cp config/example.toml config/config.toml +``` + +Edit `config/config.toml` with your settings: + +```toml +# Zoho CalDAV Configuration (Source) +[zoho] +server_url = "https://caldav.zoho.com/caldav" +username = "your-zoho-email@domain.com" +password = "your-zoho-app-password" + +# Select which calendars to import +selected_calendars = ["Work Calendar", "Personal Calendar"] + +# Nextcloud Configuration (Target) +[nextcloud] +server_url = "https://your-nextcloud-domain.com" +username = "your-nextcloud-username" +password = "your-nextcloud-app-password" +target_calendar = "Imported-Zoho-Events" +create_if_missing = true +``` + +### 4. First Run + +Test the configuration with a dry run: + +```bash +./target/release/caldav-sync --debug --list-events +``` + +Perform a one-time sync: + +```bash +./target/release/caldav-sync --once +``` + +## Configuration Details + +### Zoho Setup + +1. **Enable CalDAV in Zoho**: + - Go to Zoho Mail Settings → CalDAV + - Enable CalDAV access + - Generate an app-specific password + +2. **Find Calendar Names**: + ```bash + ./target/release/caldav-sync --list-events --debug + ``` + This will show all available calendars. + +### Nextcloud Setup + +1. **Enable CalDAV**: + - Usually enabled by default + - Access at `https://your-domain.com/remote.php/dav/` + +2. **Generate App Password**: + - Go to Settings → Security → App passwords + - Create a new app password for the sync tool + +3. **Target Calendar**: + - The tool can automatically create the target calendar + - Or create it manually in Nextcloud first + +## Usage + +### Command Line Options + +```bash +caldav-sync [OPTIONS] + +Options: + -c, --config Configuration file path [default: config/default.toml] + -s, --server-url CalDAV server URL (overrides config file) + -u, --username Username for authentication (overrides config file) + -p, --password Password for authentication (overrides config file) + --calendar Calendar name to sync (overrides config file) + -d, --debug Enable debug logging + --once Perform a one-time sync and exit + --full-resync Force a full resynchronization + --list-events List events and exit + -h, --help Print help + -V, --version Print version +``` + +### Common Operations + +1. **List Available Events**: + ```bash + caldav-sync --list-events + ``` + +2. **One-Time Sync**: + ```bash + caldav-sync --once + ``` + +3. **Full Resynchronization**: + ```bash + caldav-sync --full-resync + ``` + +4. **Override Configuration**: + ```bash + caldav-sync --username "user@example.com" --password "app-password" --once + ``` + +## Configuration Reference + +### Complete Configuration Example + +```toml +[server] +url = "https://caldav.zoho.com/caldav" +username = "your-email@domain.com" +password = "your-app-password" +timeout = 30 + +[calendar] +name = "Work Calendar" +color = "#4285F4" + +[sync] +sync_interval = 300 +sync_on_startup = true +weeks_ahead = 1 +dry_run = false + +[filters] +exclude_patterns = ["Cancelled:", "BLOCKED"] +min_duration_minutes = 5 +max_duration_hours = 24 + +[logging] +level = "info" +file = "caldav-sync.log" +``` + +### Environment Variables + +You can also use environment variables: + +```bash +export CALDAV_SERVER_URL="https://caldav.zoho.com/caldav" +export CALDAV_USERNAME="your-email@domain.com" +export CALDAV_PASSWORD="your-app-password" +export CALDAV_CALENDAR="Work Calendar" + +./target/release/caldav-sync +``` + +## Security Considerations + +1. **Use App Passwords**: Never use your main account password +2. **Secure Configuration**: Set appropriate file permissions on config files +3. **SSL/TLS**: Always use HTTPS connections +4. **Log Management**: Be careful with debug logs that may contain sensitive data + +## Troubleshooting + +### Common Issues + +1. **Authentication Failures**: + - Verify app-specific passwords are correctly set up + - Check that CalDAV is enabled in both services + - Ensure correct server URLs + +2. **Calendar Not Found**: + - Use `--list-events` to see available calendars + - Check calendar name spelling + - Verify permissions + +3. **Timezone Issues**: + - Events are automatically converted to UTC internally + - Original timezone information is preserved + - Check system timezone if times seem off + +4. **SSL Certificate Issues**: + - Ensure server URLs use HTTPS + - Check if custom certificates need to be configured + +### Debug Mode + +Enable debug logging for troubleshooting: + +```bash +caldav-sync --debug --list-events +``` + +This will show detailed HTTP requests, responses, and processing steps. + +## Development + +### Building from Source + +```bash +# Clone the repository +git clone +cd caldavpuller + +# Build in debug mode +cargo build + +# Build in release mode +cargo build --release + +# Run tests +cargo test + +# Check code formatting +cargo fmt --check + +# Run linter +cargo clippy +``` + +### Project Structure + +``` +caldavpuller/ +├── src/ +│ ├── main.rs # CLI interface and entry point +│ ├── lib.rs # Library interface +│ ├── config.rs # Configuration management +│ ├── caldav_client.rs # CalDAV HTTP client +│ ├── event.rs # Event data structures +│ ├── timezone.rs # Timezone handling +│ ├── calendar_filter.rs # Calendar filtering logic +│ ├── sync.rs # Synchronization engine +│ └── error.rs # Error types and handling +├── config/ +│ ├── default.toml # Default configuration +│ └── example.toml # Example configuration +├── tests/ +│ └── integration_tests.rs # Integration tests +├── Cargo.toml # Rust project configuration +└── README.md # This file +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Support + +For issues and questions: + +1. Check the troubleshooting section above +2. Review the debug output with `--debug` +3. Open an issue on the project repository +4. Include your configuration (with sensitive data removed) and debug logs diff --git a/config/default.toml b/config/default.toml new file mode 100644 index 0000000..f700218 --- /dev/null +++ b/config/default.toml @@ -0,0 +1,54 @@ +# Default CalDAV Sync Configuration +# This file provides default values for the Zoho to Nextcloud calendar sync + +# Zoho Configuration (Source) +[zoho] +server_url = "https://caldav.zoho.com/caldav" +username = "" +password = "" +selected_calendars = [] + +# Nextcloud Configuration (Target) +[nextcloud] +server_url = "" +username = "" +password = "" +target_calendar = "Imported-Zoho-Events" +create_if_missing = true + +[server] +# Request timeout in seconds +timeout = 30 + +[calendar] +# Calendar color in hex format +color = "#3174ad" +# Default timezone for processing +timezone = "UTC" + +[sync] +# Synchronization interval in seconds (300 = 5 minutes) +interval = 300 +# Whether to perform synchronization on startup +sync_on_startup = true +# Number of weeks ahead to sync +weeks_ahead = 1 +# Whether to run in dry-run mode (preview changes only) +dry_run = false + +# Performance settings +max_retries = 3 +retry_delay = 5 + +# Optional filtering configuration +# [filters] +# # Event types to include (leave empty for all) +# event_types = ["meeting", "appointment"] +# # Keywords to filter events by +# keywords = ["work", "meeting", "project"] +# # Keywords to exclude +# exclude_keywords = ["personal", "holiday", "cancelled"] +# # Minimum event duration in minutes +# min_duration_minutes = 5 +# # Maximum event duration in hours +# max_duration_hours = 24 diff --git a/config/example.toml b/config/example.toml new file mode 100644 index 0000000..76613ea --- /dev/null +++ b/config/example.toml @@ -0,0 +1,117 @@ +# CalDAV Configuration Example +# This file demonstrates how to configure Zoho and Nextcloud CalDAV connections +# Copy and modify this example for your specific setup + +# Global settings +global: + log_level: "info" + sync_interval: 300 # seconds (5 minutes) + conflict_resolution: "latest" # or "manual" or "local" or "remote" + timezone: "UTC" + +# Zoho CalDAV Configuration (Source) +zoho: + enabled: true + + # Server settings + server: + url: "https://caldav.zoho.com/caldav" + timeout: 30 # seconds + + # Authentication + auth: + username: "your-zoho-email@domain.com" + password: "your-zoho-app-password" # Use app-specific password, not main password + + # Calendar selection - which calendars to import from + calendars: + - name: "Work Calendar" + enabled: true + color: "#4285F4" + sync_direction: "pull" # Only pull from Zoho + + - name: "Personal Calendar" + enabled: true + color: "#34A853" + sync_direction: "pull" + + - name: "Team Meetings" + enabled: false # Disabled by default + color: "#EA4335" + sync_direction: "pull" + + # Sync options + sync: + sync_past_events: false # Don't sync past events + sync_future_events: true + sync_future_days: 7 # Only sync next week + include_attendees: false # Keep it simple + include_attachments: false + +# Nextcloud CalDAV Configuration (Target) +nextcloud: + enabled: true + + # Server settings + server: + url: "https://your-nextcloud-domain.com" + timeout: 30 # seconds + + # Authentication + auth: + username: "your-nextcloud-username" + password: "your-nextcloud-app-password" # Use app-specific password + + # Calendar discovery + discovery: + principal_url: "/remote.php/dav/principals/users/{username}/" + calendar_home_set: "/remote.php/dav/calendars/{username}/" + + # Target calendar - all Zoho events go here + calendars: + - name: "Imported-Zoho-Events" + enabled: true + color: "#FF6B6B" + sync_direction: "push" # Only push to Nextcloud + create_if_missing: true # Auto-create if it doesn't exist + + # Sync options + sync: + sync_past_events: false + sync_future_events: true + sync_future_days: 7 + +# Event filtering +filters: + events: + exclude_patterns: + - "Cancelled:" + - "BLOCKED" + + # Time-based filters + min_duration_minutes: 5 + max_duration_hours: 24 + + # Status filters + include_status: ["confirmed", "tentative"] + exclude_status: ["cancelled"] + +# Logging +logging: + level: "info" + format: "text" + file: "caldav-sync.log" + max_size: "10MB" + max_files: 3 + +# Performance settings +performance: + max_concurrent_syncs: 3 + batch_size: 25 + retry_attempts: 3 + retry_delay: 5 # seconds + +# Security settings +security: + ssl_verify: true + encryption: "tls12" diff --git a/src/caldav_client.rs b/src/caldav_client.rs new file mode 100644 index 0000000..479e38d --- /dev/null +++ b/src/caldav_client.rs @@ -0,0 +1,304 @@ +//! CalDAV client implementation + +use crate::{config::ServerConfig, error::{CalDavError, CalDavResult}}; +use reqwest::{Client, StatusCode}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use base64::Engine; +use url::Url; + +/// CalDAV client for communicating with CalDAV servers +pub struct CalDavClient { + client: Client, + config: ServerConfig, + base_url: Url, +} + +impl CalDavClient { + /// Create a new CalDAV client with the given configuration + pub fn new(config: ServerConfig) -> CalDavResult { + let base_url = Url::parse(&config.url)?; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(config.timeout)) + .build()?; + + Ok(Self { + client, + config, + base_url, + }) + } + + /// Test connection to the CalDAV server + pub async fn test_connection(&self) -> CalDavResult<()> { + let url = self.base_url.join("/")?; + let response = self.make_request("PROPFIND", &url, None).await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(CalDavError::Http( + response.status(), + format!("Connection test failed: {}", response.status()) + )) + } + } + + /// List all calendars on the server + pub async fn list_calendars(&self) -> CalDavResult> { + let url = self.base_url.join("/")?; + let body = r#" + + + + + + + + "#; + + let response = self.make_request("PROPFIND", &url, Some(body)).await?; + + if !response.status().is_success() { + return Err(CalDavError::Http( + response.status(), + "Failed to list calendars".to_string() + )); + } + + let response_text = response.text().await?; + self.parse_calendar_list(&response_text) + } + + /// Get events from a calendar within a date range + pub async fn get_events( + &self, + calendar_path: &str, + start: DateTime, + end: DateTime, + ) -> CalDavResult> { + let calendar_url = self.base_url.join(calendar_path)?; + + let body = format!(r#" + + + + + + + + + + + + + "#, + start.format("%Y%m%dT%H%M%SZ"), + end.format("%Y%m%dT%H%M%SZ") + ); + + let response = self.make_request("REPORT", &calendar_url, Some(&body)).await?; + + if !response.status().is_success() { + return Err(CalDavError::Http( + response.status(), + "Failed to get events".to_string() + )); + } + + let response_text = response.text().await?; + self.parse_events(&response_text) + } + + /// Create or update an event + pub async fn put_event(&self, calendar_path: &str, event_id: &str, ical_data: &str) -> CalDavResult<()> { + let event_url = self.base_url.join(&format!("{}/{}.ics", calendar_path, event_id))?; + + let response = self.make_request_with_body("PUT", &event_url, ical_data).await?; + + if !response.status().is_success() && response.status() != StatusCode::CREATED { + return Err(CalDavError::Http( + response.status(), + "Failed to create/update event".to_string() + )); + } + + Ok(()) + } + + /// Delete an event + pub async fn delete_event(&self, calendar_path: &str, event_id: &str) -> CalDavResult<()> { + let event_url = self.base_url.join(&format!("{}/{}.ics", calendar_path, event_id))?; + + let response = self.make_request("DELETE", &event_url, None).await?; + + if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND { + return Err(CalDavError::Http( + response.status(), + "Failed to delete event".to_string() + )); + } + + Ok(()) + } + + /// Get a specific event + pub async fn get_event(&self, calendar_path: &str, event_id: &str) -> CalDavResult> { + let event_url = self.base_url.join(&format!("{}/{}.ics", calendar_path, event_id))?; + + let response = self.make_request("GET", &event_url, None).await?; + + match response.status() { + StatusCode::OK => { + let ical_data = response.text().await?; + Ok(Some(ical_data)) + } + StatusCode::NOT_FOUND => Ok(None), + status => Err(CalDavError::Http( + status, + "Failed to get event".to_string() + )), + } + } + + /// Make an authenticated request to the CalDAV server + async fn make_request( + &self, + method: &str, + url: &Url, + body: Option<&str>, + ) -> CalDavResult { + let mut request = self.client.request(method.parse().unwrap_or(reqwest::Method::GET), url.clone()); + + // Add basic authentication + if !self.config.username.is_empty() && !self.config.password.is_empty() { + let auth = format!("{}:{}", self.config.username, self.config.password); + let auth_encoded = base64::engine::general_purpose::STANDARD.encode(auth); + request = request.header("Authorization", format!("Basic {}", auth_encoded)); + } + + // Add custom headers + if let Some(headers) = &self.config.headers { + for (key, value) in headers { + request = request.header(key, value); + } + } + + // Add default CalDAV headers + request = request + .header("Content-Type", "application/xml; charset=utf-8") + .header("Depth", "1"); + + // Add body if provided + if let Some(body) = body { + request = request.body(body.to_string()); + } + + let response = request.send().await?; + Ok(response) + } + + /// Make a request with custom content type + async fn make_request_with_body( + &self, + method: &str, + url: &Url, + body: &str, + ) -> CalDavResult { + let mut request = self.client.request(method.parse().unwrap_or(reqwest::Method::PUT), url.clone()); + + // Add basic authentication + if !self.config.username.is_empty() && !self.config.password.is_empty() { + let auth = format!("{}:{}", self.config.username, self.config.password); + let auth_encoded = base64::engine::general_purpose::STANDARD.encode(auth); + request = request.header("Authorization", format!("Basic {}", auth_encoded)); + } + + request = request + .header("Content-Type", "text/calendar; charset=utf-8"); + + let response = request.body(body.to_string()).send().await?; + Ok(response) + } + + /// Parse calendar list from XML response + fn parse_calendar_list(&self, _xml: &str) -> CalDavResult> { + // This is a simplified XML parser - in a real implementation, + // you'd use a proper XML parsing library + let calendars = Vec::new(); + + // Placeholder implementation + // TODO: Implement proper XML parsing for calendar discovery + + Ok(calendars) + } + + /// Parse events from XML response + fn parse_events(&self, _xml: &str) -> CalDavResult> { + // This is a simplified XML parser - in a real implementation, + // you'd use a proper XML parsing library + let events = Vec::new(); + + // Placeholder implementation + // TODO: Implement proper XML parsing for event data + + Ok(events) + } +} + +/// Calendar information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarInfo { + /// Calendar path + pub path: String, + /// Display name + pub display_name: String, + /// Description + pub description: Option, + /// Supported components + pub supported_components: Vec, + /// Calendar color + pub color: Option, +} + +/// Event information for CalDAV responses +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalDavEventInfo { + /// Event ID + pub id: String, + /// Event summary + pub summary: String, + /// Event description + pub description: Option, + /// Start time + pub start: DateTime, + /// End time + pub end: DateTime, + /// Event location + pub location: Option, + /// Event status + pub status: String, + /// ETag for synchronization + pub etag: Option, + /// Raw iCalendar data + pub ical_data: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_creation() { + let config = ServerConfig { + url: "https://example.com".to_string(), + username: "test".to_string(), + password: "test".to_string(), + ..Default::default() + }; + + let client = CalDavClient::new(config); + assert!(client.is_ok()); + } +} diff --git a/src/calendar_filter.rs b/src/calendar_filter.rs new file mode 100644 index 0000000..e08fd53 --- /dev/null +++ b/src/calendar_filter.rs @@ -0,0 +1,583 @@ +//! Calendar filtering functionality + +use crate::event::{Event, EventStatus, EventType}; +use chrono::{DateTime, Utc, Datelike}; +use serde::{Deserialize, Serialize}; + +/// Calendar filter for filtering events based on various criteria +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarFilter { + /// Filter rules + pub rules: Vec, + /// Whether to include events that match any rule (OR) or all rules (AND) + pub match_any: bool, +} + +impl Default for CalendarFilter { + fn default() -> Self { + Self { + rules: Vec::new(), + match_any: true, // Default to OR behavior + } + } +} + +impl CalendarFilter { + /// Create a new calendar filter + pub fn new(match_any: bool) -> Self { + Self { + rules: Vec::new(), + match_any, + } + } + + /// Add a filter rule + pub fn add_rule(&mut self, rule: FilterRule) { + self.rules.push(rule); + } + + /// Add multiple filter rules + pub fn add_rules(&mut self, rules: Vec) { + self.rules.extend(rules); + } + + /// Filter a list of events + pub fn filter_events<'a>(&self, events: &'a [Event]) -> Vec<&'a Event> { + if self.rules.is_empty() { + return events.iter().collect(); + } + + events.iter() + .filter(|event| self.matches_event(event)) + .collect() + } + + /// Filter a list of events and return owned values + pub fn filter_events_owned(&self, events: Vec) -> Vec { + if self.rules.is_empty() { + return events; + } + + events.into_iter() + .filter(|event| self.matches_event(event)) + .collect() + } + + /// Check if an event matches the filter rules + pub fn matches_event(&self, event: &Event) -> bool { + if self.rules.is_empty() { + return true; + } + + let matches: Vec = self.rules.iter() + .map(|rule| rule.matches_event(event)) + .collect(); + + if self.match_any { + // OR logic - event matches if any rule matches + matches.iter().any(|&m| m) + } else { + // AND logic - event matches only if all rules match + matches.iter().all(|&m| m) + } + } + + /// Create a date range filter + pub fn date_range(start: DateTime, end: DateTime) -> Self { + let mut filter = Self::new(true); + filter.add_rule(FilterRule::DateRange(DateRangeFilter { start, end })); + filter + } + + /// Create a keyword filter + pub fn keywords(keywords: Vec, case_sensitive: bool) -> Self { + let mut filter = Self::new(true); + filter.add_rule(FilterRule::Keywords(KeywordFilter { + keywords, + case_sensitive, + search_fields: KeywordSearchFields::default(), + })); + filter + } + + /// Create an event type filter + pub fn event_types(event_types: Vec) -> Self { + let mut filter = Self::new(true); + filter.add_rule(FilterRule::EventType(EventTypeFilter { event_types })); + filter + } + + /// Create an event status filter + pub fn event_statuses(statuses: Vec) -> Self { + let mut filter = Self::new(true); + filter.add_rule(FilterRule::EventStatus(EventStatusFilter { statuses })); + filter + } + + /// Create a combined filter with multiple criteria + pub fn combined() -> Self { + Self::new(false) // AND logic for combined filters + } +} + +/// Individual filter rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FilterRule { + /// Filter by date range + DateRange(DateRangeFilter), + /// Filter by keywords + Keywords(KeywordFilter), + /// Filter by event type + EventType(EventTypeFilter), + /// Filter by event status + EventStatus(EventStatusFilter), + /// Filter by location + Location(LocationFilter), + /// Filter by organizer + Organizer(OrganizerFilter), + /// Filter by recurrence + Recurrence(RecurrenceFilter), + /// Custom filter function (not serializable) + Custom(String), // Store as string name for reference +} + +impl FilterRule { + /// Check if an event matches this filter rule + pub fn matches_event(&self, event: &Event) -> bool { + match self { + FilterRule::DateRange(filter) => filter.matches_event(event), + FilterRule::Keywords(filter) => filter.matches_event(event), + FilterRule::EventType(filter) => filter.matches_event(event), + FilterRule::EventStatus(filter) => filter.matches_event(event), + FilterRule::Location(filter) => filter.matches_event(event), + FilterRule::Organizer(filter) => filter.matches_event(event), + FilterRule::Recurrence(filter) => filter.matches_event(event), + FilterRule::Custom(_) => false, // Custom filters need external handling + } + } +} + +/// Date range filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DateRangeFilter { + /// Start date (inclusive) + pub start: DateTime, + /// End date (inclusive) + pub end: DateTime, +} + +impl DateRangeFilter { + /// Create a new date range filter + pub fn new(start: DateTime, end: DateTime) -> Self { + Self { start, end } + } + + /// Check if an event matches the date range + pub fn matches_event(&self, event: &Event) -> bool { + // Event overlaps with the date range if: + // - Event starts before or at the end of the range, AND + // - Event ends after or at the start of the range + event.start <= self.end && event.end >= self.start + } + + /// Create a filter for events today + pub fn today() -> Self { + let now = Utc::now(); + let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap(); + let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap(); + Self { + start: DateTime::from_naive_utc_and_offset(start, Utc), + end: DateTime::from_naive_utc_and_offset(end, Utc), + } + } + + /// Create a filter for events this week + pub fn this_week() -> Self { + let now = Utc::now(); + let weekday = now.weekday().num_days_from_monday(); + let week_start = (now.date_naive() - chrono::Duration::days(weekday as i64)).and_hms_opt(0, 0, 0).unwrap(); + let week_end = (week_start.date() + chrono::Duration::days(6)).and_hms_opt(23, 59, 59).unwrap(); + Self { + start: DateTime::from_naive_utc_and_offset(week_start, Utc), + end: DateTime::from_naive_utc_and_offset(week_end, Utc), + } + } + + /// Create a filter for events this month + pub fn this_month() -> Self { + let now = Utc::now(); + let month_start = now.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap(); + let month_end = month_start.date() + .with_month(month_start.month() + 1) + .unwrap_or(month_start.date().with_year(month_start.year() + 1).unwrap()) + .with_day(1).unwrap() + .pred_opt().unwrap() + .and_hms_opt(23, 59, 59).unwrap(); + Self { + start: DateTime::from_naive_utc_and_offset(month_start, Utc), + end: DateTime::from_naive_utc_and_offset(month_end, Utc), + } + } +} + +/// Keyword filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeywordFilter { + /// Keywords to search for + pub keywords: Vec, + /// Whether the search is case sensitive + pub case_sensitive: bool, + /// Which fields to search in + pub search_fields: KeywordSearchFields, +} + +/// Fields to search for keywords +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeywordSearchFields { + /// Search in summary + pub summary: bool, + /// Search in description + pub description: bool, + /// Search in location + pub location: bool, +} + +impl Default for KeywordSearchFields { + fn default() -> Self { + Self { + summary: true, + description: true, + location: false, + } + } +} + +impl KeywordFilter { + /// Create a new keyword filter + pub fn new(keywords: Vec, case_sensitive: bool) -> Self { + Self { + keywords, + case_sensitive, + search_fields: KeywordSearchFields::default(), + } + } + + /// Set which fields to search + pub fn search_fields(mut self, fields: KeywordSearchFields) -> Self { + self.search_fields = fields; + self + } + + /// Check if an event matches the keyword filter + pub fn matches_event(&self, event: &Event) -> bool { + let search_text = self.get_search_text(event); + + let search_text = if self.case_sensitive { + search_text + } else { + search_text.to_lowercase() + }; + + self.keywords.iter().any(|keyword| { + let keyword = if self.case_sensitive { + keyword.clone() + } else { + keyword.to_lowercase() + }; + search_text.contains(&keyword) + }) + } + + /// Get the combined text to search in + fn get_search_text(&self, event: &Event) -> String { + let mut text_parts: Vec = Vec::new(); + + if self.search_fields.summary { + text_parts.push(event.summary.clone()); + } + + if self.search_fields.description { + if let Some(description) = &event.description { + text_parts.push(description.clone()); + } + } + + if self.search_fields.location { + if let Some(location) = &event.location { + text_parts.push(location.clone()); + } + } + + text_parts.join(" ") + } +} + +/// Event type filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventTypeFilter { + /// Event types to include + pub event_types: Vec, +} + +impl EventTypeFilter { + /// Create a new event type filter + pub fn new(event_types: Vec) -> Self { + Self { event_types } + } + + /// Check if an event matches the event type filter + pub fn matches_event(&self, event: &Event) -> bool { + self.event_types.contains(&event.event_type) + } +} + +/// Event status filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventStatusFilter { + /// Event statuses to include + pub statuses: Vec, +} + +impl EventStatusFilter { + /// Create a new event status filter + pub fn new(statuses: Vec) -> Self { + Self { statuses } + } + + /// Check if an event matches the event status filter + pub fn matches_event(&self, event: &Event) -> bool { + self.statuses.contains(&event.status) + } +} + +/// Location filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocationFilter { + /// Locations to include (partial match) + pub locations: Vec, + /// Whether the search is case sensitive + pub case_sensitive: bool, +} + +impl LocationFilter { + /// Create a new location filter + pub fn new(locations: Vec, case_sensitive: bool) -> Self { + Self { + locations, + case_sensitive, + } + } + + /// Check if an event matches the location filter + pub fn matches_event(&self, event: &Event) -> bool { + if let Some(location) = &event.location { + let location = if self.case_sensitive { + location.clone() + } else { + location.to_lowercase() + }; + + self.locations.iter().any(|filter_location| { + let filter_location = if self.case_sensitive { + filter_location.clone() + } else { + filter_location.to_lowercase() + }; + location.contains(&filter_location) + }) + } else { + false + } + } +} + +/// Organizer filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrganizerFilter { + /// Organizer email addresses to include + pub organizers: Vec, +} + +impl OrganizerFilter { + /// Create a new organizer filter + pub fn new(organizers: Vec) -> Self { + Self { organizers } + } + + /// Check if an event matches the organizer filter + pub fn matches_event(&self, event: &Event) -> bool { + if let Some(organizer) = &event.organizer { + self.organizers.contains(&organizer.email) + } else { + false + } + } +} + +/// Recurrence filter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecurrenceFilter { + /// Whether to include recurring events + pub include_recurring: bool, + /// Whether to include non-recurring events + pub include_non_recurring: bool, +} + +impl RecurrenceFilter { + /// Create a new recurrence filter + pub fn new(include_recurring: bool, include_non_recurring: bool) -> Self { + Self { + include_recurring, + include_non_recurring, + } + } + + /// Check if an event matches the recurrence filter + pub fn matches_event(&self, event: &Event) -> bool { + let is_recurring = event.recurrence.is_some(); + + (is_recurring && self.include_recurring) || + (!is_recurring && self.include_non_recurring) + } +} + +/// Filter builder for easy filter construction +pub struct FilterBuilder { + filter: CalendarFilter, +} + +impl FilterBuilder { + /// Create a new filter builder + pub fn new() -> Self { + Self { + filter: CalendarFilter::default(), + } + } + + /// Set match mode (any = OR, all = AND) + pub fn match_any(mut self, match_any: bool) -> Self { + self.filter.match_any = match_any; + self + } + + /// Add date range filter + pub fn date_range(mut self, start: DateTime, end: DateTime) -> Self { + self.filter.add_rule(FilterRule::DateRange(DateRangeFilter::new(start, end))); + self + } + + /// Add keywords filter + pub fn keywords(mut self, keywords: Vec) -> Self { + self.filter.add_rule(FilterRule::Keywords(KeywordFilter::new(keywords, false))); + self + } + + /// Add case-sensitive keywords filter + pub fn keywords_case_sensitive(mut self, keywords: Vec) -> Self { + self.filter.add_rule(FilterRule::Keywords(KeywordFilter::new(keywords, true))); + self + } + + /// Add event type filter + pub fn event_types(mut self, event_types: Vec) -> Self { + self.filter.add_rule(FilterRule::EventType(EventTypeFilter::new(event_types))); + self + } + + /// Add event status filter + pub fn event_statuses(mut self, statuses: Vec) -> Self { + self.filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(statuses))); + self + } + + /// Build the filter + pub fn build(self) -> CalendarFilter { + self.filter + } +} + +impl Default for FilterBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::Event; + + #[test] + fn test_date_range_filter() { + let start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T000000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let end = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T235959", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + + let filter = DateRangeFilter::new(start, end); + + // Event within range + let event_start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let event = Event::new("Test".to_string(), event_start, event_start + chrono::Duration::hours(1)); + assert!(filter.matches_event(&event)); + + // Event outside range + let event_outside = Event::new( + "Test".to_string(), + start - chrono::Duration::days(1), + start - chrono::Duration::hours(23), + ); + assert!(!filter.matches_event(&event_outside)); + } + + #[test] + fn test_keyword_filter() { + let filter = KeywordFilter::new(vec!["meeting".to_string(), "important".to_string()], false); + + let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); + assert!(filter.matches_event(&event1)); + + let event2 = Event::new("Lunch".to_string(), Utc::now(), Utc::now()); + assert!(!filter.matches_event(&event2)); + + // Test case sensitivity + let case_filter = KeywordFilter::new(vec!["MEETING".to_string()], true); + assert!(!case_filter.matches_event(&event1)); // "Team Meeting" != "MEETING" + } + + #[test] + fn test_calendar_filter() { + let mut filter = CalendarFilter::new(true); // OR logic + filter.add_rule(FilterRule::Keywords(KeywordFilter::new(vec!["meeting".to_string()], false))); + filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(vec![EventStatus::Cancelled]))); + + let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); + assert!(filter.matches_event(&event1)); // Matches keyword + + let mut event2 = Event::new("Holiday".to_string(), Utc::now(), Utc::now()); + event2.status = EventStatus::Cancelled; + assert!(filter.matches_event(&event2)); // Matches status + + let event3 = Event::new("Lunch".to_string(), Utc::now(), Utc::now()); + assert!(!filter.matches_event(&event3)); // Matches neither + } + + #[test] + fn test_filter_builder() { + let filter = FilterBuilder::new() + .match_any(false) // AND logic + .keywords(vec!["meeting".to_string()]) + .event_types(vec![EventType::Public]) + .build(); + + let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); + assert!(filter.matches_event(&event)); // Matches both conditions + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b23b4a2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,204 @@ +//! Configuration management for CalDAV synchronizer + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use anyhow::Result; + +/// Main configuration structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Server configuration + pub server: ServerConfig, + /// Calendar configuration + pub calendar: CalendarConfig, + /// Filter configuration + pub filters: Option, + /// Sync configuration + pub sync: SyncConfig, +} + +/// Server connection configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + /// CalDAV server URL + pub url: String, + /// Username for authentication + pub username: String, + /// Password for authentication + pub password: String, + /// Whether to use HTTPS + pub use_https: bool, + /// Timeout in seconds + pub timeout: u64, + /// Custom headers to send with requests + pub headers: Option>, +} + +/// Calendar-specific configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarConfig { + /// Calendar name/path + pub name: String, + /// Calendar display name + pub display_name: Option, + /// Calendar color + pub color: Option, + /// Calendar timezone + pub timezone: String, + /// Whether to sync this calendar + pub enabled: bool, +} + +/// Filter configuration for events +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterConfig { + /// Start date filter (ISO 8601) + pub start_date: Option, + /// End date filter (ISO 8601) + pub end_date: Option, + /// Event types to include + pub event_types: Option>, + /// Keywords to filter by + pub keywords: Option>, + /// Exclude keywords + pub exclude_keywords: Option>, +} + +/// Synchronization configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + /// Sync interval in seconds + pub interval: u64, + /// Whether to sync on startup + pub sync_on_startup: bool, + /// Maximum number of retries + pub max_retries: u32, + /// Retry delay in seconds + pub retry_delay: u64, + /// Whether to delete events not found on server + pub delete_missing: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + server: ServerConfig::default(), + calendar: CalendarConfig::default(), + filters: None, + sync: SyncConfig::default(), + } + } +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + url: "https://caldav.example.com".to_string(), + username: String::new(), + password: String::new(), + use_https: true, + timeout: 30, + headers: None, + } + } +} + +impl Default for CalendarConfig { + fn default() -> Self { + Self { + name: "calendar".to_string(), + display_name: None, + color: None, + timezone: "UTC".to_string(), + enabled: true, + } + } +} + +impl Default for SyncConfig { + fn default() -> Self { + Self { + interval: 300, // 5 minutes + sync_on_startup: true, + max_retries: 3, + retry_delay: 5, + delete_missing: false, + } + } +} + +impl Config { + /// Load configuration from a TOML file + pub fn from_file>(path: P) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) + } + + /// Save configuration to a TOML file + pub fn to_file>(&self, path: P) -> Result<()> { + let content = toml::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } + + /// Load configuration from environment variables + pub fn from_env() -> Result { + let mut config = Config::default(); + + if let Ok(url) = std::env::var("CALDAV_URL") { + config.server.url = url; + } + if let Ok(username) = std::env::var("CALDAV_USERNAME") { + config.server.username = username; + } + if let Ok(password) = std::env::var("CALDAV_PASSWORD") { + config.server.password = password; + } + if let Ok(calendar) = std::env::var("CALDAV_CALENDAR") { + config.calendar.name = calendar; + } + + Ok(config) + } + + /// Validate configuration + pub fn validate(&self) -> Result<()> { + if self.server.url.is_empty() { + anyhow::bail!("Server URL cannot be empty"); + } + if self.server.username.is_empty() { + anyhow::bail!("Username cannot be empty"); + } + if self.server.password.is_empty() { + anyhow::bail!("Password cannot be empty"); + } + if self.calendar.name.is_empty() { + anyhow::bail!("Calendar name cannot be empty"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.server.url, "https://caldav.example.com"); + assert_eq!(config.calendar.name, "calendar"); + assert_eq!(config.sync.interval, 300); + } + + #[test] + fn test_config_validation() { + let mut config = Config::default(); + assert!(config.validate().is_err()); // Empty username/password + + config.server.username = "test".to_string(); + config.server.password = "test".to_string(); + assert!(config.validate().is_ok()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2ab655b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,164 @@ +//! Error handling for CalDAV synchronizer + +use thiserror::Error; + +/// Result type for CalDAV operations +pub type CalDavResult = Result; + +/// Main error type for CalDAV operations +#[derive(Error, Debug)] +pub enum CalDavError { + #[error("Configuration error: {0}")] + Config(String), + + #[error("Authentication failed: {0}")] + Authentication(String), + + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + #[error("HTTP error: {0} - {1}")] + Http(reqwest::StatusCode, String), + + #[error("XML parsing error: {0}")] + XmlParse(#[from] quick_xml::DeError), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Date/time error: {0}")] + DateTime(#[from] chrono::ParseError), + + #[error("Timezone error: {0}")] + Timezone(String), + + #[error("Event processing error: {0}")] + EventProcessing(String), + + #[error("Calendar not found: {0}")] + CalendarNotFound(String), + + #[error("Event not found: {0}")] + EventNotFound(String), + + #[error("Synchronization error: {0}")] + Sync(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("URL parsing error: {0}")] + UrlParse(#[from] url::ParseError), + + #[error("Base64 encoding error: {0}")] + Base64(#[from] base64::DecodeError), + + #[error("UUID generation error: {0}")] + Uuid(#[from] uuid::Error), + + #[error("Filter error: {0}")] + Filter(String), + + #[error("Invalid response format: {0}")] + InvalidResponse(String), + + #[error("Rate limited: retry after {0} seconds")] + RateLimited(u64), + + #[error("Server error: {0}")] + ServerError(String), + + #[error("Timeout error: operation timed out after {0} seconds")] + Timeout(u64), + + #[error("Unknown error: {0}")] + Unknown(String), +} + +impl CalDavError { + /// Check if this error is retryable + pub fn is_retryable(&self) -> bool { + match self { + CalDavError::Network(_) => true, + CalDavError::Http(status, _) => { + matches!(status.as_u16(), 408 | 429 | 500..=599) + } + CalDavError::Timeout(_) => true, + CalDavError::RateLimited(_) => true, + CalDavError::ServerError(_) => true, + _ => false, + } + } + + /// Get suggested retry delay in seconds + pub fn retry_delay(&self) -> Option { + match self { + CalDavError::RateLimited(seconds) => Some(*seconds), + CalDavError::Network(_) => Some(5), + CalDavError::Http(status, _) => { + match status.as_u16() { + 429 => Some(60), // Rate limit + 500..=599 => Some(30), // Server error + _ => None, + } + } + CalDavError::Timeout(_) => Some(10), + _ => None, + } + } + + /// Check if this error indicates authentication failure + pub fn is_auth_error(&self) -> bool { + matches!(self, CalDavError::Authentication(_)) + } + + /// Check if this error indicates a configuration issue + pub fn is_config_error(&self) -> bool { + matches!(self, CalDavError::Config(_)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_retryable() { + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert!(network_error.is_retryable()); + + let auth_error = CalDavError::Authentication("Invalid credentials".to_string()); + assert!(!auth_error.is_retryable()); + + let config_error = CalDavError::Config("Missing URL".to_string()); + assert!(!config_error.is_retryable()); + } + + #[test] + fn test_retry_delay() { + let rate_limit_error = CalDavError::RateLimited(120); + assert_eq!(rate_limit_error.retry_delay(), Some(120)); + + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert_eq!(network_error.retry_delay(), Some(5)); + } + + #[test] + fn test_error_classification() { + let auth_error = CalDavError::Authentication("Invalid".to_string()); + assert!(auth_error.is_auth_error()); + + let config_error = CalDavError::Config("Invalid".to_string()); + assert!(config_error.is_config_error()); + + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert!(!network_error.is_auth_error()); + assert!(!network_error.is_config_error()); + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..b995c81 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,447 @@ +//! Event handling and iCalendar parsing + +use crate::error::{CalDavError, CalDavResult}; +use chrono::{DateTime, Utc, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Calendar event representation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + /// Unique identifier + pub uid: String, + /// Event summary/title + pub summary: String, + /// Event description + pub description: Option, + /// Start time + pub start: DateTime, + /// End time + pub end: DateTime, + /// All-day event flag + pub all_day: bool, + /// Event location + pub location: Option, + /// Event status + pub status: EventStatus, + /// Event type + pub event_type: EventType, + /// Organizer + pub organizer: Option, + /// Attendees + pub attendees: Vec, + /// Recurrence rule + pub recurrence: Option, + /// Alarm/reminders + pub alarms: Vec, + /// Custom properties + pub properties: HashMap, + /// Creation timestamp + pub created: DateTime, + /// Last modification timestamp + pub last_modified: DateTime, + /// Sequence number for updates + pub sequence: i32, + /// Timezone identifier + pub timezone: Option, +} + +/// Event status +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum EventStatus { + Confirmed, + Tentative, + Cancelled, +} + +impl Default for EventStatus { + fn default() -> Self { + EventStatus::Confirmed + } +} + +/// Event type/classification +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum EventType { + Public, + Private, + Confidential, +} + +impl Default for EventType { + fn default() -> Self { + EventType::Public + } +} + +/// Event organizer +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Organizer { + /// Email address + pub email: String, + /// Display name + pub name: Option, + /// Sent-by parameter + pub sent_by: Option, +} + +/// Event attendee +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attendee { + /// Email address + pub email: String, + /// Display name + pub name: Option, + /// Participation status + pub status: ParticipationStatus, + /// Whether required + pub required: bool, + /// RSVP requested + pub rsvp: bool, +} + +/// Participation status +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ParticipationStatus { + NeedsAction, + Accepted, + Declined, + Tentative, + Delegated, +} + +/// Recurrence rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecurrenceRule { + /// Frequency + pub frequency: RecurrenceFrequency, + /// Interval + pub interval: u32, + /// Count (number of occurrences) + pub count: Option, + /// Until date + pub until: Option>, + /// Days of week + pub by_day: Option>, + /// Days of month + pub by_month_day: Option>, + /// Months + pub by_month: Option>, +} + +/// Recurrence frequency +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum RecurrenceFrequency { + Secondly, + Minutely, + Hourly, + Daily, + Weekly, + Monthly, + Yearly, +} + +/// Day of week for recurrence +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum WeekDay { + Sunday, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, +} + +/// Event alarm/reminder +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Alarm { + /// Action type + pub action: AlarmAction, + /// Trigger time + pub trigger: AlarmTrigger, + /// Description + pub description: Option, + /// Summary + pub summary: Option, +} + +/// Alarm action +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AlarmAction { + Display, + Email, + Audio, +} + +/// Alarm trigger +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AlarmTrigger { + /// Duration before start + BeforeStart(chrono::Duration), + /// Duration after start + AfterStart(chrono::Duration), + /// Duration before end + BeforeEnd(chrono::Duration), + /// Absolute time + Absolute(DateTime), +} + +impl Event { + /// Create a new event + pub fn new(summary: String, start: DateTime, end: DateTime) -> Self { + let now = Utc::now(); + Self { + uid: Uuid::new_v4().to_string(), + summary, + description: None, + start, + end, + all_day: false, + location: None, + status: EventStatus::default(), + event_type: EventType::default(), + organizer: None, + attendees: Vec::new(), + recurrence: None, + alarms: Vec::new(), + properties: HashMap::new(), + created: now, + last_modified: now, + sequence: 0, + timezone: None, + } + } + + /// Create an all-day event + pub fn new_all_day(summary: String, date: chrono::NaiveDate) -> Self { + let start = date.and_hms_opt(0, 0, 0).unwrap(); + let end = date.and_hms_opt(23, 59, 59).unwrap(); + let start_utc = DateTime::from_naive_utc_and_offset(start, Utc); + let end_utc = DateTime::from_naive_utc_and_offset(end, Utc); + + Self { + uid: Uuid::new_v4().to_string(), + summary, + description: None, + start: start_utc, + end: end_utc, + all_day: true, + location: None, + status: EventStatus::default(), + event_type: EventType::default(), + organizer: None, + attendees: Vec::new(), + recurrence: None, + alarms: Vec::new(), + properties: HashMap::new(), + created: Utc::now(), + last_modified: Utc::now(), + sequence: 0, + timezone: None, + } + } + + /// Parse event from iCalendar data + pub fn from_ical(_ical_data: &str) -> CalDavResult { + // This is a simplified iCalendar parser + // In a real implementation, you'd use a proper iCalendar parsing library + + let event = Self::new("placeholder".to_string(), Utc::now(), Utc::now()); + + // Placeholder implementation + // TODO: Implement proper iCalendar parsing + + Ok(event) + } + + /// Convert event to iCalendar format + pub fn to_ical(&self) -> CalDavResult { + let mut ical = String::new(); + + // iCalendar header + ical.push_str("BEGIN:VCALENDAR\r\n"); + ical.push_str("VERSION:2.0\r\n"); + ical.push_str("PRODID:-//CalDAV Sync//EN\r\n"); + ical.push_str("BEGIN:VEVENT\r\n"); + + // Basic properties + ical.push_str(&format!("UID:{}\r\n", self.uid)); + ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary))); + + if let Some(description) = &self.description { + ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); + } + + // Dates + if self.all_day { + ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", + self.start.format("%Y%m%d"))); + ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", + self.end.format("%Y%m%d"))); + } else { + ical.push_str(&format!("DTSTART:{}\r\n", + self.start.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("DTEND:{}\r\n", + self.end.format("%Y%m%dT%H%M%SZ"))); + } + + // Status + ical.push_str(&format!("STATUS:{}\r\n", match self.status { + EventStatus::Confirmed => "CONFIRMED", + EventStatus::Tentative => "TENTATIVE", + EventStatus::Cancelled => "CANCELLED", + })); + + // Class + ical.push_str(&format!("CLASS:{}\r\n", match self.event_type { + EventType::Public => "PUBLIC", + EventType::Private => "PRIVATE", + EventType::Confidential => "CONFIDENTIAL", + })); + + // Timestamps + ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence)); + + // Location + if let Some(location) = &self.location { + ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location))); + } + + // Organizer + if let Some(organizer) = &self.organizer { + ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email)); + } + + // Attendees + for attendee in &self.attendees { + ical.push_str(&format!("ATTENDEE:mailto:{}\r\n", attendee.email)); + } + + // iCalendar footer + ical.push_str("END:VEVENT\r\n"); + ical.push_str("END:VCALENDAR\r\n"); + + Ok(ical) + } + + /// Update the event's last modified timestamp + pub fn touch(&mut self) { + self.last_modified = Utc::now(); + self.sequence += 1; + } + + /// Check if event occurs on a specific date + pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool { + let start_date = self.start.date_naive(); + let end_date = self.end.date_naive(); + + if self.all_day { + start_date <= date && end_date >= date + } else { + start_date <= date && end_date >= date + } + } + + /// Get event duration + pub fn duration(&self) -> chrono::Duration { + self.end.signed_duration_since(self.start) + } + + /// Check if event is currently in progress + pub fn is_in_progress(&self) -> bool { + let now = Utc::now(); + now >= self.start && now <= self.end + } +} + +/// Escape text for iCalendar format +fn escape_ical_text(text: &str) -> String { + text + .replace('\\', "\\\\") + .replace(',', "\\,") + .replace(';', "\\;") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +/// Parse iCalendar date/time +fn parse_ical_datetime(dt_str: &str) -> CalDavResult> { + // Handle different iCalendar date formats + if dt_str.len() == 8 { + // DATE format (YYYYMMDD) + let naive_date = chrono::NaiveDate::parse_from_str(dt_str, "%Y%m%d")?; + let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap(); + Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc)) + } else if dt_str.ends_with('Z') { + // UTC datetime format (YYYYMMDDTHHMMSSZ) + let dt_without_z = &dt_str[..dt_str.len()-1]; + let naive_dt = NaiveDateTime::parse_from_str(dt_without_z, "%Y%m%dT%H%M%S")?; + Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc)) + } else { + // Local time format - this would need timezone handling + Err(CalDavError::EventProcessing( + "Local time parsing not implemented".to_string() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_creation() { + let start = Utc::now(); + let end = start + chrono::Duration::hours(1); + let event = Event::new("Test Event".to_string(), start, end); + + assert_eq!(event.summary, "Test Event"); + assert_eq!(event.start, start); + assert_eq!(event.end, end); + assert!(!event.all_day); + assert_eq!(event.status, EventStatus::Confirmed); + } + + #[test] + fn test_all_day_event() { + let date = chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(); + let event = Event::new_all_day("Christmas".to_string(), date); + + assert_eq!(event.summary, "Christmas"); + assert!(event.all_day); + assert!(event.occurs_on(date)); + } + + #[test] + fn test_event_to_ical() { + let event = Event::new( + "Meeting".to_string(), + DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ), + DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ), + ); + + let ical = event.to_ical().unwrap(); + assert!(ical.contains("SUMMARY:Meeting")); + assert!(ical.contains("DTSTART:20231225T100000Z")); + assert!(ical.contains("DTEND:20231225T110000Z")); + assert!(ical.contains("BEGIN:VCALENDAR")); + assert!(ical.contains("END:VCALENDAR")); + } + + #[test] + fn test_ical_text_escaping() { + let text = "Hello, world; this\\is a test"; + let escaped = escape_ical_text(text); + assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..650f25a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,49 @@ +//! CalDAV Calendar Synchronizer Library +//! +//! This library provides functionality for synchronizing calendars with CalDAV servers. +//! It includes support for event management, timezone handling, and calendar filtering. + +pub mod config; +pub mod error; +pub mod caldav_client; +pub mod event; +pub mod timezone; +pub mod calendar_filter; +pub mod sync; + +// Re-export main types for convenience +pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig}; +pub use error::{CalDavError, CalDavResult}; +pub use caldav_client::CalDavClient; +pub use event::{Event, EventStatus, EventType}; +pub use timezone::TimezoneHandler; +pub use calendar_filter::{CalendarFilter, FilterRule}; +pub use sync::{SyncEngine, SyncResult}; + +/// Library version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Initialize the library with default configuration +pub fn init() -> CalDavResult<()> { + // Initialize logging if not already set up + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .try_init(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_library_init() { + assert!(init().is_ok()); + } + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ed36b74 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use clap::Parser; +use tracing::{info, warn, error, Level}; +use tracing_subscriber; +use caldav_sync::{Config, SyncEngine, CalDavResult}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "caldav-sync")] +#[command(about = "A CalDAV calendar synchronization tool")] +#[command(version)] +struct Cli { + /// Configuration file path + #[arg(short, long, default_value = "config/default.toml")] + config: PathBuf, + + /// CalDAV server URL (overrides config file) + #[arg(short, long)] + server_url: Option, + + /// Username for authentication (overrides config file) + #[arg(short, long)] + username: Option, + + /// Password for authentication (overrides config file) + #[arg(short, long)] + password: Option, + + /// Calendar name to sync (overrides config file) + #[arg(long)] + calendar: Option, + + /// Enable debug logging + #[arg(short, long)] + debug: bool, + + /// Perform a one-time sync and exit + #[arg(long)] + once: bool, + + /// Force a full resynchronization + #[arg(long)] + full_resync: bool, + + /// List events and exit + #[arg(long)] + list_events: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging + let log_level = if cli.debug { Level::DEBUG } else { Level::INFO }; + tracing_subscriber::fmt() + .with_max_level(log_level) + .with_target(false) + .compact() + .init(); + + info!("Starting CalDAV synchronization tool v{}", env!("CARGO_PKG_VERSION")); + + // Load configuration + let mut config = match Config::from_file(&cli.config) { + Ok(config) => { + info!("Loaded configuration from: {}", cli.config.display()); + config + } + Err(e) => { + warn!("Failed to load config file: {}", e); + info!("Using default configuration and environment variables"); + Config::from_env()? + } + }; + + // Override configuration with command line arguments + if let Some(ref server_url) = cli.server_url { + config.server.url = server_url.clone(); + } + if let Some(ref username) = cli.username { + config.server.username = username.clone(); + } + if let Some(ref password) = cli.password { + config.server.password = password.clone(); + } + if let Some(ref calendar) = cli.calendar { + config.calendar.name = calendar.clone(); + } + + // Validate configuration + if let Err(e) = config.validate() { + error!("Configuration validation failed: {}", e); + return Err(e.into()); + } + + info!("Server URL: {}", config.server.url); + info!("Username: {}", config.server.username); + info!("Calendar: {}", config.calendar.name); + + // Initialize and run synchronization + match run_sync(config, &cli).await { + Ok(_) => { + info!("CalDAV synchronization completed successfully"); + } + Err(e) => { + error!("CalDAV synchronization failed: {}", e); + return Err(e.into()); + } + } + + Ok(()) +} + +async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { + // Create sync engine + let mut sync_engine = SyncEngine::new(config.clone()).await?; + + if cli.list_events { + // List events and exit + info!("Listing events from calendar: {}", config.calendar.name); + + // Perform a sync to get events + let sync_result = sync_engine.sync_full().await?; + info!("Sync completed: {} events processed", sync_result.events_processed); + + // Get and display events + let events = sync_engine.get_local_events(); + println!("Found {} events:", events.len()); + + for event in events { + println!(" - {} ({} to {})", + event.summary, + event.start.format("%Y-%m-%d %H:%M"), + event.end.format("%Y-%m-%d %H:%M") + ); + } + + return Ok(()); + } + + if cli.once || cli.full_resync { + // Perform one-time sync + if cli.full_resync { + info!("Performing full resynchronization"); + let result = sync_engine.force_full_resync().await?; + info!("Full resync completed: {} events processed", result.events_processed); + } else { + info!("Performing one-time synchronization"); + let result = sync_engine.sync_incremental().await?; + info!("Sync completed: {} events processed", result.events_processed); + } + } else { + // Start continuous synchronization + info!("Starting continuous synchronization"); + + if config.sync.sync_on_startup { + info!("Performing initial sync"); + match sync_engine.sync_incremental().await { + Ok(result) => { + info!("Initial sync completed: {} events processed", result.events_processed); + } + Err(e) => { + warn!("Initial sync failed: {}", e); + } + } + } + + // Start auto-sync loop + sync_engine.start_auto_sync().await?; + } + + Ok(()) +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..c456234 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,518 @@ +//! Synchronization engine for CalDAV calendars + +use crate::{config::Config, caldav_client::CalDavClient, event::Event, calendar_filter::CalendarFilter, error::CalDavResult}; +use chrono::{DateTime, Utc, Duration}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use tokio::time::sleep; +use tracing::{info, warn, error, debug}; + +/// Synchronization engine for managing calendar synchronization +pub struct SyncEngine { + /// CalDAV client + client: CalDavClient, + /// Configuration + config: Config, + /// Local cache of events + local_events: HashMap, + /// Sync state + sync_state: SyncState, + /// Timezone handler + timezone_handler: crate::timezone::TimezoneHandler, +} + +/// Synchronization state +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncState { + /// Last successful sync timestamp + pub last_sync: Option>, + /// Sync token for incremental syncs + pub sync_token: Option, + /// Known event ETags + pub event_etags: HashMap, + /// Sync statistics + pub stats: SyncStats, +} + +/// Synchronization statistics +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SyncStats { + /// Total events synchronized + pub total_events: u64, + /// Events created locally + pub local_created: u64, + /// Events updated locally + pub local_updated: u64, + /// Events deleted locally + pub local_deleted: u64, + /// Events created on server + pub server_created: u64, + /// Events updated on server + pub server_updated: u64, + /// Events deleted on server + pub server_deleted: u64, + /// Sync conflicts + pub conflicts: u64, + /// Last sync duration in milliseconds + pub last_sync_duration_ms: u64, +} + +/// Synchronization result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncResult { + /// Success flag + pub success: bool, + /// Number of events processed + pub events_processed: usize, + /// Events created + pub events_created: usize, + /// Events updated + pub events_updated: usize, + /// Events deleted + pub events_deleted: usize, + /// Conflicts encountered + pub conflicts: usize, + /// Error message if any + pub error: Option, + /// Sync duration in milliseconds + pub duration_ms: u64, +} + +impl SyncEngine { + /// Create a new synchronization engine + pub async fn new(config: Config) -> CalDavResult { + let client = CalDavClient::new(config.server.clone())?; + let timezone_handler = crate::timezone::TimezoneHandler::new(&config.calendar.timezone)?; + + let engine = Self { + client, + config, + local_events: HashMap::new(), + sync_state: SyncState { + last_sync: None, + sync_token: None, + event_etags: HashMap::new(), + stats: SyncStats::default(), + }, + timezone_handler, + }; + + // Test connection + engine.client.test_connection().await?; + + Ok(engine) + } + + /// Perform a full synchronization + pub async fn sync_full(&mut self) -> CalDavResult { + let start_time = Utc::now(); + info!("Starting full calendar synchronization"); + + let mut result = SyncResult { + success: false, + events_processed: 0, + events_created: 0, + events_updated: 0, + events_deleted: 0, + conflicts: 0, + error: None, + duration_ms: 0, + }; + + match self.do_sync_full(&mut result).await { + Ok(_) => { + result.success = true; + info!("Full synchronization completed successfully"); + } + Err(e) => { + result.error = Some(e.to_string()); + error!("Full synchronization failed: {}", e); + return Err(e); + } + } + + result.duration_ms = (Utc::now() - start_time).num_milliseconds() as u64; + self.sync_state.stats.last_sync_duration_ms = result.duration_ms; + + Ok(result) + } + + /// Perform incremental synchronization + pub async fn sync_incremental(&mut self) -> CalDavResult { + let start_time = Utc::now(); + info!("Starting incremental calendar synchronization"); + + let mut result = SyncResult { + success: false, + events_processed: 0, + events_created: 0, + events_updated: 0, + events_deleted: 0, + conflicts: 0, + error: None, + duration_ms: 0, + }; + + if self.sync_state.last_sync.is_none() { + // No previous sync, do full sync + return self.sync_full().await; + } + + match self.do_sync_incremental(&mut result).await { + Ok(_) => { + result.success = true; + info!("Incremental synchronization completed successfully"); + } + Err(e) => { + result.error = Some(e.to_string()); + error!("Incremental synchronization failed: {}", e); + return Err(e); + } + } + + result.duration_ms = 0; + self.sync_state.stats.last_sync_duration_ms = result.duration_ms; + + Ok(result) + } + + /// Internal full sync implementation + async fn do_sync_full(&mut self, result: &mut SyncResult) -> CalDavResult<()> { + // Get date range for sync (past 30 days to future 365 days) + let start = Utc::now() - Duration::days(30); + let end = Utc::now() + Duration::days(365); + + // Fetch all events from server + let server_events = self.client.get_events(&self.config.calendar.name, start, end).await?; + + debug!("Fetched {} events from server", server_events.len()); + + // Convert CalDavEventInfo to Event + let server_events: Vec = server_events.into_iter().map(|caldav_event| { + // Simple conversion - in a real implementation, you'd parse the iCalendar data + Event::new(caldav_event.summary, caldav_event.start, caldav_event.end) + }).collect(); + + // Apply filters if configured + let filtered_events = if let Some(filter_config) = &self.config.filters { + let filter = self.create_filter_from_config(filter_config); + filter.filter_events_owned(server_events) + } else { + server_events + }; + + result.events_processed = filtered_events.len(); + + // Sync events + self.sync_events(&filtered_events, result).await?; + + // Update sync state + self.sync_state.last_sync = Some(Utc::now()); + self.sync_state.stats.total_events = filtered_events.len() as u64; + + Ok(()) + } + + /// Internal incremental sync implementation + async fn do_sync_incremental(&mut self, result: &mut SyncResult) -> CalDavResult<()> { + // Get date range since last sync + let start = self.sync_state.last_sync.unwrap_or_else(|| Utc::now() - Duration::days(1)); + let end = Utc::now() + Duration::days(30); + + // Fetch events updated since last sync + let server_events = self.client.get_events(&self.config.calendar.name, start, end).await?; + + debug!("Fetched {} updated events from server", server_events.len()); + + // Convert CalDavEventInfo to Event + let server_events: Vec = server_events.into_iter().map(|caldav_event| { + // Simple conversion - in a real implementation, you'd parse the iCalendar data + Event::new(caldav_event.summary, caldav_event.start, caldav_event.end) + }).collect(); + + // Apply filters + let filtered_events = if let Some(filter_config) = &self.config.filters { + let filter = self.create_filter_from_config(filter_config); + filter.filter_events_owned(server_events) + } else { + server_events + }; + + result.events_processed = filtered_events.len(); + + // Sync only changed events + self.sync_events_incremental(&filtered_events, result).await?; + + // Update sync state + self.sync_state.last_sync = Some(Utc::now()); + + Ok(()) + } + + /// Sync events with local cache + async fn sync_events(&mut self, server_events: &[Event], result: &mut SyncResult) -> CalDavResult<()> { + let mut server_event_ids = HashSet::new(); + let local_event_ids: HashSet = self.local_events.keys().cloned().collect(); + + // Process server events + for server_event in server_events { + server_event_ids.insert(server_event.uid.clone()); + + match self.local_events.get(&server_event.uid) { + Some(local_event) => { + // Event exists locally, check for updates + if self.event_changed(local_event, server_event) { + self.update_local_event(server_event.clone()); + result.events_updated += 1; + debug!("Updated event: {}", server_event.uid); + } + } + None => { + // New event on server + self.add_local_event(server_event.clone()); + result.events_created += 1; + debug!("Added new event: {}", server_event.uid); + } + } + } + + // Find events to delete (local events not on server) + if self.config.sync.delete_missing { + for event_id in &local_event_ids { + if !server_event_ids.contains(event_id) { + self.local_events.remove(event_id); + self.sync_state.event_etags.remove(event_id); + result.events_deleted += 1; + debug!("Deleted local event: {}", event_id); + } + } + } + + Ok(()) + } + + /// Sync events incrementally + async fn sync_events_incremental(&mut self, server_events: &[Event], result: &mut SyncResult) -> CalDavResult<()> { + for server_event in server_events { + match self.local_events.get(&server_event.uid) { + Some(local_event) => { + if self.event_changed(local_event, server_event) { + self.update_local_event(server_event.clone()); + result.events_updated += 1; + debug!("Updated event incrementally: {}", server_event.uid); + } + } + None => { + self.add_local_event(server_event.clone()); + result.events_created += 1; + debug!("Added new event incrementally: {}", server_event.uid); + } + } + } + + Ok(()) + } + + /// Check if an event has changed + fn event_changed(&self, local_event: &Event, server_event: &Event) -> bool { + // Simple comparison - in a real implementation, you'd compare ETags + local_event.last_modified != server_event.last_modified || + local_event.sequence != server_event.sequence + } + + /// Add event to local cache + fn add_local_event(&mut self, event: Event) { + self.local_events.insert(event.uid.clone(), event.clone()); + self.sync_state.stats.local_created += 1; + } + + /// Update event in local cache + fn update_local_event(&mut self, event: Event) { + self.local_events.insert(event.uid.clone(), event.clone()); + self.sync_state.stats.local_updated += 1; + } + + /// Create filter from configuration + fn create_filter_from_config(&self, filter_config: &crate::config::FilterConfig) -> CalendarFilter { + let mut filter = crate::calendar_filter::CalendarFilter::new(true); + + // Add date range filter + if let (Some(start_str), Some(end_str)) = (&filter_config.start_date, &filter_config.end_date) { + if let (Ok(start), Ok(end)) = ( + start_str.parse::>(), + end_str.parse::>() + ) { + filter.add_rule(crate::calendar_filter::FilterRule::DateRange( + crate::calendar_filter::DateRangeFilter::new(start, end) + )); + } + } + + // Add keyword filter + if let Some(keywords) = &filter_config.keywords { + if !keywords.is_empty() { + filter.add_rule(crate::calendar_filter::FilterRule::Keywords( + crate::calendar_filter::KeywordFilter::new(keywords.clone(), false) + )); + } + } + + // Add exclude keywords filter (custom implementation needed) + if let Some(exclude_keywords) = &filter_config.exclude_keywords { + if !exclude_keywords.is_empty() { + // This would need a custom filter implementation + debug!("Exclude keywords filter not yet implemented: {:?}", exclude_keywords); + } + } + + filter + } + + /// Get all local events + pub fn get_local_events(&self) -> Vec { + self.local_events.values().cloned().collect() + } + + /// Get local events filtered by criteria + pub fn get_local_events_filtered(&self, filter: &CalendarFilter) -> Vec { + let events: Vec = self.local_events.values().cloned().collect(); + filter.filter_events_owned(events) + } + + /// Get synchronization state + pub fn get_sync_state(&self) -> &SyncState { + &self.sync_state + } + + /// Get synchronization statistics + pub fn get_sync_stats(&self) -> &SyncStats { + &self.sync_state.stats + } + + /// Force a full resynchronization + pub async fn force_full_resync(&mut self) -> CalDavResult { + info!("Forcing full resynchronization"); + + // Clear local cache and sync state + self.local_events.clear(); + self.sync_state.event_etags.clear(); + self.sync_state.sync_token = None; + + // Perform full sync + self.sync_full().await + } + + /// Start automatic synchronization loop + pub async fn start_auto_sync(&mut self) -> CalDavResult<()> { + info!("Starting automatic synchronization with interval: {} seconds", self.config.sync.interval); + + loop { + // Sync on startup if configured + if self.config.sync.sync_on_startup { + match self.sync_incremental().await { + Ok(result) => { + info!("Auto sync completed: {} events processed", result.events_processed); + } + Err(e) => { + warn!("Auto sync failed: {}", e); + } + } + } + + // Wait for next sync + sleep(tokio::time::Duration::from_secs(self.config.sync.interval)).await; + } + } + + /// Create a new event on the server + pub async fn create_event(&mut self, mut event: Event) -> CalDavResult<()> { + // Generate UID if not present + if event.uid.is_empty() { + event.uid = uuid::Uuid::new_v4().to_string(); + } + + // Convert to iCalendar format + let ical_data = event.to_ical()?; + + // Upload to server + self.client.put_event(&self.config.calendar.name, &event.uid, &ical_data).await?; + + // Add to local cache + self.add_local_event(event); + + Ok(()) + } + + /// Update an existing event on the server + pub async fn update_event(&mut self, mut event: Event) -> CalDavResult<()> { + // Update modification timestamp + event.touch(); + + // Convert to iCalendar format + let ical_data = event.to_ical()?; + + // Update on server + self.client.put_event(&self.config.calendar.name, &event.uid, &ical_data).await?; + + // Update local cache + self.update_local_event(event); + + Ok(()) + } + + /// Delete an event from the server + pub async fn delete_event(&mut self, event_id: &str) -> CalDavResult<()> { + // Delete from server + self.client.delete_event(&self.config.calendar.name, event_id).await?; + + // Remove from local cache + self.local_events.remove(event_id); + self.sync_state.event_etags.remove(event_id); + self.sync_state.stats.local_deleted += 1; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, ServerConfig, CalendarConfig, SyncConfig}; + + #[test] + fn test_sync_state_creation() { + let state = SyncState { + last_sync: None, + sync_token: None, + event_etags: HashMap::new(), + stats: SyncStats::default(), + }; + + assert!(state.last_sync.is_none()); + assert!(state.sync_token.is_none()); + assert_eq!(state.stats.total_events, 0); + } + + #[test] + fn test_sync_result_creation() { + let result = SyncResult { + success: true, + events_processed: 10, + events_created: 2, + events_updated: 3, + events_deleted: 1, + conflicts: 0, + error: None, + duration_ms: 1000, + }; + + assert!(result.success); + assert_eq!(result.events_processed, 10); + assert_eq!(result.events_created, 2); + assert_eq!(result.events_updated, 3); + assert_eq!(result.events_deleted, 1); + assert_eq!(result.conflicts, 0); + assert!(result.error.is_none()); + assert_eq!(result.duration_ms, 1000); + } +} diff --git a/src/timezone.rs b/src/timezone.rs new file mode 100644 index 0000000..e4514ad --- /dev/null +++ b/src/timezone.rs @@ -0,0 +1,327 @@ +//! Timezone handling utilities + +use crate::error::{CalDavError, CalDavResult}; +use chrono::{DateTime, Utc, Local, TimeZone, NaiveDateTime, Offset}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Timezone handler for managing timezone conversions +#[derive(Debug, Clone)] +pub struct TimezoneHandler { + /// Default timezone + default_tz: Tz, + /// Timezone cache + timezone_cache: HashMap, +} + +impl TimezoneHandler { + /// Create a new timezone handler with the given default timezone + pub fn new(default_timezone: &str) -> CalDavResult { + let default_tz: Tz = default_timezone.parse() + .map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone)))?; + + let mut cache = HashMap::new(); + cache.insert(default_timezone.to_string(), default_tz); + + Ok(Self { + default_tz, + timezone_cache: cache, + }) + } + + /// Create a timezone handler with system local timezone + pub fn with_local_timezone() -> CalDavResult { + let local_tz = Self::get_system_timezone()?; + Self::new(&local_tz) + } + + /// Parse a datetime with timezone information + pub fn parse_datetime(&mut self, dt_str: &str, timezone: Option<&str>) -> CalDavResult> { + match timezone { + Some(tz) => { + let tz_obj = self.get_timezone(tz)?; + self.parse_datetime_with_tz(dt_str, tz_obj) + } + None => { + // Try to parse as UTC first + if let Ok(dt) = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%SZ") { + Ok(DateTime::from_naive_utc_and_offset(dt, Utc)) + } else { + // Try to parse as local time + let local_dt = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S")?; + let local_dt = Local.from_local_datetime(&local_dt) + .single() + .ok_or_else(|| CalDavError::Timezone("Ambiguous local time".to_string()))?; + Ok(local_dt.with_timezone(&Utc)) + } + } + } + } + + /// Convert UTC datetime to a specific timezone + pub fn convert_to_timezone(&mut self, dt: DateTime, timezone: &str) -> CalDavResult> { + let tz_obj = self.get_timezone(timezone)?; + Ok(dt.with_timezone(&tz_obj)) + } + + /// Convert datetime from a specific timezone to UTC + pub fn convert_from_timezone(&mut self, dt: DateTime, timezone: &str) -> CalDavResult> { + let _tz_obj = self.get_timezone(timezone)?; + Ok(dt.with_timezone(&Utc)) + } + + /// Format datetime in iCalendar format + pub fn format_ical_datetime(&mut self, dt: DateTime, use_local_time: bool) -> CalDavResult { + if use_local_time { + let local_dt = self.convert_to_timezone(dt, &self.default_tz.to_string())?; + Ok(local_dt.format("%Y%m%dT%H%M%S").to_string()) + } else { + Ok(dt.format("%Y%m%dT%H%M%SZ").to_string()) + } + } + + /// Format date in iCalendar format (for all-day events) + pub fn format_ical_date(&self, dt: DateTime) -> String { + dt.format("%Y%m%d").to_string() + } + + /// Get a timezone object, using cache if available + fn get_timezone(&mut self, timezone: &str) -> CalDavResult { + if let Some(tz) = self.timezone_cache.get(timezone) { + return Ok(*tz); + } + + let tz_obj: Tz = timezone.parse() + .map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", timezone)))?; + + self.timezone_cache.insert(timezone.to_string(), tz_obj); + Ok(tz_obj) + } + + /// Parse datetime with specific timezone + fn parse_datetime_with_tz(&self, dt_str: &str, tz: Tz) -> CalDavResult> { + let naive_dt = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S")?; + let local_dt = tz.from_local_datetime(&naive_dt) + .single() + .ok_or_else(|| CalDavError::Timezone("Ambiguous local time".to_string()))?; + Ok(local_dt.with_timezone(&Utc)) + } + + /// Get system timezone + fn get_system_timezone() -> CalDavResult { + // Try to get timezone from environment + if let Ok(tz) = std::env::var("TZ") { + return Ok(tz); + } + + // Try common timezone detection methods + #[cfg(unix)] + { + if let Ok(link) = std::fs::read_link("/etc/localtime") { + if let Some(tz_path) = link.to_str() { + if let Some(tz_name) = tz_path.strip_prefix("../usr/share/zoneinfo/") { + return Ok(tz_name.to_string()); + } + if let Some(tz_name) = tz_path.strip_prefix("/usr/share/zoneinfo/") { + return Ok(tz_name.to_string()); + } + } + } + } + + #[cfg(windows)] + { + // Windows timezone detection would require additional libraries + // For now, default to UTC on Windows + } + + // Fallback to UTC + Ok("UTC".to_string()) + } + + /// Get the default timezone + pub fn default_timezone(&self) -> String { + self.default_tz.to_string() + } + + /// List all available timezones + pub fn list_timezones() -> Vec<&'static str> { + chrono_tz::TZ_VARIANTS.iter().map(|tz| tz.name()).collect() + } + + /// Validate timezone string + pub fn validate_timezone(timezone: &str) -> bool { + timezone.parse::().is_ok() + } + + /// Get current time in default timezone + pub fn now(&self) -> DateTime { + Utc::now().with_timezone(&self.default_tz) + } + + /// Get current time in UTC + pub fn now_utc() -> DateTime { + Utc::now() + } +} + +impl Default for TimezoneHandler { + fn default() -> Self { + Self::new("UTC").unwrap() + } +} + +/// Timezone information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimezoneInfo { + /// Timezone name + pub name: String, + /// Current offset from UTC in seconds + pub offset: i32, + /// Daylight Saving Time active + pub dst_active: bool, + /// Timezone abbreviation + pub abbreviation: String, +} + +impl TimezoneHandler { + /// Get information about a timezone + pub fn get_timezone_info(&mut self, timezone: &str) -> CalDavResult { + let tz_obj = self.get_timezone(timezone)?; + let now = Utc::now().with_timezone(&tz_obj); + + Ok(TimezoneInfo { + name: timezone.to_string(), + offset: now.offset().fix().local_minus_utc(), + dst_active: false, // is_dst() method removed in newer chrono-tz versions + abbreviation: now.format("%Z").to_string(), + }) + } + + /// Convert between two timezones + pub fn convert_between_timezones( + &mut self, + dt: DateTime, + from_tz: &str, + to_tz: &str, + ) -> CalDavResult> { + let _from_tz_obj = self.get_timezone(from_tz)?; + let to_tz_obj = self.get_timezone(to_tz)?; + Ok(dt.with_timezone(&to_tz_obj)) + } +} + +/// Timezone-aware datetime wrapper +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZonedDateTime { + pub datetime: DateTime, + pub timezone: Option, +} + +impl ZonedDateTime { + /// Create a new timezone-aware datetime + pub fn new(datetime: DateTime, timezone: Option) -> Self { + Self { datetime, timezone } + } + + /// Create from local time + pub fn from_local(local_dt: DateTime, timezone: Option) -> Self { + Self { + datetime: local_dt.with_timezone(&Utc), + timezone, + } + } + + /// Get the datetime in UTC + pub fn utc(&self) -> DateTime { + self.datetime + } + + /// Get the datetime in the specified timezone + pub fn in_timezone(&self, handler: &mut TimezoneHandler, timezone: &str) -> CalDavResult> { + handler.convert_to_timezone(self.datetime, timezone) + } + + /// Format for iCalendar + pub fn format_ical(&self, handler: &mut TimezoneHandler) -> CalDavResult { + match &self.timezone { + Some(tz) => { + if tz == "UTC" { + handler.format_ical_datetime(self.datetime, false) + } else { + // For non-UTC timezones, we'd need to handle local time formatting + // This is a simplified implementation + handler.format_ical_datetime(self.datetime, false) + } + } + None => handler.format_ical_datetime(self.datetime, false), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timezone_handler_creation() { + let handler = TimezoneHandler::new("UTC").unwrap(); + assert_eq!(handler.default_timezone(), "UTC"); + } + + #[test] + fn test_utc_datetime_parsing() { + let handler = TimezoneHandler::default(); + let dt = handler.parse_datetime("20231225T100000Z", None).unwrap(); + assert_eq!(dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z"); + } + + #[test] + fn test_timezone_validation() { + assert!(TimezoneHandler::validate_timezone("UTC")); + assert!(TimezoneHandler::validate_timezone("America/New_York")); + assert!(TimezoneHandler::validate_timezone("Europe/London")); + assert!(!TimezoneHandler::validate_timezone("Invalid/Timezone")); + } + + #[test] + fn test_ical_formatting() { + let handler = TimezoneHandler::default(); + let dt = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + + let ical_utc = handler.format_ical_datetime(dt, false).unwrap(); + assert_eq!(ical_utc, "20231225T100000Z"); + + let ical_date = handler.format_ical_date(dt); + assert_eq!(ical_date, "20231225"); + } + + #[test] + fn test_timezone_conversion() { + let mut handler = TimezoneHandler::new("UTC").unwrap(); + let dt = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + + // Convert to UTC (should be the same) + let utc_dt = handler.convert_to_timezone(dt, "UTC").unwrap(); + assert_eq!(utc_dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z"); + } + + #[test] + fn test_zoned_datetime() { + let dt = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let zdt = ZonedDateTime::new(dt, Some("UTC".to_string())); + + assert_eq!(zdt.utc(), dt); + assert_eq!(zdt.timezone, Some("UTC".to_string())); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..57ec2e2 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,285 @@ +use caldav_sync::{Config, CalDavResult}; + +#[cfg(test)] +mod config_tests { + use super::*; + + #[test] + fn test_default_config() -> CalDavResult<()> { + let config = Config::default(); + assert_eq!(config.server.url, "https://caldav.example.com"); + assert_eq!(config.calendar.name, "calendar"); + assert_eq!(config.sync.interval, 300); + config.validate()?; + Ok(()) + } + + #[test] + fn test_config_validation() -> CalDavResult<()> { + let mut config = Config::default(); + + // Should fail with empty credentials + assert!(config.validate().is_err()); + + config.server.username = "test_user".to_string(); + config.server.password = "test_pass".to_string(); + + // Should succeed now + assert!(config.validate().is_ok()); + Ok(()) + } +} + +#[cfg(test)] +mod error_tests { + use caldav_sync::{CalDavError, CalDavResult}; + + #[test] + fn test_error_retryable() { + let network_error = CalDavError::Network( + reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) + ); + assert!(network_error.is_retryable()); + + let auth_error = CalDavError::Authentication("Invalid credentials".to_string()); + assert!(!auth_error.is_retryable()); + + let config_error = CalDavError::Config("Missing URL".to_string()); + assert!(!config_error.is_retryable()); + } + + #[test] + fn test_error_classification() { + let auth_error = CalDavError::Authentication("Invalid".to_string()); + assert!(auth_error.is_auth_error()); + + let config_error = CalDavError::Config("Invalid".to_string()); + assert!(config_error.is_config_error()); + } +} + +#[cfg(test)] +mod event_tests { + use caldav_sync::event::{Event, EventStatus, EventType}; + use chrono::{DateTime, Utc, NaiveDate}; + + #[test] + fn test_event_creation() { + let start = Utc::now(); + let end = start + chrono::Duration::hours(1); + let event = Event::new("Test Event".to_string(), start, end); + + assert_eq!(event.summary, "Test Event"); + assert_eq!(event.start, start); + assert_eq!(event.end, end); + assert!(!event.all_day); + assert_eq!(event.status, EventStatus::Confirmed); + assert_eq!(event.event_type, EventType::Public); + } + + #[test] + fn test_all_day_event() { + let date = NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(); + let event = Event::new_all_day("Christmas".to_string(), date); + + assert_eq!(event.summary, "Christmas"); + assert!(event.all_day); + assert!(event.occurs_on(date)); + } + + #[test] + fn test_event_to_ical() -> caldav_sync::CalDavResult<()> { + let event = Event::new( + "Meeting".to_string(), + DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ), + DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ), + ); + + let ical = event.to_ical()?; + assert!(ical.contains("SUMMARY:Meeting")); + assert!(ical.contains("DTSTART:20231225T100000Z")); + assert!(ical.contains("DTEND:20231225T110000Z")); + assert!(ical.contains("BEGIN:VCALENDAR")); + assert!(ical.contains("END:VCALENDAR")); + Ok(()) + } +} + +#[cfg(test)] +mod timezone_tests { + use caldav_sync::timezone::TimezoneHandler; + + #[test] + fn test_timezone_handler_creation() -> CalDavResult<()> { + let handler = TimezoneHandler::new("UTC")?; + assert_eq!(handler.default_timezone(), "UTC"); + Ok(()) + } + + #[test] + fn test_timezone_validation() { + assert!(TimezoneHandler::validate_timezone("UTC")); + assert!(TimezoneHandler::validate_timezone("America/New_York")); + assert!(TimezoneHandler::validate_timezone("Europe/London")); + assert!(!TimezoneHandler::validate_timezone("Invalid/Timezone")); + } + + #[test] + fn test_ical_formatting() -> CalDavResult<()> { + let handler = TimezoneHandler::default(); + let dt = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + + let ical_utc = handler.format_ical_datetime(dt, false)?; + assert_eq!(ical_utc, "20231225T100000Z"); + + let ical_date = handler.format_ical_date(dt); + assert_eq!(ical_date, "20231225"); + Ok(()) + } +} + +#[cfg(test)] +mod filter_tests { + use caldav_sync::calendar_filter::{ + CalendarFilter, FilterRule, DateRangeFilter, KeywordFilter, + EventTypeFilter, EventStatusFilter, FilterBuilder + }; + use caldav_sync::event::{Event, EventStatus, EventType}; + use chrono::{DateTime, Utc}; + + #[test] + fn test_date_range_filter() { + let start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T000000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let end = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T235959", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + + let filter = DateRangeFilter::new(start, end); + + let event_start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let event = Event::new("Test".to_string(), event_start, event_start + chrono::Duration::hours(1)); + assert!(filter.matches_event(&event)); + + let event_outside = Event::new( + "Test".to_string(), + start - chrono::Duration::days(1), + start - chrono::Duration::hours(23), + ); + assert!(!filter_outside.matches_event(&event_outside)); + } + + #[test] + fn test_keyword_filter() { + let filter = KeywordFilter::new(vec!["meeting".to_string(), "important".to_string()], false); + + let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); + assert!(filter.matches_event(&event1)); + + let event2 = Event::new("Lunch".to_string(), Utc::now(), Utc::now()); + assert!(!filter.matches_event(&event2)); + } + + #[test] + fn test_calendar_filter() { + let mut filter = CalendarFilter::new(true); // OR logic + filter.add_rule(FilterRule::Keywords(KeywordFilter::new(vec!["meeting".to_string()], false))); + filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(vec![EventStatus::Cancelled]))); + + let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); + assert!(filter.matches_event(&event1)); // Matches keyword + + let mut event2 = Event::new("Holiday".to_string(), Utc::now(), Utc::now()); + event2.status = EventStatus::Cancelled; + assert!(filter.matches_event(&event2)); // Matches status + + let event3 = Event::new("Lunch".to_string(), Utc::now(), Utc::now()); + assert!(!filter.matches_event(&event3)); // Matches neither + } + + #[test] + fn test_filter_builder() { + let filter = FilterBuilder::new() + .match_any(false) // AND logic + .keywords(vec!["meeting".to_string()]) + .event_types(vec![EventType::Public]) + .build(); + + let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now()); + assert!(filter.matches_event(&event)); // Matches both conditions + } +} + +#[cfg(test)] +mod integration_tests { + use super::*; + + #[test] + fn test_library_initialization() -> CalDavResult<()> { + caldav_sync::init()?; + Ok(()) + } + + #[test] + fn test_version() { + assert!(!caldav_sync::VERSION.is_empty()); + } + + #[test] + fn test_full_workflow() -> CalDavResult<()> { + // Initialize library + caldav_sync::init()?; + + // Create configuration + let config = Config::default(); + + // Validate configuration + config.validate()?; + + // Create some test events + let event1 = caldav_sync::event::Event::new( + "Test Meeting".to_string(), + Utc::now(), + Utc::now() + chrono::Duration::hours(1), + ); + + let event2 = caldav_sync::event::Event::new_all_day( + "Test Holiday".to_string(), + chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(), + ); + + // Test event serialization + let ical1 = event1.to_ical()?; + let ical2 = event2.to_ical()?; + + assert!(!ical1.is_empty()); + assert!(!ical2.is_empty()); + assert!(ical1.contains("SUMMARY:Test Meeting")); + assert!(ical2.contains("SUMMARY:Test Holiday")); + + // Test filtering + let filter = caldav_sync::calendar_filter::FilterBuilder::new() + .keywords(vec!["test".to_string()]) + .build(); + + assert!(filter.matches_event(&event1)); + assert!(filter.matches_event(&event2)); + + Ok(()) + } +}