diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/AGENTS.md b/AGENTS.md index f04a40e..c0662ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,16 @@ The local directory structure must be: `///= total`. Each course provides `start-semester`/`end-semester` relationships to semester IDs, course numbers, and titles. +- `/semesters/{id}` exposes only human strings like `"WiSe 2024/25"` plus ISO start/end timestamps—no canonical short keys. Derive keys such as `ws2425` from the title or `start` year and cache the mapping `semester_id → key` in `state.toml`. +- `/courses/{id}/folders` lists folder nodes with attributes (`folder-type`, `is-empty`, mkdate/chdate) and nested relationships: follow `/folders/{folder_id}/folders` recursively for subfolders, because `meta.count` only reports a child count. +- `/folders/{id}/file-refs` is the primary listing for downloadable files. Each `file-ref` has attributes (`name`, `filesize`, `mkdate`, `chdate`, MIME, `is-downloadable`), relationships back to the parent folder/course, and a `meta.download-url` like `/sendfile.php?...`. Prepend the configured base URL before downloading. +- `/files/{id}` only repeats size/timestamp data and links back to `file-refs`; it does **not** expose checksums. Track change detection via `(file-ref id, filesize, chdate)` and/or compute local hashes. +- File/folder listings share the same JSON:API pagination scheme. Always honor the `meta.page` counts and `links.first/last/next` to avoid missing entries in large folders. + ## Configuration (TOML, including paths) All configuration and state in this project must use **TOML**. [web:131] diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c7d8b49 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2264 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[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 = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[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 = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "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 = "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", + "http", + "http-body", + "httparse", + "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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[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.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "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.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "async-compression", + "base64", + "bytes", + "futures-core", + "futures-util", + "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", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "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 = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +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 = "studip-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "atty", + "base64", + "clap", + "directories", + "reqwest", + "rpassword", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "time", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "url", + "walkdir", +] + +[[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.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "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 = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +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.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.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.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +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" + +[[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 new file mode 100644 index 0000000..bdaabd4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "studip-sync" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +directories = "5.0" +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "brotli", "deflate", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tokio = { version = "1.37", features = ["macros", "rt-multi-thread", "fs", "io-util"] } +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] } +url = "2.4" +atty = "0.2" +rpassword = "7.3" +walkdir = "2.5" +time = "0.3" +sha2 = "0.10" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f2d59e --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# studip-sync + +Command-line tool written in Rust (edition 2024) to sync files from the Stud.IP JSON:API to a local filesystem tree. +The repository contains the cargo project (with CLI/config/state scaffolding) plus an offline copy of the JSON:API documentation (in `jsonapi/`). + +## Current status + +- `cargo` binary crate scaffolded with name `studip-sync`, pinned to Rust edition 2024. +- CLI implemented with `auth`, `sync`, and `list-courses` subcommands plus logging/verbosity flags. +- Config/state loaders wired up with XDG path resolution, multi-profile support, and JSON/quiet/debug logging modes. +- `studip-sync auth` prompts for credentials (or reads `--username/--password` / `STUDIP_SYNC_USERNAME|PASSWORD`) and stores the base64 Basic auth token in the active profile. +- `studip-sync list-courses` now talks to the Stud.IP JSON:API, caches user/semester/course metadata, and prints a table of enrolled courses (with pagination + semester-key inference). +- `studip-sync sync` walks courses → folders → file refs via the JSON:API, downloads missing or changed files (streamed to disk), and supports `--dry-run` / `--prune` cleanup. +- Ready for further implementation of Stud.IP HTTP client, sync logic, and actual command behaviors. + +## Next steps + +1. Add configurable download concurrency plus richer progress/logging (per-course summaries, ETA) while keeping memory usage low. +2. Implement smarter state usage (incremental `filter[since]` queries, resume checkpoints) and expand pruning to detect/cleanup orphaned state entries. +3. Add tests and ensure `cargo fmt` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo test` pass (and wire into CI if applicable). diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..3b3d3f4 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,1032 @@ +use crate::{ + Result, + config::{ConfigFile, ConfigProfile}, + logging::{LogConfig, init_logging}, + paths::{AppPaths, PathOverrides}, + semesters, + state::{CourseState, ProfileState, SemesterState, StateFile}, + studip_client::{Course, FileRef, Folder, SemesterData, SemesterResponse, StudipClient}, +}; +use anyhow::{Context, anyhow, bail}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use clap::{ArgAction, Parser, Subcommand, ValueHint}; +use rpassword::prompt_password; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + env, + fmt::Write as FmtWrite, + fs::File, + io::{self, BufReader, Read, Write}, + path::{Path, PathBuf}, +}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::{fs, io::AsyncWriteExt}; +use tracing::info; +use walkdir::WalkDir; + +const USERNAME_ENV: &str = "STUDIP_SYNC_USERNAME"; +const PASSWORD_ENV: &str = "STUDIP_SYNC_PASSWORD"; + +#[derive(Debug, Parser)] +#[command( + name = "studip-sync", + about = "Synchronize Stud.IP documents to the local filesystem." +)] +pub struct Cli { + #[arg(short = 'q', long = "quiet", action = ArgAction::SetTrue, global = true)] + quiet: bool, + #[arg(short = 'd', long = "debug", action = ArgAction::SetTrue, global = true)] + debug: bool, + #[arg(short = 'j', long = "json", action = ArgAction::SetTrue, global = true)] + json: bool, + #[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)] + verbose: u8, + #[arg(long = "config-dir", value_hint = ValueHint::DirPath, global = true)] + config_dir: Option, + #[arg(long = "data-dir", value_hint = ValueHint::DirPath, global = true)] + data_dir: Option, + #[arg(long = "profile", global = true)] + profile: Option, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Configure credentials and other persistent settings. + Auth(AuthArgs), + /// Perform a one-way sync from Stud.IP to the local filesystem. + Sync(SyncArgs), + /// Show known courses, optionally refreshing from Stud.IP. + ListCourses(ListCoursesArgs), +} + +#[derive(Debug, Parser)] +pub struct AuthArgs { + #[arg(long = "non-interactive", action = ArgAction::SetTrue)] + pub non_interactive: bool, + #[arg(long = "username")] + pub username: Option, + #[arg(long = "password")] + pub password: Option, +} + +#[derive(Debug, Parser)] +pub struct SyncArgs { + #[arg(long = "dry-run", action = ArgAction::SetTrue)] + pub dry_run: bool, + #[arg(long = "prune", action = ArgAction::SetTrue)] + pub prune: bool, + #[arg(long = "since")] + pub since: Option, +} + +#[derive(Debug, Parser)] +pub struct ListCoursesArgs { + #[arg(long = "refresh", action = ArgAction::SetTrue)] + pub refresh: bool, +} + +impl Cli { + pub async fn run(self) -> Result<()> { + let log_cfg = LogConfig { + quiet: self.quiet, + debug: self.debug, + json: self.json, + verbosity: self.verbose, + }; + init_logging(&log_cfg)?; + + let overrides = PathOverrides { + config_dir: self.config_dir.clone(), + data_dir: self.data_dir.clone(), + }; + let paths = AppPaths::new(&overrides)?; + paths.ensure_dirs()?; + + let config_path = paths.config_file(); + let state_path = paths.state_file(); + + let mut config_store = ConfigFile::load_or_default(&config_path)?; + let state_store = StateFile::load_or_default(&state_path)?; + + let profile_name = self + .profile + .clone() + .unwrap_or_else(|| config_store.default_profile.clone()); + + let config_preexisting = config_store.profiles.contains_key(&profile_name); + config_store + .profiles + .entry(profile_name.clone()) + .or_insert_with(ConfigProfile::default); + + let mut ctx = CommandContext::new( + paths, + profile_name, + config_store, + state_store, + config_path, + state_path, + !config_preexisting, + ); + + match self.command { + Command::Auth(args) => args.execute(&mut ctx).await?, + Command::Sync(args) => args.execute(&mut ctx).await?, + Command::ListCourses(args) => args.execute(&mut ctx).await?, + } + + ctx.persist()?; + + Ok(()) + } +} + +pub struct CommandContext { + paths: AppPaths, + active_profile: String, + pub config: ConfigFile, + pub state: StateFile, + config_path: PathBuf, + state_path: PathBuf, + config_dirty: bool, + state_dirty: bool, +} + +impl CommandContext { + fn new( + paths: AppPaths, + active_profile: String, + config: ConfigFile, + state: StateFile, + config_path: PathBuf, + state_path: PathBuf, + config_dirty: bool, + ) -> Self { + Self { + paths, + active_profile, + config, + state, + config_path, + state_path, + config_dirty, + state_dirty: false, + } + } + + pub fn profile_name(&self) -> &str { + &self.active_profile + } + + pub fn paths(&self) -> &AppPaths { + &self.paths + } + + pub fn config_profile(&self) -> &ConfigProfile { + self.config + .profiles + .get(&self.active_profile) + .expect("active profile must exist") + } + + pub fn config_profile_mut(&mut self) -> &mut ConfigProfile { + self.config_dirty = true; + self.config + .profiles + .get_mut(&self.active_profile) + .expect("active profile must exist") + } + + pub fn state_profile(&self) -> Option<&ProfileState> { + self.state.profile(&self.active_profile) + } + + pub fn state_profile_mut(&mut self) -> &mut ProfileState { + self.state_dirty = true; + self.state.profile_mut(&self.active_profile) + } + + pub fn semester_key_by_id(&self, semester_id: &str) -> Option { + self.state_profile().and_then(|profile| { + profile.semesters.iter().find_map(|(key, semester)| { + if semester.id == semester_id { + Some(key.clone()) + } else { + None + } + }) + }) + } + + pub fn insert_semester( + &mut self, + preferred_key: String, + mut semester: SemesterState, + ) -> String { + if let Some(existing_key) = self.semester_key_by_id(&semester.id) { + return existing_key; + } + + let base_key = preferred_key; + let mut key = base_key.clone(); + let mut counter = 2; + loop { + let collision = self + .state_profile() + .and_then(|profile| profile.semesters.get(&key)) + .map(|existing| existing.id != semester.id) + .unwrap_or(false); + + if collision { + key = format!("{base_key}-{counter}"); + counter += 1; + continue; + } + break; + } + + semester.key = key.clone(); + self.state_profile_mut() + .semesters + .insert(key.clone(), semester); + key + } + + pub fn upsert_course(&mut self, course: CourseState) { + self.state_profile_mut() + .courses + .insert(course.id.clone(), course); + } + + pub fn clear_courses(&mut self) { + self.state_profile_mut().courses.clear(); + } + + pub fn mark_state_dirty(&mut self) { + self.state_dirty = true; + } + + pub fn persist(&mut self) -> Result<()> { + if self.config_dirty { + self.config.save(&self.config_path)?; + self.config_dirty = false; + } + if self.state_dirty { + self.state.save(&self.state_path)?; + self.state_dirty = false; + } + Ok(()) + } +} + +impl AuthArgs { + async fn execute(&self, ctx: &mut CommandContext) -> Result<()> { + let (username, password) = self.collect_credentials()?; + let encoded = BASE64.encode(format!("{username}:{password}")); + let profile = ctx.config_profile_mut(); + profile.username = Some(username.clone()); + profile.basic_auth_b64 = Some(encoded); + + info!( + profile = ctx.profile_name(), + username = username.as_str(), + "stored Stud.IP credentials" + ); + Ok(()) + } + + fn collect_credentials(&self) -> Result<(String, String)> { + let mut username = self.username_from_sources(); + let mut password = self.password_from_sources(); + + if self.non_interactive { + let username = username.ok_or_else(|| { + anyhow!("non-interactive auth requires --username or {USERNAME_ENV}") + })?; + let password = password.ok_or_else(|| { + anyhow!("non-interactive auth requires --password or {PASSWORD_ENV}") + })?; + return Ok((username, password)); + } + + if username.is_none() { + username = Some(prompt_username("Stud.IP username: ")?); + } + if password.is_none() { + password = Some(prompt_secret("Stud.IP password: ")?); + } + + let username = username.expect("username guaranteed"); + let password = password.expect("password guaranteed"); + Ok((username, password)) + } + + fn username_from_sources(&self) -> Option { + self.username + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + env::var(USERNAME_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + } + + fn password_from_sources(&self) -> Option { + self.password + .clone() + .filter(|value| !value.is_empty()) + .or_else(|| { + env::var(PASSWORD_ENV) + .ok() + .filter(|value| !value.is_empty()) + }) + } +} + +fn prompt_username(prompt: &str) -> Result { + let mut stdout = io::stdout(); + stdout.write_all(prompt.as_bytes())?; + stdout.flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let value = input.trim().to_string(); + if value.is_empty() { + bail!("username cannot be empty"); + } + Ok(value) +} + +fn prompt_secret(prompt: &str) -> Result { + let value = prompt_password(prompt)?; + if value.is_empty() { + bail!("password cannot be empty"); + } + Ok(value) +} + +impl SyncArgs { + async fn execute(&self, ctx: &mut CommandContext) -> Result<()> { + let client = StudipClient::from_profile(ctx.config_profile())?; + let user_id = resolve_user_id(ctx, &client, false).await?; + let courses = client.list_courses(&user_id).await?; + + ensure_semesters_cached(ctx, &client, &courses).await?; + + let fallback_root = ctx.paths().data_dir.join("downloads"); + let download_root = ctx + .config_profile() + .download_root + .clone() + .unwrap_or(fallback_root); + + if !self.dry_run { + fs::create_dir_all(&download_root).await?; + } + + let mut remote_files = HashSet::new(); + let mut stats = SyncStats::default(); + let mut name_registry = NameRegistry::default(); + + for course in courses { + sync_course( + ctx, + &client, + &course, + &download_root, + &mut name_registry, + self, + &mut remote_files, + &mut stats, + ) + .await?; + } + + if self.prune { + let prune = prune_local(&download_root, &remote_files, self.dry_run)?; + stats.pruned_files = prune.removed_files; + stats.pruned_dirs = prune.removed_dirs; + } + + info!( + profile = ctx.profile_name(), + dry_run = self.dry_run, + prune = self.prune, + downloaded = stats.downloaded, + skipped = stats.skipped, + planned = stats.planned, + pruned_files = stats.pruned_files, + pruned_dirs = stats.pruned_dirs, + "sync completed" + ); + Ok(()) + } +} + +impl ListCoursesArgs { + async fn execute(&self, ctx: &mut CommandContext) -> Result<()> { + let client = StudipClient::from_profile(ctx.config_profile())?; + let user_id = resolve_user_id(ctx, &client, self.refresh).await?; + let courses = client.list_courses(&user_id).await?; + + ensure_semesters_cached(ctx, &client, &courses).await?; + + let previous_courses = ctx + .state_profile() + .map(|profile| profile.courses.clone()) + .unwrap_or_default(); + + if self.refresh { + ctx.clear_courses(); + } + + let mut summaries = Vec::new(); + + for course in courses { + let semester_key = course + .relationships + .semester_id() + .and_then(|sem_id| ctx.semester_key_by_id(sem_id)) + .unwrap_or_else(|| "unknown".to_string()); + + let previous_sync = previous_courses + .get(&course.id) + .and_then(|existing| existing.last_sync.clone()); + + let display_title = course_display_title(&course); + let summary = CourseSummary { + semester_key: semester_key.clone(), + course_id: course.id.clone(), + course_number: course.attributes.course_number.clone(), + title: display_title.clone(), + }; + + ctx.upsert_course(CourseState { + id: course.id.clone(), + name: display_title, + semester_key, + last_sync: previous_sync, + }); + + summaries.push(summary); + } + + summaries.sort_by(|a, b| { + a.semester_key + .cmp(&b.semester_key) + .then_with(|| a.title.cmp(&b.title)) + }); + + if summaries.is_empty() { + println!("No courses found on Stud.IP."); + } else { + for summary in &summaries { + println!( + "{:<8} {:<12} {:<12} {}", + summary.semester_key, + summary + .course_number + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("-"), + summary.course_id, + summary.title + ); + } + } + + info!( + profile = ctx.profile_name(), + refresh = self.refresh, + count = summaries.len(), + "listed courses" + ); + Ok(()) + } +} + +struct CourseSummary { + semester_key: String, + course_id: String, + course_number: Option, + title: String, +} + +async fn resolve_user_id( + ctx: &mut CommandContext, + client: &StudipClient, + force_refresh: bool, +) -> Result { + if !force_refresh { + if let Some(existing) = ctx + .state_profile() + .and_then(|profile| profile.user_id.clone()) + { + return Ok(existing); + } + } + + let response = client.current_user().await?; + let user_id = response.data.id; + ctx.state_profile_mut().user_id = Some(user_id.clone()); + Ok(user_id) +} + +async fn ensure_semesters_cached( + ctx: &mut CommandContext, + client: &StudipClient, + courses: &[Course], +) -> Result<()> { + let mut missing = HashSet::new(); + for course in courses { + if let Some(semester_id) = course.relationships.semester_id() { + if ctx.semester_key_by_id(semester_id).is_none() { + missing.insert(semester_id.to_string()); + } + } + } + + for semester_id in missing { + let SemesterResponse { data } = client.semester(&semester_id).await?; + let SemesterData { id, attributes } = data; + let title = attributes.title; + let preferred_key = semesters::infer_key(&title); + ctx.insert_semester( + preferred_key, + SemesterState { + id, + title, + key: String::new(), + }, + ); + } + + Ok(()) +} + +fn course_display_title(course: &Course) -> String { + match &course.attributes.subtitle { + Some(subtitle) if !subtitle.is_empty() => { + format!("{} – {}", course.attributes.title, subtitle) + } + _ => course.attributes.title.clone(), + } +} + +#[derive(Default)] +struct SyncStats { + downloaded: usize, + skipped: usize, + planned: usize, + pruned_files: usize, + pruned_dirs: usize, +} + +#[derive(Default)] +struct PruneStats { + removed_files: usize, + removed_dirs: usize, +} + +async fn sync_course( + ctx: &mut CommandContext, + client: &StudipClient, + course: &Course, + download_root: &Path, + name_registry: &mut NameRegistry, + args: &SyncArgs, + remote_files: &mut HashSet, + stats: &mut SyncStats, +) -> Result<()> { + let semester_key = course + .relationships + .semester_id() + .and_then(|id| ctx.semester_key_by_id(id)) + .unwrap_or_else(|| "unknown".into()); + + let semester_dir = download_root.join(&semester_key); + ensure_directory(&semester_dir, args.dry_run).await?; + + let course_dir_name = name_registry.unique_dir_name( + &semester_dir, + normalize_component(&course.attributes.title), + &course.id, + ); + let course_dir = semester_dir.join(course_dir_name); + ensure_directory(&course_dir, args.dry_run).await?; + + let folders = client.list_course_folders(&course.id).await?; + for folder in folders { + sync_folder( + ctx, + client, + folder, + &course_dir, + download_root, + name_registry, + args, + remote_files, + stats, + ) + .await?; + } + + Ok(()) +} + +async fn sync_folder( + ctx: &mut CommandContext, + client: &StudipClient, + folder: Folder, + parent_path: &Path, + download_root: &Path, + name_registry: &mut NameRegistry, + args: &SyncArgs, + remote_files: &mut HashSet, + stats: &mut SyncStats, +) -> Result<()> { + let mut stack = vec![(folder, parent_path.to_path_buf())]; + + while let Some((current_folder, parent)) = stack.pop() { + let folder_component = name_registry.unique_dir_name( + &parent, + normalize_component(¤t_folder.attributes.name), + ¤t_folder.id, + ); + let folder_path = parent.join(folder_component); + ensure_directory(&folder_path, args.dry_run).await?; + + let file_refs = client.list_file_refs(¤t_folder.id).await?; + for file_ref in file_refs { + sync_file_ref( + ctx, + client, + file_ref, + &folder_path, + download_root, + name_registry, + args, + remote_files, + stats, + ) + .await?; + } + + let subfolders = client.list_subfolders(¤t_folder.id).await?; + for subfolder in subfolders { + stack.push((subfolder, folder_path.clone())); + } + } + + Ok(()) +} + +async fn sync_file_ref( + ctx: &mut CommandContext, + client: &StudipClient, + file_ref: FileRef, + folder_path: &Path, + download_root: &Path, + name_registry: &mut NameRegistry, + args: &SyncArgs, + remote_files: &mut HashSet, + stats: &mut SyncStats, +) -> Result<()> { + let file_name = name_registry.unique_file_name( + folder_path, + normalize_file_name(&file_ref.attributes.name), + &file_ref.id, + ); + let local_path = folder_path.join(&file_name); + let relative_path = local_path + .strip_prefix(download_root) + .context("File path escaped download root")? + .to_path_buf(); + + remote_files.insert(relative_path.clone()); + + let needs_download = file_needs_download(ctx, &file_ref, &local_path); + + if args.dry_run { + if needs_download { + println!("Would download {}", relative_path.display()); + stats.planned += 1; + } else { + stats.skipped += 1; + } + return Ok(()); + } + + if needs_download { + let checksum = download_file_to(client, &file_ref, &local_path).await?; + update_file_state(ctx, &file_ref, &local_path, Some(checksum))?; + println!("Downloaded {}", relative_path.display()); + stats.downloaded += 1; + } else { + stats.skipped += 1; + } + + Ok(()) +} + +fn file_needs_download(ctx: &CommandContext, file_ref: &FileRef, local_path: &Path) -> bool { + if !local_path.exists() { + return true; + } + let existing = ctx + .state_profile() + .and_then(|profile| profile.files.get(&file_ref.id)); + + if let Some(state) = existing { + if let Some(saved) = state.remote_modified.as_deref() { + if saved != file_ref.attributes.modified.as_str() { + return true; + } + } + + if let (Some(saved_size), Some(remote_size)) = (state.size, file_ref.attributes.file_size) { + if saved_size != remote_size { + return true; + } + } + + if let Some(expected_checksum) = state.checksum.as_deref() { + match compute_file_checksum(local_path) { + Ok(actual) => { + if actual != expected_checksum { + return true; + } + } + Err(_) => return true, + } + } + + false + } else { + true + } +} + +async fn download_file_to( + client: &StudipClient, + file_ref: &FileRef, + destination: &Path, +) -> Result { + let meta = file_ref + .meta + .as_ref() + .ok_or_else(|| anyhow!("file-ref {} is missing download metadata", file_ref.id))?; + + let mut response = client.download_file(&meta.download_url).await?; + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).await?; + } + + let tmp_path = destination.with_extension("part"); + let _ = fs::remove_file(&tmp_path).await; + let mut file = fs::File::create(&tmp_path).await?; + let mut hasher = Sha256::new(); + + while let Some(chunk) = response.chunk().await? { + hasher.update(&chunk); + file.write_all(&chunk).await?; + } + file.flush().await?; + drop(file); + + fs::rename(&tmp_path, destination).await?; + let digest = hasher.finalize(); + Ok(hex_encode(digest.as_slice())) +} + +fn update_file_state( + ctx: &mut CommandContext, + file_ref: &FileRef, + destination: &Path, + checksum: Option, +) -> Result<()> { + let profile = ctx.state_profile_mut(); + let entry = + profile + .files + .entry(file_ref.id.clone()) + .or_insert_with(|| crate::state::FileState { + id: file_ref.id.clone(), + ..Default::default() + }); + + entry.size = file_ref.attributes.file_size; + entry.remote_modified = Some(file_ref.attributes.modified.clone()); + entry.last_downloaded = Some(current_timestamp()?); + entry.path_hint = Some(destination.to_path_buf()); + if let Some(hash) = checksum { + entry.checksum = Some(hash); + } + + ctx.mark_state_dirty(); + Ok(()) +} + +fn current_timestamp() -> Result { + let now = OffsetDateTime::now_utc(); + Ok(now.format(&Rfc3339)?) +} + +async fn ensure_directory(path: &Path, dry_run: bool) -> Result<()> { + if dry_run { + return Ok(()); + } + fs::create_dir_all(path).await?; + Ok(()) +} + +fn normalize_component(input: &str) -> String { + let mut sanitized = String::new(); + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + sanitized.push(ch); + } else if ch.is_whitespace() || matches!(ch, '-' | '_' | '.') { + if !sanitized.ends_with('_') { + sanitized.push('_'); + } + } else { + // skip other characters + } + } + let trimmed = sanitized.trim_matches('_'); + if trimmed.is_empty() { + "untitled".to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_file_name(name: &str) -> String { + let mut sanitized = String::new(); + for ch in name.chars() { + if ch == '/' || ch == '\\' { + sanitized.push('_'); + } else if ch.is_control() { + continue; + } else { + sanitized.push(ch); + } + } + if sanitized.trim().is_empty() { + "file".to_string() + } else { + sanitized + } +} + +fn compute_file_checksum(path: &Path) -> Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + Ok(hex_encode(hasher.finalize().as_slice())) +} + +fn prune_local( + root: &Path, + expected_files: &HashSet, + dry_run: bool, +) -> Result { + if !root.exists() { + return Ok(PruneStats::default()); + } + + let mut stats = PruneStats::default(); + + for entry in WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let rel = entry.path().strip_prefix(root).unwrap_or(entry.path()); + if !expected_files.contains(rel) { + if dry_run { + println!("Would delete {}", rel.display()); + } else { + std::fs::remove_file(entry.path())?; + println!("Deleted {}", rel.display()); + } + stats.removed_files += 1; + } + } + + for entry in WalkDir::new(root) + .contents_first(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_dir()) + { + if entry.path() == root { + continue; + } + if entry + .path() + .read_dir() + .map(|mut i| i.next().is_none()) + .unwrap_or(false) + { + if dry_run { + println!( + "Would remove empty directory {}", + entry + .path() + .strip_prefix(root) + .unwrap_or(entry.path()) + .display() + ); + } else { + std::fs::remove_dir(entry.path()).ok(); + } + stats.removed_dirs += 1; + } + } + + Ok(stats) +} + +#[derive(Default)] +struct NameRegistry { + dir_names: HashMap>, + file_names: HashMap>, +} + +impl NameRegistry { + fn unique_dir_name(&mut self, parent: &Path, base: String, fallback_id: &str) -> String { + Self::unique_name_internal(&mut self.dir_names, parent, base, fallback_id, "untitled") + } + + fn unique_file_name(&mut self, parent: &Path, base: String, fallback_id: &str) -> String { + Self::unique_name_internal(&mut self.file_names, parent, base, fallback_id, "file") + } + + fn unique_name_internal( + registry: &mut HashMap>, + parent: &Path, + base: String, + fallback_id: &str, + fallback_default: &str, + ) -> String { + let parent_key = parent.to_path_buf(); + let entry = registry.entry(parent_key).or_default(); + let short = short_id(fallback_id); + let candidate = if base.is_empty() { + format!("{fallback_default}_{short}") + } else { + base + }; + + if !entry.contains(&candidate) && !parent.join(&candidate).exists() { + entry.insert(candidate.clone()); + return candidate; + } + + let mut counter = 2; + let base_name = candidate.clone(); + loop { + let new_candidate = format!("{base_name}_{short}_{counter}"); + if !entry.contains(&new_candidate) && !parent.join(&new_candidate).exists() { + entry.insert(new_candidate.clone()); + return new_candidate; + } + counter += 1; + } + } +} + +fn short_id(id: &str) -> String { + let trimmed = id.trim(); + if trimmed.len() <= 8 { + trimmed.to_string() + } else { + trimmed[..8].to_string() + } +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(&mut out, "{:02x}", byte); + } + out +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..446cdb1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,114 @@ +use crate::Result; +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +const DEFAULT_PROFILE_NAME: &str = "default"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigProfile { + #[serde(default)] + pub username: Option, + #[serde(default = "ConfigProfile::default_base_url")] + pub base_url: String, + #[serde(default = "ConfigProfile::default_jsonapi_path")] + pub jsonapi_path: String, + #[serde(default)] + pub basic_auth_b64: Option, + #[serde(default)] + pub download_root: Option, + #[serde(default = "ConfigProfile::default_max_concurrent_downloads")] + pub max_concurrent_downloads: usize, +} + +impl Default for ConfigProfile { + fn default() -> Self { + Self { + username: None, + base_url: Self::default_base_url(), + jsonapi_path: Self::default_jsonapi_path(), + basic_auth_b64: None, + download_root: None, + max_concurrent_downloads: Self::default_max_concurrent_downloads(), + } + } +} + +impl ConfigProfile { + fn default_base_url() -> String { + "https://studip.uni-trier.de".to_string() + } + + fn default_jsonapi_path() -> String { + "/jsonapi.php/v1".to_string() + } + + fn default_max_concurrent_downloads() -> usize { + 3 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigFile { + #[serde(default = "default_profile_name")] + pub default_profile: String, + #[serde(default)] + pub profiles: HashMap, +} + +impl Default for ConfigFile { + fn default() -> Self { + let mut profiles = HashMap::new(); + profiles.insert(DEFAULT_PROFILE_NAME.to_string(), ConfigProfile::default()); + Self { + default_profile: DEFAULT_PROFILE_NAME.to_string(), + profiles, + } + } +} + +fn default_profile_name() -> String { + DEFAULT_PROFILE_NAME.to_string() +} + +impl ConfigFile { + pub fn load_or_default(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => { + let file: Self = + toml::from_str(&contents).context("Failed to parse config.toml")?; + Ok(file) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(err) => Err(err.into()), + } + } + + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = toml::to_string_pretty(self)?; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = file.metadata()?.permissions(); + perms.set_mode(0o600); + file.set_permissions(perms)?; + } + file.write_all(contents.as_bytes())?; + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f06f6f2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod cli; +pub mod config; +pub mod logging; +pub mod paths; +pub mod semesters; +pub mod state; +pub mod studip_client; + +pub type Result = anyhow::Result; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..97a9185 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,61 @@ +use crate::Result; +use tracing::Level; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +#[derive(Clone, Debug, Default)] +pub struct LogConfig { + pub quiet: bool, + pub debug: bool, + pub json: bool, + pub verbosity: u8, +} + +impl LogConfig { + pub fn level(&self) -> Level { + if self.quiet { + return Level::ERROR; + } + if self.debug { + return Level::DEBUG; + } + match self.verbosity { + 0 => Level::INFO, + 1 => Level::DEBUG, + _ => Level::TRACE, + } + } +} + +pub fn init_logging(cfg: &LogConfig) -> Result<()> { + let level = cfg.level(); + + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level.to_string())); + + let registry = tracing_subscriber::registry().with(env_filter); + + let init_result = if cfg.json { + registry + .with( + fmt::layer() + .with_target(false) + .with_level(true) + .with_ansi(atty::is(atty::Stream::Stdout)) + .with_timer(fmt::time::SystemTime) + .json(), + ) + .try_init() + } else { + registry + .with( + fmt::layer() + .with_target(false) + .with_level(true) + .with_ansi(atty::is(atty::Stream::Stdout)) + .with_timer(fmt::time::SystemTime), + ) + .try_init() + }; + + init_result.map_err(|err| err.into()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..efa5adf --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +use clap::Parser; +use studip_sync::{Result, cli::Cli}; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + cli.run().await +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..d2d5625 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,58 @@ +use crate::Result; +use anyhow::anyhow; +use directories::ProjectDirs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default)] +pub struct PathOverrides { + pub config_dir: Option, + pub data_dir: Option, +} + +#[derive(Debug, Clone)] +pub struct AppPaths { + pub config_dir: PathBuf, + pub data_dir: PathBuf, +} + +impl AppPaths { + pub fn new(overrides: &PathOverrides) -> Result { + if let (Some(config_dir), Some(data_dir)) = (&overrides.config_dir, &overrides.data_dir) { + return Ok(Self { + config_dir: config_dir.clone(), + data_dir: data_dir.clone(), + }); + } + + let project_dirs = ProjectDirs::from("de", "UniTrier", "studip-sync") + .ok_or_else(|| anyhow!("Failed to determine XDG directories"))?; + + let config_dir = overrides + .config_dir + .clone() + .unwrap_or_else(|| project_dirs.config_dir().to_path_buf()); + let data_dir = overrides + .data_dir + .clone() + .unwrap_or_else(|| project_dirs.data_dir().to_path_buf()); + + Ok(Self { + config_dir, + data_dir, + }) + } + + pub fn ensure_dirs(&self) -> Result<()> { + std::fs::create_dir_all(&self.config_dir)?; + std::fs::create_dir_all(&self.data_dir)?; + Ok(()) + } + + pub fn config_file(&self) -> PathBuf { + self.config_dir.join("config.toml") + } + + pub fn state_file(&self) -> PathBuf { + self.data_dir.join("state.toml") + } +} diff --git a/src/semesters.rs b/src/semesters.rs new file mode 100644 index 0000000..9550b1d --- /dev/null +++ b/src/semesters.rs @@ -0,0 +1,107 @@ +pub fn infer_key(title: &str) -> String { + use SemesterSeason::*; + + let season = detect_season(title); + let numbers = extract_numbers(title); + + match season { + Winter => { + let first = numbers + .get(0) + .map(|value| last_two_digits(value)) + .unwrap_or_else(|| "00".into()); + let second = numbers + .get(1) + .map(|value| last_two_digits(value)) + .unwrap_or_else(|| { + // winter spans two years; if only one provided, assume +1 + numbers + .get(0) + .map(|n| increment_two_digits(n)) + .unwrap_or_else(|| "00".into()) + }); + format!("ws{first}{second}") + } + Summer => { + let year = numbers + .get(0) + .map(|value| last_two_digits(value)) + .unwrap_or_else(|| "00".into()); + format!("ss{year}") + } + Unknown => fallback_key(title), + } +} + +#[derive(Copy, Clone, Debug)] +enum SemesterSeason { + Winter, + Summer, + Unknown, +} + +fn detect_season(title: &str) -> SemesterSeason { + let lower = title.to_ascii_lowercase(); + if lower.contains("wise") + || lower.contains("winter") + || lower.contains("ws ") + || lower.starts_with("ws") + { + SemesterSeason::Winter + } else if lower.contains("sose") + || lower.contains("sommer") + || lower.contains("summer") + || lower.contains("ss ") + || lower.starts_with("ss") + { + SemesterSeason::Summer + } else { + SemesterSeason::Unknown + } +} + +fn extract_numbers(title: &str) -> Vec { + let mut numbers = Vec::new(); + let mut current = String::new(); + for ch in title.chars() { + if ch.is_ascii_digit() { + current.push(ch); + } else if !current.is_empty() { + numbers.push(current.clone()); + current.clear(); + } + } + if !current.is_empty() { + numbers.push(current); + } + numbers +} + +fn last_two_digits(value: &str) -> String { + if value.len() <= 2 { + format!("{:0>2}", value) + } else { + value[value.len() - 2..].to_string() + } +} + +fn increment_two_digits(value: &str) -> String { + let last_two = last_two_digits(value); + let parsed = last_two.parse::().unwrap_or(0); + format!("{:02}", (parsed + 1) % 100) +} + +fn fallback_key(title: &str) -> String { + let mut key = String::from("sem-"); + for ch in title.chars() { + if ch.is_ascii_alphanumeric() { + key.push(ch.to_ascii_lowercase()); + } else if ch.is_whitespace() && !key.ends_with('-') { + key.push('-'); + } + if key.len() >= 32 { + break; + } + } + key.trim_end_matches('-').to_string() +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..d50eeef --- /dev/null +++ b/src/state.rs @@ -0,0 +1,85 @@ +use crate::Result; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct StateFile { + #[serde(default)] + pub profiles: HashMap, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ProfileState { + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub semesters: HashMap, + #[serde(default)] + pub courses: HashMap, + #[serde(default)] + pub files: HashMap, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct SemesterState { + pub id: String, + pub title: String, + pub key: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct CourseState { + pub id: String, + pub name: String, + pub semester_key: String, + #[serde(default)] + pub last_sync: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct FileState { + pub id: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub checksum: Option, + #[serde(default)] + pub last_downloaded: Option, + #[serde(default)] + pub remote_modified: Option, + #[serde(default)] + pub path_hint: Option, +} + +impl StateFile { + pub fn load_or_default(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => Ok(toml::from_str(&contents)?), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(err) => Err(err.into()), + } + } + + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = toml::to_string_pretty(self)?; + fs::write(path, contents)?; + Ok(()) + } + + pub fn profile_mut(&mut self, profile: &str) -> &mut ProfileState { + self.profiles + .entry(profile.to_string()) + .or_insert_with(ProfileState::default) + } + + pub fn profile(&self, profile: &str) -> Option<&ProfileState> { + self.profiles.get(profile) + } +} diff --git a/src/studip_client.rs b/src/studip_client.rs new file mode 100644 index 0000000..0763972 --- /dev/null +++ b/src/studip_client.rs @@ -0,0 +1,307 @@ +use crate::{Result, config::ConfigProfile}; +use anyhow::{Context, anyhow, bail}; +use reqwest::{ + Client, Response, + header::{AUTHORIZATION, HeaderValue}, +}; +use serde::{Deserialize, de::DeserializeOwned}; +use url::Url; + +#[derive(Clone)] +pub struct StudipClient { + http: Client, + base: Url, + root: Url, + auth_header: HeaderValue, +} + +impl StudipClient { + pub fn from_profile(profile: &ConfigProfile) -> Result { + let auth_b64 = profile.basic_auth_b64.as_ref().ok_or_else(|| { + anyhow!("No credentials configured for this profile. Run `studip-sync auth` first.") + })?; + + let mut auth_header = HeaderValue::from_str(&format!("Basic {}", auth_b64)) + .context("Invalid base64 credentials")?; + auth_header.set_sensitive(true); + + let (base, root) = build_root_and_api_urls(profile)?; + + let http = Client::builder() + .user_agent("studip-sync/0.1") + .build() + .context("Failed to build HTTP client")?; + + Ok(Self { + http, + base, + root, + auth_header, + }) + } + + pub async fn current_user(&self) -> Result { + let url = self.endpoint("users/me")?; + self.get_json(url).await + } + + pub async fn semester(&self, semester_id: &str) -> Result { + let url = self.endpoint(&format!("semesters/{semester_id}"))?; + self.get_json(url).await + } + + pub async fn list_courses(&self, user_id: &str) -> Result> { + let path = format!("users/{user_id}/courses"); + self.fetch_all_pages(&path).await + } + + pub async fn list_course_folders(&self, course_id: &str) -> Result> { + let path = format!("courses/{course_id}/folders"); + self.fetch_all_pages(&path).await + } + + pub async fn list_subfolders(&self, folder_id: &str) -> Result> { + let path = format!("folders/{folder_id}/folders"); + self.fetch_all_pages(&path).await + } + + pub async fn list_file_refs(&self, folder_id: &str) -> Result> { + let path = format!("folders/{folder_id}/file-refs"); + self.fetch_all_pages(&path).await + } + + pub async fn download_file(&self, relative_path: &str) -> Result { + let url = self.download_endpoint(relative_path)?; + self.send_request(url).await + } + + fn endpoint(&self, path: &str) -> Result { + let normalized = path.trim_start_matches('/'); + self.root.join(normalized).map_err(Into::into) + } + + async fn get_json(&self, url: Url) -> Result + where + T: DeserializeOwned, + { + self.send_request(url) + .await? + .json::() + .await + .map_err(Into::into) + } + + async fn fetch_all_pages(&self, path: &str) -> Result> + where + T: DeserializeOwned, + { + let mut offset = 0usize; + let limit = 100usize; + let mut items = Vec::new(); + + loop { + let mut url = self.endpoint(path)?; + { + let mut pairs = url.query_pairs_mut(); + pairs.append_pair("page[offset]", &offset.to_string()); + pairs.append_pair("page[limit]", &limit.to_string()); + } + + let ListResponse { data, meta, .. } = self.get_json(url).await?; + let count = data.len(); + items.extend(data); + + let total = meta + .and_then(|meta| meta.page) + .map(|page| page.total) + .unwrap_or(offset + count); + + if offset + limit >= total || count == 0 { + break; + } + offset += limit; + } + + Ok(items) + } + fn download_endpoint(&self, path: &str) -> Result { + let normalized = path.trim_start_matches('/'); + self.base.join(normalized).map_err(Into::into) + } + + async fn send_request(&self, url: Url) -> Result { + let response = self + .http + .get(url.clone()) + .header(AUTHORIZATION, self.auth_header.clone()) + .send() + .await + .with_context(|| format!("GET {}", url))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("Stud.IP request failed ({status}) - {body}"); + } + + Ok(response) + } +} + +fn build_root_and_api_urls(profile: &ConfigProfile) -> Result<(Url, Url)> { + let base = profile.base_url.trim_end_matches('/'); + let jsonapi_path = profile.jsonapi_path.trim_start_matches('/'); + + let base_url = Url::parse(base).context("Invalid base_url")?; + let mut api_url = base_url.clone(); + api_url.set_path(jsonapi_path); + if !api_url.path().ends_with('/') { + api_url.set_path(&format!("{}/", api_url.path().trim_end_matches('/'))); + } + + Ok((base_url, api_url)) +} + +#[derive(Debug, Deserialize)] +pub struct UserResponse { + pub data: UserData, +} + +#[derive(Debug, Deserialize)] +pub struct UserData { + pub id: String, + pub attributes: UserAttributes, +} + +#[derive(Debug, Deserialize)] +pub struct UserAttributes { + pub username: String, + #[serde(rename = "formatted-name")] + pub formatted_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct SemesterResponse { + pub data: SemesterData, +} + +#[derive(Debug, Deserialize)] +pub struct SemesterData { + pub id: String, + pub attributes: SemesterAttributes, +} + +#[derive(Debug, Deserialize)] +pub struct SemesterAttributes { + pub title: String, +} + +#[derive(Debug, Deserialize)] +pub struct Course { + pub id: String, + pub attributes: CourseAttributes, + pub relationships: CourseRelationships, +} + +#[derive(Debug, Deserialize)] +pub struct CourseAttributes { + #[serde(rename = "course-number")] + pub course_number: Option, + pub title: String, + #[serde(default)] + pub subtitle: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CourseRelationships { + #[serde(rename = "start-semester")] + pub start_semester: Option, + #[serde(rename = "end-semester")] + pub end_semester: Option, +} + +impl CourseRelationships { + pub fn semester_id(&self) -> Option<&str> { + self.start_semester + .as_ref() + .and_then(|rel| rel.data.as_ref()) + .map(|link| link.id.as_str()) + } +} + +#[derive(Debug, Deserialize)] +pub struct Relationship { + pub data: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ResourceIdentifier { + #[serde(rename = "type")] + pub resource_type: String, + pub id: String, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + pub data: Vec, + pub meta: Option, +} + +#[derive(Debug, Deserialize)] +struct ListMeta { + pub page: Option, +} + +#[derive(Debug, Deserialize)] +struct PageMeta { + #[allow(dead_code)] + pub offset: usize, + #[allow(dead_code)] + pub limit: usize, + pub total: usize, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Folder { + pub id: String, + pub attributes: FolderAttributes, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct FolderAttributes { + #[serde(rename = "folder-type")] + pub folder_type: String, + pub name: String, + #[serde(rename = "is-readable")] + pub is_readable: bool, + #[serde(rename = "is-visible")] + pub is_visible: bool, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct FileRef { + pub id: String, + pub attributes: FileRefAttributes, + pub meta: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct FileRefAttributes { + pub name: String, + #[serde(rename = "mkdate")] + pub created: String, + #[serde(rename = "chdate")] + pub modified: String, + #[serde(rename = "filesize")] + #[serde(default)] + pub file_size: Option, + #[serde(rename = "mime-type")] + #[serde(default)] + pub mime_type: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct FileRefMeta { + #[serde(rename = "download-url")] + pub download_url: String, +}