diff --git a/Cargo.lock b/Cargo.lock index 3860550..0e22e71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -67,12 +76,34 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bevy-tasks-cli" version = "0.1.0" @@ -83,6 +114,8 @@ dependencies = [ "clap", "colored", "fs_extra", + "rpassword", + "tokio", "uuid", ] @@ -92,11 +125,17 @@ version = "0.1.0" dependencies = [ "chrono", "directories", + "keyring", + "quick-xml", + "reqwest", "serde", "serde_json", "serde_yaml", + "sha2", "tempfile", + "tokio", "uuid", + "wiremock", ] [[package]] @@ -109,9 +148,18 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.3" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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" @@ -119,6 +167,18 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.34" @@ -134,6 +194,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -204,12 +270,100 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[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 = "directories" version = "5.0.1" @@ -231,6 +385,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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 = "equivalent" version = "1.0.2" @@ -253,12 +418,125 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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 = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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" @@ -266,8 +544,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -277,9 +557,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -294,6 +595,120 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "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 = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -318,6 +733,108 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "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.0" @@ -328,6 +845,22 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -350,6 +883,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -358,9 +906,18 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] [[package]] name = "libredox" @@ -378,18 +935,50 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -399,6 +988,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -417,6 +1016,71 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -426,6 +1090,70 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -441,6 +1169,44 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -449,9 +1215,106 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.0.8" @@ -465,6 +1328,41 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -477,6 +1375,48 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.219" @@ -509,6 +1449,18 @@ 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 = "serde_yaml" version = "0.9.34+deprecated" @@ -522,18 +1474,73 @@ dependencies = [ "unsafe-libyaml", ] +[[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 = "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.106" @@ -545,6 +1552,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "tempfile" version = "3.23.0" @@ -564,7 +1591,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -578,6 +1614,169 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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 = "unicode-ident" version = "1.0.18" @@ -590,6 +1789,30 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +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" @@ -608,6 +1831,21 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -649,6 +1887,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -681,6 +1932,57 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.2" @@ -755,6 +2057,15 @@ 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" @@ -773,6 +2084,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -959,6 +2279,29 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -967,3 +2310,126 @@ checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags", ] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 8f00525..c75200f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,6 @@ uuid = { version = "1.0", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1.0" tokio = { version = "1.40", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha2 = "0.10" +quick-xml = "0.36" diff --git a/crates/bevy-tasks-cli/Cargo.toml b/crates/bevy-tasks-cli/Cargo.toml index 324c26f..fd6a15c 100644 --- a/crates/bevy-tasks-cli/Cargo.toml +++ b/crates/bevy-tasks-cli/Cargo.toml @@ -15,3 +15,5 @@ anyhow = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } fs_extra = "1.3" +tokio = { workspace = true } +rpassword = "5.0" diff --git a/crates/bevy-tasks-cli/src/commands/mod.rs b/crates/bevy-tasks-cli/src/commands/mod.rs index d65ef91..fe9d468 100644 --- a/crates/bevy-tasks-cli/src/commands/mod.rs +++ b/crates/bevy-tasks-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod workspace; pub mod list; pub mod task; pub mod group; +pub mod sync; use bevy_tasks_core::{AppConfig, TaskRepository}; use anyhow::{Context, Result}; diff --git a/crates/bevy-tasks-cli/src/commands/sync.rs b/crates/bevy-tasks-cli/src/commands/sync.rs new file mode 100644 index 0000000..4bf0ea3 --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/sync.rs @@ -0,0 +1,229 @@ +use anyhow::{Context, Result}; +use colored::Colorize; +use bevy_tasks_core::sync::{SyncMode, sync_workspace, get_sync_status}; +use bevy_tasks_core::webdav::{WebDavClient, store_credentials, load_credentials}; +use crate::output; +use super::{load_config, save_config}; + +/// Run sync setup: prompt for URL, username, password, test connection, store credentials. +pub fn setup(workspace_name: Option) -> Result<()> { + let mut config = load_config()?; + + let (name, workspace) = if let Some(name) = workspace_name { + let ws = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .clone(); + (name, ws) + } else { + let (n, ws) = config.get_current_workspace() + .context("No workspace set. Use 'bevy-tasks init' to create one.")?; + (n.clone(), ws.clone()) + }; + + // Prompt for WebDAV URL + println!("WebDAV sync setup for workspace \"{}\"", name.green()); + println!(); + + let url = prompt("WebDAV URL: ")?; + if url.is_empty() { + output::error("URL cannot be empty"); + return Ok(()); + } + + let username = prompt("Username: ")?; + let password = rpassword::read_password_from_tty(Some("Password: ")) + .context("Failed to read password")?; + + // Test connection + println!(); + println!("Testing connection..."); + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + let client = WebDavClient::new(&url, &username, &password); + + match rt.block_on(client.test_connection()) { + Ok(()) => { + output::success("Connection successful!"); + } + Err(e) => { + output::error(&format!("Connection failed: {}", e)); + return Ok(()); + } + } + + // Store credentials in keychain + let domain = extract_domain(&url); + match store_credentials(&domain, &username, &password) { + Ok(()) => output::info("Credentials stored in system keychain"), + Err(e) => { + output::warning(&format!( + "Could not store in keychain ({}). Set BEVY_TASKS_WEBDAV_USER and BEVY_TASKS_WEBDAV_PASS env vars instead.", + e + )); + } + } + + // Update workspace config with WebDAV URL + let mut ws = workspace; + ws.webdav_url = Some(url); + config.add_workspace(name, ws); + save_config(&config)?; + + output::success("Sync setup complete. Run 'bevy-tasks sync' to sync."); + Ok(()) +} + +/// Execute a sync operation. +pub fn execute(mode: SyncMode, workspace_name: Option) -> Result<()> { + let config = load_config()?; + + let (name, workspace) = if let Some(name) = workspace_name { + let ws = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .clone(); + (name, ws) + } else { + let (n, ws) = config.get_current_workspace() + .context("No workspace set. Use 'bevy-tasks init' to create one.")?; + (n.clone(), ws.clone()) + }; + + let url = workspace.webdav_url.as_ref() + .ok_or_else(|| anyhow::anyhow!( + "No WebDAV URL configured for workspace '{}'. Run 'bevy-tasks sync --setup' first.", name + ))?; + + let domain = extract_domain(url); + let (username, password) = load_credentials(&domain) + .context("Failed to load credentials")?; + + let mode_str = match mode { + SyncMode::Full => "Syncing", + SyncMode::Push => "Pushing", + SyncMode::Pull => "Pulling", + }; + println!("{} workspace \"{}\"...", mode_str, name.green()); + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + let result = rt.block_on(sync_workspace( + &workspace.path, + url, + &username, + &password, + mode, + Some(Box::new(|msg: &str| { println!("{}", msg); })), + )).context("Sync failed")?; + + // Print summary + let mut parts = Vec::new(); + if result.uploaded > 0 { parts.push(format!("{} uploaded", result.uploaded)); } + if result.downloaded > 0 { parts.push(format!("{} downloaded", result.downloaded)); } + if result.deleted_local > 0 { parts.push(format!("{} deleted locally", result.deleted_local)); } + if result.deleted_remote > 0 { parts.push(format!("{} deleted remotely", result.deleted_remote)); } + if result.conflicts > 0 { parts.push(format!("{} conflicts", result.conflicts)); } + + if parts.is_empty() { + output::success("Already in sync, nothing to do."); + } else { + let summary = parts.join(", "); + if result.errors.is_empty() { + output::success(&format!("Sync complete: {}", summary)); + } else { + output::warning(&format!("Sync complete with errors: {}", summary)); + for err in &result.errors { + output::error(err); + } + } + } + + Ok(()) +} + +/// Show sync status for a workspace. +pub fn status(workspace_name: Option, all: bool) -> Result<()> { + let config = load_config()?; + + if all { + // Show status for all workspaces that have sync configured + let mut found_any = false; + let mut names: Vec<_> = config.workspaces.keys().cloned().collect(); + names.sort(); + for name in names { + let ws = config.get_workspace(&name).unwrap(); + if ws.webdav_url.is_some() { + found_any = true; + print_workspace_status(&name, &ws.path, ws.webdav_url.as_deref())?; + println!(); + } + } + if !found_any { + output::info("No workspaces have sync configured. Run 'bevy-tasks sync --setup' to set up."); + } + return Ok(()); + } + + let (name, workspace) = if let Some(name) = workspace_name { + let ws = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .clone(); + (name, ws) + } else { + let (n, ws) = config.get_current_workspace() + .context("No workspace set.")?; + (n.clone(), ws.clone()) + }; + + print_workspace_status(&name, &workspace.path, workspace.webdav_url.as_deref())?; + Ok(()) +} + +fn print_workspace_status(name: &str, path: &std::path::Path, webdav_url: Option<&str>) -> Result<()> { + println!("Workspace: {}", name.green()); + + if let Some(url) = webdav_url { + println!(" WebDAV URL: {}", url); + } else { + println!(" WebDAV: {}", "not configured".dimmed()); + return Ok(()); + } + + let info = get_sync_status(path)?; + + if let Some(last) = info.last_sync { + println!(" Last sync: {}", last.format("%Y-%m-%d %H:%M:%S UTC")); + } else { + println!(" Last sync: {}", "never".dimmed()); + } + + println!(" Tracked files: {}", info.tracked_files); + println!(" Pending changes: {}", info.pending_changes); + if info.queued_operations > 0 { + println!(" Queued operations: {}", format!("{}", info.queued_operations).yellow()); + } + + Ok(()) +} + +/// Extract domain from a URL for credential storage. +fn extract_domain(url: &str) -> String { + url.split("://") + .nth(1) + .unwrap_or(url) + .split('/') + .next() + .unwrap_or(url) + .split(':') + .next() + .unwrap_or(url) + .to_string() +} + +/// Prompt the user for text input. +fn prompt(message: &str) -> Result { + use std::io::Write; + print!("{}", message); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} diff --git a/crates/bevy-tasks-cli/src/main.rs b/crates/bevy-tasks-cli/src/main.rs index 5c298c7..6c0268e 100644 --- a/crates/bevy-tasks-cli/src/main.rs +++ b/crates/bevy-tasks-cli/src/main.rs @@ -77,6 +77,28 @@ enum Commands { /// Toggle group-by-due-date for a list #[command(subcommand)] Group(GroupCommands), + + /// Sync workspace with WebDAV server + Sync { + /// Run initial setup (URL, credentials) + #[arg(long)] + setup: bool, + /// Push-only sync (upload local changes) + #[arg(long, conflicts_with_all = ["pull", "setup", "status"])] + push: bool, + /// Pull-only sync (download remote changes) + #[arg(long, conflicts_with_all = ["push", "setup", "status"])] + pull: bool, + /// Show sync status + #[arg(long, conflicts_with_all = ["push", "pull", "setup"])] + status: bool, + /// Show status for all workspaces (with --status) + #[arg(long, requires = "status")] + all: bool, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, } #[derive(Subcommand)] @@ -233,6 +255,22 @@ fn main() -> Result<()> { group::disable(list, workspace)?; } }, + Commands::Sync { setup, push, pull, status, all, workspace } => { + if setup { + sync::setup(workspace)?; + } else if status { + sync::status(workspace, all)?; + } else { + let mode = if push { + bevy_tasks_core::sync::SyncMode::Push + } else if pull { + bevy_tasks_core::sync::SyncMode::Pull + } else { + bevy_tasks_core::sync::SyncMode::Full + }; + sync::execute(mode, workspace)?; + } + }, } Ok(()) diff --git a/crates/bevy-tasks-core/Cargo.toml b/crates/bevy-tasks-core/Cargo.toml index f1dedfe..e9cb8d9 100644 --- a/crates/bevy-tasks-core/Cargo.toml +++ b/crates/bevy-tasks-core/Cargo.toml @@ -10,6 +10,13 @@ serde_yaml = "0.9" uuid = { workspace = true } chrono = { workspace = true } directories = "5.0" +reqwest = { workspace = true } +sha2 = { workspace = true } +quick-xml = { workspace = true } +tokio = { workspace = true } +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } [dev-dependencies] tempfile = "3.0" +wiremock = "0.6" +tokio = { workspace = true } diff --git a/crates/bevy-tasks-core/src/config.rs b/crates/bevy-tasks-core/src/config.rs index 2e042ae..4741204 100644 --- a/crates/bevy-tasks-core/src/config.rs +++ b/crates/bevy-tasks-core/src/config.rs @@ -6,11 +6,15 @@ use crate::error::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceConfig { pub path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub webdav_url: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub last_sync: Option>, } impl WorkspaceConfig { pub fn new(path: PathBuf) -> Self { - Self { path } + Self { path, webdav_url: None, last_sync: None } } } @@ -201,4 +205,43 @@ mod tests { assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new")); assert_eq!(config.workspaces.len(), 1); } + + #[test] + fn test_workspace_config_with_webdav_fields_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + + let mut config = AppConfig::new(); + let mut ws = WorkspaceConfig::new(PathBuf::from("/tasks")); + ws.webdav_url = Some("https://dav.example.com/tasks".to_string()); + ws.last_sync = Some(chrono::Utc::now()); + config.add_workspace("synced".to_string(), ws); + config.save_to_file(&config_path).unwrap(); + + let loaded = AppConfig::load_from_file(&config_path).unwrap(); + let ws = loaded.get_workspace("synced").unwrap(); + assert_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks")); + assert!(ws.last_sync.is_some()); + } + + #[test] + fn test_backwards_compat_loading_old_format() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + + // Write old-format JSON without webdav_url or last_sync fields + let old_json = r#"{ + "workspaces": { + "personal": { "path": "/home/user/tasks" } + }, + "current_workspace": "personal" + }"#; + std::fs::write(&config_path, old_json).unwrap(); + + let loaded = AppConfig::load_from_file(&config_path).unwrap(); + let ws = loaded.get_workspace("personal").unwrap(); + assert_eq!(ws.path, PathBuf::from("/home/user/tasks")); + assert!(ws.webdav_url.is_none()); + assert!(ws.last_sync.is_none()); + } } diff --git a/crates/bevy-tasks-core/src/error.rs b/crates/bevy-tasks-core/src/error.rs index d80de41..b094fdf 100644 --- a/crates/bevy-tasks-core/src/error.rs +++ b/crates/bevy-tasks-core/src/error.rs @@ -10,6 +10,9 @@ pub enum Error { WorkspaceNotFound(String), ListNotFound(String), TaskNotFound(String), + WebDav(String), + Sync(String), + Credential(String), } impl fmt::Display for Error { @@ -22,6 +25,9 @@ impl fmt::Display for Error { Error::WorkspaceNotFound(name) => write!(f, "Workspace not found: {}", name), Error::ListNotFound(id) => write!(f, "List not found: {}", id), Error::TaskNotFound(id) => write!(f, "Task not found: {}", id), + Error::WebDav(msg) => write!(f, "WebDAV error: {}", msg), + Error::Sync(msg) => write!(f, "Sync error: {}", msg), + Error::Credential(msg) => write!(f, "Credential error: {}", msg), } } } @@ -46,4 +52,10 @@ impl From for Error { } } +impl From for Error { + fn from(err: reqwest::Error) -> Self { + Error::WebDav(err.to_string()) + } +} + pub type Result = std::result::Result; diff --git a/crates/bevy-tasks-core/src/lib.rs b/crates/bevy-tasks-core/src/lib.rs index ff02f00..f78de8f 100644 --- a/crates/bevy-tasks-core/src/lib.rs +++ b/crates/bevy-tasks-core/src/lib.rs @@ -3,6 +3,8 @@ pub mod storage; pub mod repository; pub mod config; pub mod error; +pub mod webdav; +pub mod sync; pub use models::{Task, TaskStatus, TaskList}; pub use repository::TaskRepository; diff --git a/crates/bevy-tasks-core/src/sync.rs b/crates/bevy-tasks-core/src/sync.rs new file mode 100644 index 0000000..085d58a --- /dev/null +++ b/crates/bevy-tasks-core/src/sync.rs @@ -0,0 +1,1107 @@ +use std::collections::HashMap; +use std::path::Path; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Sha256, Digest}; +use crate::error::{Error, Result}; +use crate::webdav::WebDavClient; + +// --- Sync State --- + +/// Persisted sync state for a workspace, stored as `.syncstate.json`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SyncState { + pub last_sync: Option>, + pub files: HashMap, +} + +/// Entry tracking the last-synced state of a single file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncFileEntry { + pub checksum: String, + pub modified_at: Option, + pub size: u64, +} + +// --- Sync Actions --- + +/// An action to take during sync, computed from the three-way diff. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyncAction { + Upload { path: String }, + Download { path: String }, + DeleteLocal { path: String }, + DeleteRemote { path: String }, + ConflictLocalWins { path: String }, + ConflictRemoteWins { path: String }, +} + +impl SyncAction { + pub fn path(&self) -> &str { + match self { + SyncAction::Upload { path } + | SyncAction::Download { path } + | SyncAction::DeleteLocal { path } + | SyncAction::DeleteRemote { path } + | SyncAction::ConflictLocalWins { path } + | SyncAction::ConflictRemoteWins { path } => path, + } + } +} + +/// Result summary of a sync operation. +#[derive(Debug, Default)] +pub struct SyncResult { + pub uploaded: u32, + pub downloaded: u32, + pub deleted_local: u32, + pub deleted_remote: u32, + pub conflicts: u32, + pub errors: Vec, +} + +/// Sync direction mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncMode { + Push, + Pull, + Full, +} + +// --- Local / Remote file info for diffing --- + +/// Snapshot of a local file's state. +#[derive(Debug, Clone)] +pub struct LocalFileInfo { + pub path: String, + pub checksum: String, + pub modified_at: Option, + pub size: u64, +} + +/// Snapshot of a remote file's state (from PROPFIND). +#[derive(Debug, Clone)] +pub struct RemoteFileSnapshot { + pub path: String, + pub last_modified: Option, + pub size: u64, +} + +// --- Three-way diff --- + +/// Compute sync actions by comparing local files, remote files, and the last-synced base state. +/// +/// Three-way diff logic: +/// | Local vs Base | Remote vs Base | Action | +/// |---------------|----------------|---------------------------------------------| +/// | unchanged | unchanged | skip | +/// | added | absent | upload | +/// | absent | added | download | +/// | modified | unchanged | upload | +/// | unchanged | modified | download | +/// | deleted | unchanged | delete remote | +/// | unchanged | deleted | delete local | +/// | modified | modified | last-write-wins (compare timestamps) | +/// | deleted | modified | download (remote wins) | +/// | modified | deleted | upload (local wins) | +/// | added | added | last-write-wins | +pub fn compute_sync_actions( + local_files: &[LocalFileInfo], + remote_files: &[RemoteFileSnapshot], + sync_state: &SyncState, +) -> Vec { + let local_map: HashMap<&str, &LocalFileInfo> = local_files.iter().map(|f| (f.path.as_str(), f)).collect(); + let remote_map: HashMap<&str, &RemoteFileSnapshot> = remote_files.iter().map(|f| (f.path.as_str(), f)).collect(); + + let mut all_paths: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for f in local_files { all_paths.insert(&f.path); } + for f in remote_files { all_paths.insert(&f.path); } + for p in sync_state.files.keys() { all_paths.insert(p); } + + let mut actions = Vec::new(); + + for path in all_paths { + let local = local_map.get(path); + let remote = remote_map.get(path); + let base = sync_state.files.get(path); + + match (local, remote, base) { + // Both present, base known: check for changes + (Some(l), Some(r), Some(b)) => { + let local_changed = l.checksum != b.checksum; + let remote_changed = r.size != b.size || r.last_modified.as_deref() != b.modified_at.as_deref(); + + match (local_changed, remote_changed) { + (false, false) => {} // Skip, unchanged + (true, false) => actions.push(SyncAction::Upload { path: path.to_string() }), + (false, true) => actions.push(SyncAction::Download { path: path.to_string() }), + (true, true) => { + // Both modified: last-write-wins based on timestamps + if local_wins(l.modified_at.as_deref(), r.last_modified.as_deref()) { + actions.push(SyncAction::ConflictLocalWins { path: path.to_string() }); + } else { + actions.push(SyncAction::ConflictRemoteWins { path: path.to_string() }); + } + } + } + } + + // Local only, no base: added locally + (Some(_), None, None) => { + actions.push(SyncAction::Upload { path: path.to_string() }); + } + + // Remote only, no base: added remotely + (None, Some(_), None) => { + actions.push(SyncAction::Download { path: path.to_string() }); + } + + // Both present, no base (both added): last-write-wins + (Some(l), Some(r), None) => { + if local_wins(l.modified_at.as_deref(), r.last_modified.as_deref()) { + actions.push(SyncAction::ConflictLocalWins { path: path.to_string() }); + } else { + actions.push(SyncAction::ConflictRemoteWins { path: path.to_string() }); + } + } + + // Local present, remote gone, base known: remote was deleted + (Some(_), None, Some(_)) => { + // modified locally + deleted remote -> upload (local wins) + actions.push(SyncAction::Upload { path: path.to_string() }); + } + + // Remote present, local gone, base known: local was deleted + (None, Some(_), Some(b)) => { + let remote_changed = remote.map_or(false, |r| r.size != b.size || r.last_modified.as_deref() != b.modified_at.as_deref()); + if remote_changed { + // deleted locally + modified remotely -> download (remote wins) + actions.push(SyncAction::Download { path: path.to_string() }); + } else { + // deleted locally, remote unchanged -> delete remote + actions.push(SyncAction::DeleteRemote { path: path.to_string() }); + } + } + + // Both gone, base known: both deleted, skip (clean up base) + (None, None, Some(_)) => {} + + // Local gone, remote gone, no base: nothing to do + (None, None, None) => {} + + } + } + + // Sort actions for deterministic output + actions.sort_by(|a, b| a.path().cmp(b.path())); + actions +} + +/// Determine if local wins based on timestamps. True means local wins. +fn local_wins(local_modified: Option<&str>, remote_modified: Option<&str>) -> bool { + // Try parsing both; if we can't parse, local wins by default + let local_ts = local_modified.and_then(parse_timestamp); + let remote_ts = remote_modified.and_then(parse_timestamp); + match (local_ts, remote_ts) { + (Some(l), Some(r)) => l >= r, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => true, // Default to local + } +} + +/// Parse a timestamp string (ISO 8601 or HTTP date format). +fn parse_timestamp(s: &str) -> Option> { + // Try ISO 8601 / RFC 3339 + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Utc)); + } + // Try RFC 2822 + if let Ok(dt) = DateTime::parse_from_rfc2822(s) { + return Some(dt.with_timezone(&Utc)); + } + // Try HTTP date format: "Mon, 01 Jan 2026 00:00:00 GMT" + // Strip the day-of-week prefix and GMT suffix, parse the core date + if s.ends_with("GMT") { + let trimmed = s.trim_end_matches("GMT").trim(); + // After stripping "Mon, " prefix: "01 Jan 2026 00:00:00" + if let Some(comma_pos) = trimmed.find(", ") { + let date_part = &trimmed[comma_pos + 2..]; + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_part, "%d %b %Y %H:%M:%S") { + return Some(dt.and_utc()); + } + } + } + None +} + +// --- Offline Queue --- + +/// Persisted offline operation queue, stored as `.syncqueue.json`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OfflineQueue { + pub operations: Vec, +} + +/// A queued sync operation that failed to execute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueuedOperation { + pub action_type: String, + pub path: String, + pub queued_at: DateTime, +} + +impl OfflineQueue { + pub fn load(workspace_path: &Path) -> Self { + let queue_path = workspace_path.join(".syncqueue.json"); + if !queue_path.exists() { + return Self::default(); + } + match std::fs::read_to_string(&queue_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn save(&self, workspace_path: &Path) -> Result<()> { + let queue_path = workspace_path.join(".syncqueue.json"); + if self.operations.is_empty() { + // Clean up empty queue file + if queue_path.exists() { + let _ = std::fs::remove_file(&queue_path); + } + return Ok(()); + } + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&queue_path, content)?; + Ok(()) + } + + /// Merge queued operations with fresh actions, deduplicating by path. + /// Fresh actions take precedence over stale queued ones. + pub fn merge_with_actions(&self, fresh_actions: Vec) -> Vec { + let mut result_map: HashMap = HashMap::new(); + + // Add queued operations first (lower priority) + for op in &self.operations { + if let Some(action) = queued_op_to_action(op) { + result_map.insert(op.path.clone(), action); + } + } + + // Fresh actions override queued ones + for action in fresh_actions { + result_map.insert(action.path().to_string(), action); + } + + let mut actions: Vec = result_map.into_values().collect(); + actions.sort_by(|a, b| a.path().cmp(b.path())); + actions + } +} + +fn queued_op_to_action(op: &QueuedOperation) -> Option { + let path = op.path.clone(); + match op.action_type.as_str() { + "upload" => Some(SyncAction::Upload { path }), + "download" => Some(SyncAction::Download { path }), + "delete_local" => Some(SyncAction::DeleteLocal { path }), + "delete_remote" => Some(SyncAction::DeleteRemote { path }), + "conflict_local_wins" => Some(SyncAction::ConflictLocalWins { path }), + "conflict_remote_wins" => Some(SyncAction::ConflictRemoteWins { path }), + _ => None, + } +} + +fn action_to_queued_op(action: &SyncAction) -> QueuedOperation { + let (action_type, path) = match action { + SyncAction::Upload { path } => ("upload", path), + SyncAction::Download { path } => ("download", path), + SyncAction::DeleteLocal { path } => ("delete_local", path), + SyncAction::DeleteRemote { path } => ("delete_remote", path), + SyncAction::ConflictLocalWins { path } => ("conflict_local_wins", path), + SyncAction::ConflictRemoteWins { path } => ("conflict_remote_wins", path), + }; + QueuedOperation { + action_type: action_type.to_string(), + path: path.clone(), + queued_at: Utc::now(), + } +} + +// --- File Scanning --- + +/// Compute SHA-256 checksum of file contents. +pub fn compute_checksum(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +/// Check if a filename is a syncable file (*.md, .listdata.json, .metadata.json). +fn is_syncable(path: &str) -> bool { + let filename = path.rsplit('/').next().unwrap_or(path); + filename.ends_with(".md") + || filename == ".listdata.json" + || filename == ".metadata.json" +} + +/// Scan local workspace files and compute checksums. +pub fn scan_local_files(workspace_path: &Path) -> Result> { + let mut files = Vec::new(); + scan_dir_recursive(workspace_path, workspace_path, &mut files)?; + Ok(files) +} + +fn scan_dir_recursive(root: &Path, dir: &Path, files: &mut Vec) -> Result<()> { + let entries = std::fs::read_dir(dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let relative = path.strip_prefix(root) + .map_err(|e| Error::Sync(e.to_string()))? + .to_string_lossy() + .replace('\\', "/"); + + // Skip sync state/queue files + if relative == ".syncstate.json" || relative == ".syncqueue.json" { + continue; + } + + if path.is_dir() { + scan_dir_recursive(root, &path, files)?; + } else if is_syncable(&relative) { + let data = std::fs::read(&path)?; + let metadata = std::fs::metadata(&path)?; + let modified = metadata.modified().ok() + .and_then(|t| { + let dt: DateTime = t.into(); + Some(dt.to_rfc3339()) + }); + + files.push(LocalFileInfo { + path: relative, + checksum: compute_checksum(&data), + modified_at: modified, + size: data.len() as u64, + }); + } + } + Ok(()) +} + +/// Convert PROPFIND results into RemoteFileSnapshot list, recursing into directories. +fn scan_remote_files<'a>(client: &'a WebDavClient, base_path: &'a str) -> std::pin::Pin>> + Send + 'a>> { + let base_path = base_path.to_string(); + Box::pin(async move { + let mut result = Vec::new(); + let entries = client.list_files(&base_path).await?; + + for entry in entries { + let full_path = if base_path.is_empty() { + entry.path.clone() + } else { + format!("{}/{}", base_path.trim_end_matches('/'), entry.path) + }; + + if entry.is_dir { + let sub_entries = scan_remote_files(client, &full_path).await?; + result.extend(sub_entries); + } else if is_syncable(&full_path) { + result.push(RemoteFileSnapshot { + path: full_path, + last_modified: entry.last_modified, + size: entry.content_length, + }); + } + } + + Ok(result) + }) +} + +// --- Sync State I/O --- + +impl SyncState { + pub fn load(workspace_path: &Path) -> Self { + let state_path = workspace_path.join(".syncstate.json"); + if !state_path.exists() { + return Self::default(); + } + match std::fs::read_to_string(&state_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn save(&self, workspace_path: &Path) -> Result<()> { + let state_path = workspace_path.join(".syncstate.json"); + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&state_path, content)?; + Ok(()) + } + + /// Update the sync state for a single file after a successful sync action. + pub fn record_file(&mut self, path: &str, checksum: &str, modified_at: Option<&str>, size: u64) { + self.files.insert(path.to_string(), SyncFileEntry { + checksum: checksum.to_string(), + modified_at: modified_at.map(|s| s.to_string()), + size, + }); + } + + /// Remove a file entry from sync state (after deletion). + pub fn remove_file(&mut self, path: &str) { + self.files.remove(path); + } +} + +// --- Sync Executor --- + +/// Callback type for sync progress reporting. +pub type ProgressCallback = Box; + +/// Execute a full sync between a local workspace and a remote WebDAV server. +pub async fn sync_workspace( + workspace_path: &Path, + webdav_url: &str, + username: &str, + password: &str, + mode: SyncMode, + on_progress: Option, +) -> Result { + let client = WebDavClient::new(webdav_url, username, password); + let mut sync_state = SyncState::load(workspace_path); + let queue = OfflineQueue::load(workspace_path); + let mut result = SyncResult::default(); + + let report = |msg: &str| { + if let Some(ref cb) = on_progress { + cb(msg); + } + }; + + // Ensure remote root exists + client.test_connection().await?; + + // Scan local files + let local_files = scan_local_files(workspace_path)?; + + // Scan remote files + let remote_files = match scan_remote_files(&client, "").await { + Ok(files) => files, + Err(e) => { + // Network error during scan: save what we can and return + result.errors.push(format!("Failed to scan remote: {}", e)); + return Ok(result); + } + }; + + // Compute actions from three-way diff + let fresh_actions = compute_sync_actions(&local_files, &remote_files, &sync_state); + + // Merge with offline queue + let all_actions = queue.merge_with_actions(fresh_actions); + + // Filter by sync mode + let actions: Vec = all_actions.into_iter().filter(|a| match mode { + SyncMode::Full => true, + SyncMode::Push => matches!(a, SyncAction::Upload { .. } | SyncAction::DeleteRemote { .. } | SyncAction::ConflictLocalWins { .. }), + SyncMode::Pull => matches!(a, SyncAction::Download { .. } | SyncAction::DeleteLocal { .. } | SyncAction::ConflictRemoteWins { .. }), + }).collect(); + + // Execute actions, collecting failures for the queue + let mut failed_actions = Vec::new(); + + for action in &actions { + match execute_action(&client, workspace_path, action, &mut sync_state, &report).await { + Ok(()) => { + match action { + SyncAction::Upload { .. } | SyncAction::ConflictLocalWins { .. } => result.uploaded += 1, + SyncAction::Download { .. } | SyncAction::ConflictRemoteWins { .. } => result.downloaded += 1, + SyncAction::DeleteLocal { .. } => result.deleted_local += 1, + SyncAction::DeleteRemote { .. } => result.deleted_remote += 1, + } + } + Err(e) => { + let msg = format!("Failed {}: {}", action.path(), e); + report(&format!(" ! {}", msg)); + result.errors.push(msg); + if matches!(action, + SyncAction::Upload { .. } | SyncAction::Download { .. } + | SyncAction::ConflictLocalWins { .. } | SyncAction::ConflictRemoteWins { .. } + ) { + result.conflicts += 1; + } + failed_actions.push(action.clone()); + } + } + } + + // Save queue with remaining failed actions + let new_queue = OfflineQueue { + operations: failed_actions.iter().map(|a| action_to_queued_op(a)).collect(), + }; + new_queue.save(workspace_path)?; + + // Update sync state timestamp + sync_state.last_sync = Some(Utc::now()); + sync_state.save(workspace_path)?; + + Ok(result) +} + +/// Execute a single sync action. +async fn execute_action( + client: &WebDavClient, + workspace_path: &Path, + action: &SyncAction, + sync_state: &mut SyncState, + report: &dyn Fn(&str), +) -> Result<()> { + match action { + SyncAction::Upload { path } | SyncAction::ConflictLocalWins { path } => { + let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)); + let data = std::fs::read(&local_path)?; + let checksum = compute_checksum(&data); + + // Ensure remote parent directory exists + if let Some(parent) = path_parent(path) { + client.ensure_dir(parent).await?; + } + + report(&format!(" ^ Uploading {}", path)); + client.put_file(path, data.clone()).await?; + + // Record in sync state using local file metadata + let modified = std::fs::metadata(&local_path).ok() + .and_then(|m| m.modified().ok()) + .map(|t| { let dt: DateTime = t.into(); dt.to_rfc3339() }); + sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64); + } + + SyncAction::Download { path } | SyncAction::ConflictRemoteWins { path } => { + report(&format!(" v Downloading {}", path)); + let data = client.get_file(path).await?; + let checksum = compute_checksum(&data); + + let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if let Some(parent) = local_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&local_path, &data)?; + + // Record in sync state + let modified = std::fs::metadata(&local_path).ok() + .and_then(|m| m.modified().ok()) + .map(|t| { let dt: DateTime = t.into(); dt.to_rfc3339() }); + sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64); + } + + SyncAction::DeleteLocal { path } => { + report(&format!(" x Deleting local {}", path)); + let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if local_path.exists() { + std::fs::remove_file(&local_path)?; + } + sync_state.remove_file(path); + } + + SyncAction::DeleteRemote { path } => { + report(&format!(" x Deleting remote {}", path)); + client.delete_file(path).await?; + sync_state.remove_file(path); + } + } + Ok(()) +} + +/// Get the parent path of a sync path (e.g., "My Tasks/file.md" -> "My Tasks"). +fn path_parent(path: &str) -> Option<&str> { + path.rfind('/').map(|i| &path[..i]) +} + +/// Get sync status information for display. +pub fn get_sync_status(workspace_path: &Path) -> Result { + let sync_state = SyncState::load(workspace_path); + let queue = OfflineQueue::load(workspace_path); + let local_files = scan_local_files(workspace_path)?; + + // Count pending changes (files changed since last sync) + let mut pending_changes = 0u32; + for file in &local_files { + if let Some(base) = sync_state.files.get(&file.path) { + if file.checksum != base.checksum { + pending_changes += 1; + } + } else { + pending_changes += 1; // New file + } + } + + // Count files in base that are now missing locally (deleted) + for path in sync_state.files.keys() { + if !local_files.iter().any(|f| f.path == *path) { + pending_changes += 1; + } + } + + Ok(SyncStatusInfo { + last_sync: sync_state.last_sync, + tracked_files: sync_state.files.len() as u32, + pending_changes, + queued_operations: queue.operations.len() as u32, + }) +} + +/// Summary of sync status for display. +pub struct SyncStatusInfo { + pub last_sync: Option>, + pub tracked_files: u32, + pub pending_changes: u32, + pub queued_operations: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // --- compute_sync_actions tests --- + + fn make_local(path: &str, checksum: &str) -> LocalFileInfo { + LocalFileInfo { + path: path.to_string(), + checksum: checksum.to_string(), + modified_at: Some("2026-01-15T12:00:00+00:00".to_string()), + size: 100, + } + } + + fn make_remote(path: &str) -> RemoteFileSnapshot { + RemoteFileSnapshot { + path: path.to_string(), + last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string()), + size: 100, + } + } + + fn make_base(checksum: &str) -> SyncFileEntry { + SyncFileEntry { + checksum: checksum.to_string(), + modified_at: Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string()), + size: 100, + } + } + + #[test] + fn test_unchanged_both_sides() { + let local = vec![make_local("file.md", "abc123")]; + let remote = vec![make_remote("file.md")]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("abc123")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert!(actions.is_empty()); + } + + #[test] + fn test_local_added_remote_absent() { + let local = vec![make_local("new.md", "abc123")]; + let remote = vec![]; + let state = SyncState::default(); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::Upload { path: "new.md".to_string() }); + } + + #[test] + fn test_remote_added_local_absent() { + let local = vec![]; + let remote = vec![make_remote("new.md")]; + let state = SyncState::default(); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::Download { path: "new.md".to_string() }); + } + + #[test] + fn test_local_modified_remote_unchanged() { + let local = vec![make_local("file.md", "new_checksum")]; + let remote = vec![make_remote("file.md")]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("old_checksum")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() }); + } + + #[test] + fn test_remote_modified_local_unchanged() { + let local = vec![make_local("file.md", "same_checksum")]; + let mut remote = make_remote("file.md"); + remote.size = 200; // Changed size indicates modification + let remote = vec![remote]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("same_checksum")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::Download { path: "file.md".to_string() }); + } + + #[test] + fn test_local_deleted_remote_unchanged() { + let local = vec![]; + let remote = vec![make_remote("file.md")]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("abc123")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::DeleteRemote { path: "file.md".to_string() }); + } + + #[test] + fn test_remote_deleted_local_unchanged() { + let local = vec![make_local("file.md", "abc123")]; + let remote = vec![]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("abc123")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + // Local present, remote gone, base known -> upload (local wins) + assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() }); + } + + #[test] + fn test_both_modified_local_newer() { + let mut local = make_local("file.md", "new_local"); + local.modified_at = Some("2026-03-15T12:00:00+00:00".to_string()); + let mut remote = make_remote("file.md"); + remote.last_modified = Some("Mon, 01 Mar 2026 00:00:00 GMT".to_string()); + remote.size = 200; + + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("old_base")); + + let actions = compute_sync_actions(&[local], &[remote], &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::ConflictLocalWins { path: "file.md".to_string() }); + } + + #[test] + fn test_both_modified_remote_newer() { + let mut local = make_local("file.md", "new_local"); + local.modified_at = Some("2026-01-01T00:00:00+00:00".to_string()); + let mut remote = make_remote("file.md"); + remote.last_modified = Some("Sun, 15 Mar 2026 12:00:00 GMT".to_string()); + remote.size = 200; + + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("old_base")); + + let actions = compute_sync_actions(&[local], &[remote], &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::ConflictRemoteWins { path: "file.md".to_string() }); + } + + #[test] + fn test_deleted_local_modified_remote() { + let local = vec![]; + let mut remote = make_remote("file.md"); + remote.size = 200; // Modified + let remote = vec![remote]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("abc123")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::Download { path: "file.md".to_string() }); + } + + #[test] + fn test_modified_local_deleted_remote() { + let local = vec![make_local("file.md", "new_checksum")]; + let remote = vec![]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("old_checksum")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() }); + } + + #[test] + fn test_both_added_local_newer() { + let mut local = make_local("file.md", "local_content"); + local.modified_at = Some("2026-03-15T12:00:00+00:00".to_string()); + let mut remote = make_remote("file.md"); + remote.last_modified = Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string()); + + let state = SyncState::default(); // No base entry + + let actions = compute_sync_actions(&[local], &[remote], &state); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], SyncAction::ConflictLocalWins { path: "file.md".to_string() }); + } + + #[test] + fn test_both_deleted() { + let local = vec![]; + let remote = vec![]; + let mut state = SyncState::default(); + state.files.insert("file.md".to_string(), make_base("abc123")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert!(actions.is_empty()); + } + + #[test] + fn test_multiple_files_mixed() { + let local = vec![ + make_local("keep.md", "same"), + make_local("modified.md", "new"), + make_local("new_local.md", "brand_new"), + ]; + let remote = vec![ + make_remote("keep.md"), + make_remote("modified.md"), + make_remote("new_remote.md"), + ]; + let mut state = SyncState::default(); + state.files.insert("keep.md".to_string(), make_base("same")); + state.files.insert("modified.md".to_string(), make_base("old")); + + let actions = compute_sync_actions(&local, &remote, &state); + assert_eq!(actions.len(), 3); + // modified.md: local modified, remote unchanged -> upload + assert!(actions.iter().any(|a| matches!(a, SyncAction::Upload { path } if path == "modified.md"))); + // new_local.md: added locally -> upload + assert!(actions.iter().any(|a| matches!(a, SyncAction::Upload { path } if path == "new_local.md"))); + // new_remote.md: added remotely -> download + assert!(actions.iter().any(|a| matches!(a, SyncAction::Download { path } if path == "new_remote.md"))); + } + + // --- Sync state persistence --- + + #[test] + fn test_sync_state_save_load_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let mut state = SyncState::default(); + state.last_sync = Some(Utc::now()); + state.record_file("test.md", "abc123", Some("2026-01-01T00:00:00Z"), 42); + + state.save(temp_dir.path()).unwrap(); + let loaded = SyncState::load(temp_dir.path()); + + assert!(loaded.last_sync.is_some()); + assert_eq!(loaded.files.len(), 1); + assert_eq!(loaded.files["test.md"].checksum, "abc123"); + assert_eq!(loaded.files["test.md"].size, 42); + } + + #[test] + fn test_sync_state_load_missing() { + let temp_dir = TempDir::new().unwrap(); + let state = SyncState::load(temp_dir.path()); + assert!(state.last_sync.is_none()); + assert!(state.files.is_empty()); + } + + // --- Offline queue --- + + #[test] + fn test_queue_save_load_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let queue = OfflineQueue { + operations: vec![QueuedOperation { + action_type: "upload".to_string(), + path: "test.md".to_string(), + queued_at: Utc::now(), + }], + }; + + queue.save(temp_dir.path()).unwrap(); + let loaded = OfflineQueue::load(temp_dir.path()); + assert_eq!(loaded.operations.len(), 1); + assert_eq!(loaded.operations[0].path, "test.md"); + } + + #[test] + fn test_queue_empty_cleans_up_file() { + let temp_dir = TempDir::new().unwrap(); + let queue_path = temp_dir.path().join(".syncqueue.json"); + + // Write a non-empty queue first + let queue = OfflineQueue { + operations: vec![QueuedOperation { + action_type: "upload".to_string(), + path: "test.md".to_string(), + queued_at: Utc::now(), + }], + }; + queue.save(temp_dir.path()).unwrap(); + assert!(queue_path.exists()); + + // Save empty queue should remove the file + let empty_queue = OfflineQueue::default(); + empty_queue.save(temp_dir.path()).unwrap(); + assert!(!queue_path.exists()); + } + + #[test] + fn test_queue_merge_fresh_overrides_stale() { + let queue = OfflineQueue { + operations: vec![QueuedOperation { + action_type: "upload".to_string(), + path: "file.md".to_string(), + queued_at: Utc::now(), + }], + }; + + let fresh = vec![SyncAction::Download { path: "file.md".to_string() }]; + let merged = queue.merge_with_actions(fresh); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0], SyncAction::Download { path: "file.md".to_string() }); + } + + #[test] + fn test_queue_merge_combines_different_paths() { + let queue = OfflineQueue { + operations: vec![QueuedOperation { + action_type: "upload".to_string(), + path: "a.md".to_string(), + queued_at: Utc::now(), + }], + }; + + let fresh = vec![SyncAction::Download { path: "b.md".to_string() }]; + let merged = queue.merge_with_actions(fresh); + + assert_eq!(merged.len(), 2); + } + + // --- Checksum --- + + #[test] + fn test_compute_checksum_deterministic() { + let data = b"hello world"; + let c1 = compute_checksum(data); + let c2 = compute_checksum(data); + assert_eq!(c1, c2); + assert!(!c1.is_empty()); + } + + #[test] + fn test_compute_checksum_different_data() { + let c1 = compute_checksum(b"hello"); + let c2 = compute_checksum(b"world"); + assert_ne!(c1, c2); + } + + // --- File scanning --- + + #[test] + fn test_is_syncable() { + assert!(is_syncable("file.md")); + assert!(is_syncable("My Tasks/Buy groceries.md")); + assert!(is_syncable(".listdata.json")); + assert!(is_syncable("My Tasks/.listdata.json")); + assert!(is_syncable(".metadata.json")); + assert!(!is_syncable(".syncstate.json")); + assert!(!is_syncable("random.txt")); + assert!(!is_syncable("image.png")); + } + + #[test] + fn test_scan_local_files() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Create a workspace-like structure + std::fs::write(root.join(".metadata.json"), "{}").unwrap(); + std::fs::create_dir_all(root.join("My Tasks")).unwrap(); + std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap(); + std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap(); + std::fs::write(root.join("My Tasks").join("task2.md"), "# Task 2").unwrap(); + // Non-syncable file should be skipped + std::fs::write(root.join("My Tasks").join("notes.txt"), "notes").unwrap(); + // Sync state file should be skipped + std::fs::write(root.join(".syncstate.json"), "{}").unwrap(); + + let files = scan_local_files(root).unwrap(); + assert_eq!(files.len(), 4); // .metadata.json, .listdata.json, task1.md, task2.md + assert!(files.iter().any(|f| f.path == ".metadata.json")); + assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json")); + assert!(files.iter().any(|f| f.path == "My Tasks/task1.md")); + assert!(files.iter().any(|f| f.path == "My Tasks/task2.md")); + assert!(!files.iter().any(|f| f.path.contains("notes.txt"))); + assert!(!files.iter().any(|f| f.path.contains(".syncstate.json"))); + } + + // --- Sync status --- + + #[test] + fn test_get_sync_status_no_state() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + std::fs::write(root.join(".metadata.json"), "{}").unwrap(); + + let status = get_sync_status(root).unwrap(); + assert!(status.last_sync.is_none()); + assert_eq!(status.tracked_files, 0); + assert_eq!(status.pending_changes, 1); // .metadata.json is new + assert_eq!(status.queued_operations, 0); + } + + // --- Timestamp parsing --- + + #[test] + fn test_parse_timestamp_rfc3339() { + let result = parse_timestamp("2026-01-15T12:00:00+00:00"); + assert!(result.is_some()); + } + + #[test] + fn test_parse_timestamp_http_date() { + let result = parse_timestamp("Mon, 01 Jan 2026 00:00:00 GMT"); + assert!(result.is_some()); + } + + #[test] + fn test_parse_timestamp_invalid() { + let result = parse_timestamp("not a date"); + assert!(result.is_none()); + } + + #[test] + fn test_local_wins_local_newer() { + assert!(local_wins( + Some("2026-03-15T12:00:00+00:00"), + Some("Mon, 01 Jan 2026 00:00:00 GMT"), + )); + } + + #[test] + fn test_local_wins_remote_newer() { + assert!(!local_wins( + Some("2026-01-01T00:00:00+00:00"), + Some("Sun, 15 Mar 2026 12:00:00 GMT"), + )); + } + + // --- path_parent --- + + #[test] + fn test_path_parent() { + assert_eq!(path_parent("My Tasks/file.md"), Some("My Tasks")); + assert_eq!(path_parent("file.md"), None); + assert_eq!(path_parent("a/b/c.md"), Some("a/b")); + } +} diff --git a/crates/bevy-tasks-core/src/webdav.rs b/crates/bevy-tasks-core/src/webdav.rs new file mode 100644 index 0000000..19a7b6e --- /dev/null +++ b/crates/bevy-tasks-core/src/webdav.rs @@ -0,0 +1,640 @@ +use reqwest::Client; +use crate::error::{Error, Result}; + +/// Information about a file on the remote WebDAV server. +#[derive(Debug, Clone)] +pub struct RemoteFileInfo { + pub path: String, + pub is_dir: bool, + pub content_length: u64, + pub last_modified: Option, +} + +/// WebDAV client wrapping reqwest with basic auth. +pub struct WebDavClient { + _client: Client, + _base_url: String, + _username: String, + _password: String, +} + +impl WebDavClient { + pub fn new(base_url: &str, username: &str, password: &str) -> Self { + let base_url = base_url.trim_end_matches('/').to_string(); + Self { + _client: Client::new(), + _base_url: base_url, + _username: username.to_string(), + _password: password.to_string(), + } + } + + fn full_url(&self, path: &str) -> String { + let path = path.trim_start_matches('/'); + if path.is_empty() { + self._base_url.clone() + } else { + // Percent-encode path segments while preserving '/' + let encoded: String = path + .split('/') + .map(|seg| percent_encode(seg)) + .collect::>() + .join("/"); + format!("{}/{}", self._base_url, encoded) + } + } + + /// Test connection by issuing a PROPFIND depth 0 on the root. + pub async fn test_connection(&self) -> Result<()> { + let resp = self._client + .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url) + .basic_auth(&self._username, Some(&self._password)) + .header("Depth", "0") + .header("Content-Type", "application/xml") + .body(PROPFIND_BODY) + .send() + .await?; + + let status = resp.status().as_u16(); + if status == 207 || status == 200 { + Ok(()) + } else if status == 401 || status == 403 { + Err(Error::Credential("Authentication failed".to_string())) + } else { + Err(Error::WebDav(format!("Unexpected status {}", status))) + } + } + + /// List files at a given path using PROPFIND depth 1. + pub async fn list_files(&self, path: &str) -> Result> { + let url = self.full_url(path); + let resp = self._client + .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) + .basic_auth(&self._username, Some(&self._password)) + .header("Depth", "1") + .header("Content-Type", "application/xml") + .body(PROPFIND_BODY) + .send() + .await?; + + let status = resp.status().as_u16(); + if status != 207 { + return Err(Error::WebDav(format!("PROPFIND failed with status {}", status))); + } + + let body = resp.text().await?; + parse_propfind_response(&body, &self._base_url, path) + } + + /// Download a file's contents. + pub async fn get_file(&self, path: &str) -> Result> { + let url = self.full_url(path); + let resp = self._client + .get(&url) + .basic_auth(&self._username, Some(&self._password)) + .send() + .await?; + + let status = resp.status().as_u16(); + if status == 404 { + return Err(Error::NotFound(format!("Remote file not found: {}", path))); + } + if status != 200 { + return Err(Error::WebDav(format!("GET failed with status {}", status))); + } + + Ok(resp.bytes().await?.to_vec()) + } + + /// Upload a file. + pub async fn put_file(&self, path: &str, content: Vec) -> Result<()> { + let url = self.full_url(path); + let resp = self._client + .put(&url) + .basic_auth(&self._username, Some(&self._password)) + .body(content) + .send() + .await?; + + let status = resp.status().as_u16(); + if !(200..=299).contains(&status) { + return Err(Error::WebDav(format!("PUT failed with status {}", status))); + } + Ok(()) + } + + /// Delete a remote file. + pub async fn delete_file(&self, path: &str) -> Result<()> { + let url = self.full_url(path); + let resp = self._client + .delete(&url) + .basic_auth(&self._username, Some(&self._password)) + .send() + .await?; + + let status = resp.status().as_u16(); + if status == 404 { + return Ok(()); // Already gone + } + if !(200..=299).contains(&status) { + return Err(Error::WebDav(format!("DELETE failed with status {}", status))); + } + Ok(()) + } + + /// Create a directory via MKCOL. + pub async fn create_dir(&self, path: &str) -> Result<()> { + let url = self.full_url(path); + let resp = self._client + .request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url) + .basic_auth(&self._username, Some(&self._password)) + .send() + .await?; + + let status = resp.status().as_u16(); + if status == 405 { + return Ok(()); // Already exists + } + if !(200..=299).contains(&status) { + return Err(Error::WebDav(format!("MKCOL failed with status {}", status))); + } + Ok(()) + } + + /// Ensure a directory exists, creating it and parents as needed. + pub async fn ensure_dir(&self, path: &str) -> Result<()> { + let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect(); + let mut current = String::new(); + for part in parts { + current = if current.is_empty() { + part.to_string() + } else { + format!("{}/{}", current, part) + }; + self.create_dir(¤t).await?; + } + Ok(()) + } +} + +const PROPFIND_BODY: &str = r#" + + + + + + +"#; + +/// Percent-encode a single path segment (not the whole path). +fn percent_encode(segment: &str) -> String { + let mut result = String::new(); + for byte in segment.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push_str(&format!("%{:02X}", byte)); + } + } + } + result +} + +/// Percent-decode a string. +fn percent_decode(s: &str) -> String { + let mut result = Vec::new(); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(val) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + result.push(val); + i += 3; + continue; + } + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).to_string() +} + +/// Parse a PROPFIND multistatus XML response into RemoteFileInfo entries. +/// Handles namespace prefix variations (d:, D:, no prefix). +fn parse_propfind_response(xml: &str, base_url: &str, request_path: &str) -> Result> { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut results = Vec::new(); + + // State machine for parsing + let mut in_response = false; + let mut in_propstat = false; + let mut in_prop = false; + let mut current_href: Option = None; + let mut current_is_dir = false; + let mut current_content_length: u64 = 0; + let mut current_last_modified: Option = None; + let mut reading_href = false; + let mut reading_content_length = false; + let mut reading_last_modified = false; + let mut in_resourcetype = false; + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + let name_bytes = e.name().as_ref().to_vec(); + let local = local_name(&name_bytes); + match local { + "response" => { + in_response = true; + current_href = None; + current_is_dir = false; + current_content_length = 0; + current_last_modified = None; + } + "propstat" => in_propstat = true, + "prop" if in_propstat => in_prop = true, + "href" if in_response => reading_href = true, + "resourcetype" if in_prop => in_resourcetype = true, + "collection" if in_resourcetype => current_is_dir = true, + "getcontentlength" if in_prop => reading_content_length = true, + "getlastmodified" if in_prop => reading_last_modified = true, + _ => {} + } + } + Ok(Event::End(ref e)) => { + let name_bytes = e.name().as_ref().to_vec(); + let local = local_name(&name_bytes); + match local { + "response" => { + if let Some(href) = current_href.take() { + let path = extract_relative_path(&href, base_url, request_path); + if !path.is_empty() { + results.push(RemoteFileInfo { + path, + is_dir: current_is_dir, + content_length: current_content_length, + last_modified: current_last_modified.take(), + }); + } + } + in_response = false; + } + "propstat" => in_propstat = false, + "prop" => in_prop = false, + "resourcetype" => in_resourcetype = false, + "href" => reading_href = false, + "getcontentlength" => reading_content_length = false, + "getlastmodified" => reading_last_modified = false, + _ => {} + } + } + Ok(Event::Text(ref e)) => { + if let Ok(text) = e.unescape() { + let text = text.to_string(); + if reading_href { + current_href = Some(text); + } else if reading_content_length { + current_content_length = text.trim().parse().unwrap_or(0); + } else if reading_last_modified { + current_last_modified = Some(text); + } + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Error::WebDav(format!("XML parse error: {}", e))), + _ => {} + } + } + + Ok(results) +} + +/// Get local name from a potentially namespaced XML tag name. +fn local_name(name: &[u8]) -> &str { + let s = std::str::from_utf8(name).unwrap_or(""); + // Handle both "D:href" and "href" and "{DAV:}href" forms + if let Some(pos) = s.rfind(':') { + &s[pos + 1..] + } else if let Some(pos) = s.rfind('}') { + &s[pos + 1..] + } else { + s + } +} + +/// Extract a relative path from an href, stripping the base URL prefix and the request path. +fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> String { + let decoded = percent_decode(href); + // Strip scheme + host if present + let path = if let Some(pos) = decoded.find("://") { + let after_scheme = &decoded[pos + 3..]; + if let Some(slash) = after_scheme.find('/') { + &after_scheme[slash..] + } else { + "" + } + } else { + decoded.as_str() + }; + + // Extract the base path from base_url + let base_path = if let Some(pos) = base_url.find("://") { + let after_scheme = &base_url[pos + 3..]; + if let Some(slash) = after_scheme.find('/') { + &after_scheme[slash..] + } else { + "" + } + } else { + "" + }; + + let mut relative = path.to_string(); + // Strip base path prefix + if !base_path.is_empty() { + let bp = base_path.trim_end_matches('/'); + if let Some(stripped) = relative.strip_prefix(bp) { + relative = stripped.to_string(); + } + } + + // Strip request path prefix + let req = request_path.trim_matches('/'); + if !req.is_empty() { + let prefixed = format!("/{}", req); + if let Some(stripped) = relative.strip_prefix(&prefixed) { + relative = stripped.to_string(); + } + } + + // Clean up leading/trailing slashes + let relative = relative.trim_matches('/').to_string(); + relative +} + +// --- Credential Storage --- + +/// Store WebDAV credentials in the platform keychain. +pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> { + let service = format!("com.bevy-tasks.webdav.{}", domain); + + let user_entry = keyring::Entry::new(&service, "username") + .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; + user_entry.set_password(username) + .map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?; + + let pass_entry = keyring::Entry::new(&service, "password") + .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; + pass_entry.set_password(password) + .map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?; + + Ok(()) +} + +/// Load WebDAV credentials from the platform keychain, falling back to env vars. +pub fn load_credentials(domain: &str) -> Result<(String, String)> { + let service = format!("com.bevy-tasks.webdav.{}", domain); + + let user_entry = keyring::Entry::new(&service, "username") + .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; + let pass_entry = keyring::Entry::new(&service, "password") + .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; + + match (user_entry.get_password(), pass_entry.get_password()) { + (Ok(user), Ok(pass)) => return Ok((user, pass)), + _ => {} + } + + // Fallback to env vars for headless/CI environments + if let (Ok(user), Ok(pass)) = ( + std::env::var("BEVY_TASKS_WEBDAV_USER"), + std::env::var("BEVY_TASKS_WEBDAV_PASS"), + ) { + return Ok((user, pass)); + } + + Err(Error::Credential(format!( + "No credentials found for '{}'. Run 'bevy-tasks sync --setup' or set BEVY_TASKS_WEBDAV_USER and BEVY_TASKS_WEBDAV_PASS.", + domain + ))) +} + +/// Delete WebDAV credentials from the platform keychain. +pub fn delete_credentials(domain: &str) -> Result<()> { + let service = format!("com.bevy-tasks.webdav.{}", domain); + + if let Ok(entry) = keyring::Entry::new(&service, "username") { + let _ = entry.delete_credential(); + } + if let Ok(entry) = keyring::Entry::new(&service, "password") { + let _ = entry.delete_credential(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- URL encoding tests --- + + #[test] + fn test_percent_encode_simple() { + assert_eq!(percent_encode("hello"), "hello"); + } + + #[test] + fn test_percent_encode_spaces() { + assert_eq!(percent_encode("Buy groceries"), "Buy%20groceries"); + } + + #[test] + fn test_percent_encode_special_chars() { + assert_eq!(percent_encode("task (1)"), "task%20%281%29"); + } + + #[test] + fn test_percent_decode_roundtrip() { + let original = "Buy groceries (urgent)"; + let encoded = percent_encode(original); + let decoded = percent_decode(&encoded); + assert_eq!(decoded, original); + } + + // --- PROPFIND XML parsing tests --- + + #[test] + fn test_parse_propfind_with_d_prefix() { + let xml = r#" + + + /remote/ + + + + 0 + + + + + /remote/My%20Tasks/ + + + + 0 + + + + + /remote/My%20Tasks/Buy%20groceries.md + + + + 150 + Mon, 01 Jan 2026 00:00:00 GMT + + + +"#; + + let results = parse_propfind_response(xml, "http://example.com/remote", "").unwrap(); + assert_eq!(results.len(), 2); // Root directory itself is empty path -> skipped + assert_eq!(results[0].path, "My Tasks"); + assert!(results[0].is_dir); + assert_eq!(results[1].path, "My Tasks/Buy groceries.md"); + assert!(!results[1].is_dir); + assert_eq!(results[1].content_length, 150); + } + + #[test] + fn test_parse_propfind_with_uppercase_d_prefix() { + let xml = r#" + + + /dav/ + + + + + + + + /dav/notes.md + + + + 42 + + + +"#; + + let results = parse_propfind_response(xml, "http://example.com/dav", "").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "notes.md"); + assert!(!results[0].is_dir); + assert_eq!(results[0].content_length, 42); + } + + #[test] + fn test_parse_propfind_no_prefix() { + let xml = r#" + + + /files/ + + + + + + + + /files/test.md + + + + 100 + Tue, 15 Mar 2026 10:30:00 GMT + + + +"#; + + let results = parse_propfind_response(xml, "http://example.com/files", "").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test.md"); + assert_eq!(results[0].last_modified.as_deref(), Some("Tue, 15 Mar 2026 10:30:00 GMT")); + } + + #[test] + fn test_parse_propfind_with_subpath() { + let xml = r#" + + + /remote/My%20Tasks/ + + + + + + + + /remote/My%20Tasks/task1.md + + + + 50 + + + +"#; + + let results = parse_propfind_response(xml, "http://example.com/remote", "My Tasks").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "task1.md"); + } + + // --- WebDavClient URL building --- + + #[test] + fn test_full_url_building() { + let client = WebDavClient::new("http://example.com/dav/", "user", "pass"); + assert_eq!(client.full_url(""), "http://example.com/dav"); + assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md"); + assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md"); + } + + #[test] + fn test_full_url_strips_leading_slash() { + let client = WebDavClient::new("http://example.com/dav", "user", "pass"); + assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md"); + } + + // --- extract_relative_path --- + + #[test] + fn test_extract_relative_path_full_url_href() { + let path = extract_relative_path( + "http://example.com/dav/My%20Tasks/file.md", + "http://example.com/dav", + "", + ); + assert_eq!(path, "My Tasks/file.md"); + } + + #[test] + fn test_extract_relative_path_absolute_href() { + let path = extract_relative_path( + "/dav/Work/meeting.md", + "http://example.com/dav", + "", + ); + assert_eq!(path, "Work/meeting.md"); + } +}